From 3707127edfb075d26f6f14c940702974ed887f33 Mon Sep 17 00:00:00 2001 From: ned Date: Sat, 25 Feb 2023 14:59:16 +0100 Subject: [PATCH] initial commit --- .github/workflows/build.yml | 24 ++ .gitignore | 8 + .../project.pbxproj | 366 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../ExpandableTextExample/ContentView.swift | 47 +++ .../ExpandableTextExampleApp.swift | 17 + .../Preview Assets.xcassets/Contents.json | 6 + LICENSE | 24 ++ Package.swift | 31 ++ README.md | 92 +++++ .../ExpandableText+Modifiers.swift | 100 +++++ Sources/ExpandableText/ExpandableText.swift | 113 ++++++ Sources/Utilities/OverlayAdapter.swift | 26 ++ Sources/Utilities/TruncationTextMask.swift | 54 +++ Sources/Utilities/ViewSizeReader.swift | 26 ++ 19 files changed, 979 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Example/ExpandableTextExample.xcodeproj/project.pbxproj create mode 100644 Example/ExpandableTextExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/ExpandableTextExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/ExpandableTextExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/ExpandableTextExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/ExpandableTextExample/Assets.xcassets/Contents.json create mode 100644 Example/ExpandableTextExample/ContentView.swift create mode 100644 Example/ExpandableTextExample/ExpandableTextExampleApp.swift create mode 100644 Example/ExpandableTextExample/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/ExpandableText/ExpandableText+Modifiers.swift create mode 100644 Sources/ExpandableText/ExpandableText.swift create mode 100644 Sources/Utilities/OverlayAdapter.swift create mode 100644 Sources/Utilities/TruncationTextMask.swift create mode 100644 Sources/Utilities/ViewSizeReader.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f1521ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: | + xcodebuild \ + -scheme ExpandableText \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 14' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fdeb3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.netrc +.swiftpm diff --git a/Example/ExpandableTextExample.xcodeproj/project.pbxproj b/Example/ExpandableTextExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f16db83 --- /dev/null +++ b/Example/ExpandableTextExample.xcodeproj/project.pbxproj @@ -0,0 +1,366 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 81189B9F29AA554F003AEFC0 /* ExpandableText in Frameworks */ = {isa = PBXBuildFile; productRef = 81189B9E29AA554F003AEFC0 /* ExpandableText */; }; + 816E11FA29AA525C0006F0B1 /* ExpandableTextExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816E11F929AA525C0006F0B1 /* ExpandableTextExampleApp.swift */; }; + 816E11FC29AA525C0006F0B1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816E11FB29AA525C0006F0B1 /* ContentView.swift */; }; + 816E11FE29AA525D0006F0B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 816E11FD29AA525D0006F0B1 /* Assets.xcassets */; }; + 816E120129AA525D0006F0B1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 816E120029AA525D0006F0B1 /* Preview Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 81189B9C29AA5524003AEFC0 /* ExpandableText */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ExpandableText; path = ..; sourceTree = ""; }; + 816E11F629AA525C0006F0B1 /* ExpandableTextExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExpandableTextExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 816E11F929AA525C0006F0B1 /* ExpandableTextExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableTextExampleApp.swift; sourceTree = ""; }; + 816E11FB29AA525C0006F0B1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 816E11FD29AA525D0006F0B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 816E120029AA525D0006F0B1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 816E11F329AA525C0006F0B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 81189B9F29AA554F003AEFC0 /* ExpandableText in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 81189B9D29AA554F003AEFC0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 816E11ED29AA525B0006F0B1 = { + isa = PBXGroup; + children = ( + 81189B9C29AA5524003AEFC0 /* ExpandableText */, + 816E11F829AA525C0006F0B1 /* ExpandableTextExample */, + 816E11F729AA525C0006F0B1 /* Products */, + 81189B9D29AA554F003AEFC0 /* Frameworks */, + ); + sourceTree = ""; + }; + 816E11F729AA525C0006F0B1 /* Products */ = { + isa = PBXGroup; + children = ( + 816E11F629AA525C0006F0B1 /* ExpandableTextExample.app */, + ); + name = Products; + sourceTree = ""; + }; + 816E11F829AA525C0006F0B1 /* ExpandableTextExample */ = { + isa = PBXGroup; + children = ( + 816E11F929AA525C0006F0B1 /* ExpandableTextExampleApp.swift */, + 816E11FB29AA525C0006F0B1 /* ContentView.swift */, + 816E11FD29AA525D0006F0B1 /* Assets.xcassets */, + 816E11FF29AA525D0006F0B1 /* Preview Content */, + ); + path = ExpandableTextExample; + sourceTree = ""; + }; + 816E11FF29AA525D0006F0B1 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 816E120029AA525D0006F0B1 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 816E11F529AA525C0006F0B1 /* ExpandableTextExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 816E120429AA525D0006F0B1 /* Build configuration list for PBXNativeTarget "ExpandableTextExample" */; + buildPhases = ( + 816E11F229AA525C0006F0B1 /* Sources */, + 816E11F329AA525C0006F0B1 /* Frameworks */, + 816E11F429AA525C0006F0B1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ExpandableTextExample; + packageProductDependencies = ( + 81189B9E29AA554F003AEFC0 /* ExpandableText */, + ); + productName = ExpandableTextExample; + productReference = 816E11F629AA525C0006F0B1 /* ExpandableTextExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 816E11EE29AA525B0006F0B1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 816E11F529AA525C0006F0B1 = { + CreatedOnToolsVersion = 14.2; + }; + }; + }; + buildConfigurationList = 816E11F129AA525B0006F0B1 /* Build configuration list for PBXProject "ExpandableTextExample" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 816E11ED29AA525B0006F0B1; + productRefGroup = 816E11F729AA525C0006F0B1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 816E11F529AA525C0006F0B1 /* ExpandableTextExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 816E11F429AA525C0006F0B1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 816E120129AA525D0006F0B1 /* Preview Assets.xcassets in Resources */, + 816E11FE29AA525D0006F0B1 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 816E11F229AA525C0006F0B1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 816E11FC29AA525C0006F0B1 /* ContentView.swift in Sources */, + 816E11FA29AA525C0006F0B1 /* ExpandableTextExampleApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 816E120229AA525D0006F0B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 816E120329AA525D0006F0B1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 816E120529AA525D0006F0B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ExpandableTextExample/Preview Content\""; + DEVELOPMENT_TEAM = 8PT88A387A; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = it.ned.ExpandableTextExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 816E120629AA525D0006F0B1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ExpandableTextExample/Preview Content\""; + DEVELOPMENT_TEAM = 8PT88A387A; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = it.ned.ExpandableTextExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 816E11F129AA525B0006F0B1 /* Build configuration list for PBXProject "ExpandableTextExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 816E120229AA525D0006F0B1 /* Debug */, + 816E120329AA525D0006F0B1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 816E120429AA525D0006F0B1 /* Build configuration list for PBXNativeTarget "ExpandableTextExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 816E120529AA525D0006F0B1 /* Debug */, + 816E120629AA525D0006F0B1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 81189B9E29AA554F003AEFC0 /* ExpandableText */ = { + isa = XCSwiftPackageProductDependency; + productName = ExpandableText; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 816E11EE29AA525B0006F0B1 /* Project object */; +} diff --git a/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/ExpandableTextExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/ExpandableTextExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/ExpandableTextExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/ExpandableTextExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/ExpandableTextExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/ExpandableTextExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Example/ExpandableTextExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/ExpandableTextExample/Assets.xcassets/Contents.json b/Example/ExpandableTextExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/ExpandableTextExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/ExpandableTextExample/ContentView.swift b/Example/ExpandableTextExample/ContentView.swift new file mode 100644 index 0000000..f07b110 --- /dev/null +++ b/Example/ExpandableTextExample/ContentView.swift @@ -0,0 +1,47 @@ +// +// ContentView.swift +// ExpandableTextExample +// +// Created by ned on 25/02/23. +// + +import SwiftUI +import ExpandableText + +struct ContentView: View { + + let loremIpsum = #"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."# + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ExpandableText(loremIpsum) + .border(.red) + + ExpandableText(loremIpsum) + .border(.red) + .environment(\.layoutDirection, .rightToLeft) + + ExpandableText(loremIpsum) + .font(.system(.headline, design: .rounded)) + .foregroundColor(.secondary) + .lineLimit(4) + .moreButtonText("read more") + .moreButtonFont(.system(.headline, design: .rounded).bold()) + .moreButtonColor(.red) + .expandAnimation(.easeInOut(duration: 2)) + .trimMultipleNewlinesWhenTruncated(false) + .border(.red) + + Spacer() + } + .padding() + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/Example/ExpandableTextExample/ExpandableTextExampleApp.swift b/Example/ExpandableTextExample/ExpandableTextExampleApp.swift new file mode 100644 index 0000000..51be0d4 --- /dev/null +++ b/Example/ExpandableTextExample/ExpandableTextExampleApp.swift @@ -0,0 +1,17 @@ +// +// ExpandableTextExampleApp.swift +// ExpandableTextExample +// +// Created by ned on 25/02/23. +// + +import SwiftUI + +@main +struct ExpandableTextExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Example/ExpandableTextExample/Preview Content/Preview Assets.xcassets/Contents.json b/Example/ExpandableTextExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/ExpandableTextExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4e0d394 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ExpandableText", + platforms: [ + .iOS(.v13) + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "ExpandableText", + targets: ["ExpandableText"] + ), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ExpandableText", + dependencies: [], + path: "Sources" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e69fe4 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# ExpandableText +[![build](https://github.com/n3d1117/ExpandableText/actions/workflows/build.yml/badge.svg)](https://github.com/n3d1117/ExpandableText/actions/workflows/build.yml) +[![swift-version](https://img.shields.io/badge/swift-5.7-orange.svg)](https://github.com/apple/swift/) +[![ios-version](https://img.shields.io/badge/ios-13.0+-brightgreen.svg)](https://github.com/apple/ios/) +[![xcode-version](https://img.shields.io/badge/xcode-14.2-blue)](https://developer.apple.com/xcode/) +[![license](https://img.shields.io/badge/license-The%20Unlicense-yellow.svg)](LICENSE) + +An expandable text view that displays a truncated version of its contents with a "show more" button that expands the view to show the full contents. + +iOS 13+ compatible, fully customizable, written in SwiftUI. + +## Installation +Available via the [Swift Package Manager](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). Requires iOS 13+. + +``` +https://github.com/n3d1117/ExpandableText +``` + +## Features +- Customizable line limit +- Customizable font, color, and `more` button appearance with SwiftUI-like modifiers +- Automatically hide `more` button if the whole text fits within the view +- Support right-to-left languages +- Support custom expand animation +- Automatically trim multiple new lines when truncated (can be disabled) + +## Usage + +### Basic usage + + + + + +
+ +```swift +import ExpandableText + +let loremIpsum = """ +Lorem ipsum dolor sit amet, consectetur adipiscing +elit, sed do eiusmod tempor incididunt ut labore et +dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip +ex ea commodo consequat. Duis aute irure dolor in +reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. +""" + +ExpandableText(loremIpsum) + .border(.red) +``` + + +![Basic usage demo](https://user-images.githubusercontent.com/11541888/221367314-5e59b284-41a9-43d2-9ac2-4d51ee3bc46b.png) +
+ +### Customization options + + + + + +
+ +```swift +ExpandableText(loremIpsum) + .font(.headline) + .foregroundColor(.secondary) + .lineLimit(4) + .moreButtonText("read more") + .moreButtonFont(.headline.bold()) + .moreButtonColor(.red) + .expandAnimation(.easeInOut(duration: 2)) + .trimMultipleNewlinesWhenTruncated(false) + .border(.red) +``` + + +![Customization demo](https://user-images.githubusercontent.com/11541888/221367312-3062bd32-5eae-45d4-bf3a-0474985cb712.png) +
+ +## Credits +- [NuPlay/ExpandableText](https://github.com/NuPlay/ExpandableText) for inspiration and some portions of code + +## License +Available under The Unlicense license. See [LICENSE](LICENSE) file for further information. + + + + + diff --git a/Sources/ExpandableText/ExpandableText+Modifiers.swift b/Sources/ExpandableText/ExpandableText+Modifiers.swift new file mode 100644 index 0000000..7b095b5 --- /dev/null +++ b/Sources/ExpandableText/ExpandableText+Modifiers.swift @@ -0,0 +1,100 @@ +// +// ExpandableText+Modifiers.swift +// +// +// Created by ned on 25/02/23. +// + +import Foundation +import SwiftUI + +public extension ExpandableText { + + /** + Sets the font for the text in the `ExpandableText` instance. + - Parameter font: The font to use for the text. Defaults to `body` + - Returns: A new `ExpandableText` instance with the specified font applied. + */ + func font(_ font: Font) -> Self { + var copy = self + copy.font = font + return copy + } + + /** + Sets the foreground color for the text in the `ExpandableText` instance. + - Parameter color: The foreground color to use for the text. Defaults to `primary` + - Returns: A new `ExpandableText` instance with the specified foreground color applied. + */ + func foregroundColor(_ color: Color) -> Self { + var copy = self + copy.color = color + return copy + } + + /** + Sets the maximum number of lines to use for rendering the text in the `ExpandableText` instance. + - Parameter limit: The maximum number of lines to use for rendering the text. Defaults to `3` + - Returns: A new `ExpandableText` instance with the specified line limit applied. + */ + func lineLimit(_ limit: Int) -> Self { + var copy = self + copy.lineLimit = limit + return copy + } + + /** + Sets the text to use for the "show more" button in the `ExpandableText` instance. + - Parameter moreText: The text to use for the "show more" button. Defaults to `more` + - Returns: A new `ExpandableText` instance with the specified "show more" button text applied. + */ + func moreButtonText(_ moreText: String) -> Self { + var copy = self + copy.moreButtonText = moreText + return copy + } + + /** + Sets the font to use for the "show more" button in the `ExpandableText` instance. + - Parameter font: The font to use for the "show more" button. Defaults to the same font as the text + - Returns: A new `ExpandableText` instance with the specified "show more" button font applied. + */ + func moreButtonFont(_ font: Font) -> Self { + var copy = self + copy.moreButtonFont = font + return copy + } + + /** + Sets the color to use for the "show more" button in the `ExpandableText` instance. + - Parameter color: The color to use for the "show more" button. Defaults to `accentColor` + - Returns: A new `ExpandableText` instance with the specified "show more" button color applied. + */ + func moreButtonColor(_ color: Color) -> Self { + var copy = self + copy.moreButtonColor = color + return copy + } + + /** + Sets the animation to use when expanding the `ExpandableText` instance. + - Parameter animation: The animation to use for the expansion. Defaults to `default` + - Returns: A new `ExpandableText` instance with the specified expansion animation applied. + */ + func expandAnimation(_ animation: Animation) -> Self { + var copy = self + copy.expandAnimation = animation + return copy + } + + /** + Sets whether multiple consecutive newline characters should be trimmed when truncating the text in the `ExpandableText` instance. + - Parameter value: A boolean value indicating whether to trim multiple consecutive newline characters. Defaults to `true` + - Returns: A new `ExpandableText` instance with the specified trimming behavior applied. + */ + func trimMultipleNewlinesWhenTruncated(_ value: Bool) -> Self { + var copy = self + copy.trimMultipleNewlinesWhenTruncated = value + return copy + } +} diff --git a/Sources/ExpandableText/ExpandableText.swift b/Sources/ExpandableText/ExpandableText.swift new file mode 100644 index 0000000..87c6de0 --- /dev/null +++ b/Sources/ExpandableText/ExpandableText.swift @@ -0,0 +1,113 @@ +// +// ExpandableText.swift +// ExpandableText +// +// Created by ned on 23/02/23. +// + +import Foundation +import SwiftUI + +/** +An expandable text view that displays a truncated version of its contents with a "show more" button that expands the view to show the full contents. + + To create a new ExpandableText view, use the init method and provide the initial text string as a parameter. The text string will be automatically trimmed of any leading or trailing whitespace and newline characters. + +Example usage with default parameters: + ```swift +ExpandableText("Lorem ipsum dolor sit amet, consectetur adipiscing elit...") + .font(.body) + .foregroundColor(.primary) + .lineLimit(3) + .moreButtonText("more") + .moreButtonColor(.accentColor) + .expandAnimation(.default) + .trimMultipleNewlinesWhenTruncated(true) + ``` +*/ +public struct ExpandableText: View { + + @State private var isExpanded: Bool = false + @State private var isTruncated: Bool = false + + @State private var intrinsicSize: CGSize = .zero + @State private var truncatedSize: CGSize = .zero + @State private var moreTextSize: CGSize = .zero + + private let text: String + internal var font: Font = .body + internal var color: Color = .primary + internal var lineLimit: Int = 3 + internal var moreButtonText: String = "more" + internal var moreButtonFont: Font? + internal var moreButtonColor: Color = .accentColor + internal var expandAnimation: Animation = .default + internal var trimMultipleNewlinesWhenTruncated: Bool = true + + /** + Initializes a new `ExpandableText` instance with the specified text string, trimmed of any leading or trailing whitespace and newline characters. + - Parameter text: The initial text string to display in the `ExpandableText` view. + - Returns: A new `ExpandableText` instance with the specified text string and trimming applied. + */ + public init(_ text: String) { + self.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public var body: some View { + content + .lineLimit(isExpanded ? nil : lineLimit) + .applyingTruncationMask(moreTextSize: moreTextSize, enabled: !isExpanded && isTruncated) + .readSize { size in + truncatedSize = size + isTruncated = truncatedSize != intrinsicSize + } + .background( + content + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .readSize { size in + intrinsicSize = size + isTruncated = truncatedSize != intrinsicSize + } + ) + .background( + Text(moreButtonText) + .font(moreButtonFont ?? font) + .hidden() + .readSize { moreTextSize = $0 } + ) + .contentShape(Rectangle()) + .onTapGesture { + if !isExpanded, isTruncated { + withAnimation { isExpanded.toggle() } + } + } + .modifier(OverlayAdapter(alignment: .trailingLastTextBaseline, view: { + if !isExpanded, isTruncated { + Button { + withAnimation(expandAnimation) { isExpanded.toggle() } + } label: { + Text(moreButtonText) + .font(moreButtonFont ?? font) + .foregroundColor(moreButtonColor) + } + } + })) + } + + private var content: some View { + Text(.init( + trimMultipleNewlinesWhenTruncated + ? (!isExpanded && isTruncated ? textTrimmingDoubleNewlines : text) + : text + )) + .font(font) + .foregroundColor(color) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var textTrimmingDoubleNewlines: String { + text.replacingOccurrences(of: #"\n\s*\n"#, with: "\n", options: .regularExpression) + } +} diff --git a/Sources/Utilities/OverlayAdapter.swift b/Sources/Utilities/OverlayAdapter.swift new file mode 100644 index 0000000..db3baf2 --- /dev/null +++ b/Sources/Utilities/OverlayAdapter.swift @@ -0,0 +1,26 @@ +// +// OverlayAdapter.swift +// +// +// Created by ned on 25/02/23. +// + +import SwiftUI + +internal struct OverlayAdapter: ViewModifier { + let alignment: Alignment + let view: () -> V + + init(alignment: Alignment, @ViewBuilder view: @escaping () -> V) { + self.alignment = alignment + self.view = view + } + + func body(content: Content) -> some View { + if #available(iOS 15.0, *) { + content.overlay(alignment: alignment, content: view) + } else { + content.overlay(view(), alignment: alignment) + } + } +} diff --git a/Sources/Utilities/TruncationTextMask.swift b/Sources/Utilities/TruncationTextMask.swift new file mode 100644 index 0000000..b22fa9d --- /dev/null +++ b/Sources/Utilities/TruncationTextMask.swift @@ -0,0 +1,54 @@ +// +// TruncationTextMask.swift +// +// +// Created by ned on 25/02/23. +// + +import SwiftUI + +private struct TruncationTextMask: ViewModifier { + + let size: CGSize + let enabled: Bool + + @Environment(\.layoutDirection) private var layoutDirection + + func body(content: Content) -> some View { + if enabled { + content + .mask( + VStack(spacing: 0) { + Rectangle() + HStack(spacing: 0){ + Rectangle() + HStack(spacing: 0) { + LinearGradient( + gradient: Gradient(stops: [ + Gradient.Stop(color: .black, location: 0), + Gradient.Stop(color: .clear, location: 0.9) + ]), + startPoint: layoutDirection == .rightToLeft ? .trailing : .leading, + endPoint: layoutDirection == .rightToLeft ? .leading : .trailing + ) + .frame(width: size.width, height: size.height) + + Rectangle() + .foregroundColor(.clear) + .frame(width: size.width) + } + }.frame(height: size.height) + } + ) + } else { + content + .fixedSize(horizontal: false, vertical: true) + } + } +} + +internal extension View { + func applyingTruncationMask(moreTextSize: CGSize, enabled: Bool) -> some View { + modifier(TruncationTextMask(size: moreTextSize, enabled: enabled)) + } +} diff --git a/Sources/Utilities/ViewSizeReader.swift b/Sources/Utilities/ViewSizeReader.swift new file mode 100644 index 0000000..0ab9ee1 --- /dev/null +++ b/Sources/Utilities/ViewSizeReader.swift @@ -0,0 +1,26 @@ +// +// ViewSizeReader.swift +// +// +// Created by ned on 25/02/23. +// + +import SwiftUI + +// https://www.fivestars.blog/articles/swiftui-share-layout-information/ +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} +} + +internal extension View { + func readSize(onChange: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { geometryProxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: geometryProxy.size) + } + ) + .onPreferenceChange(SizePreferenceKey.self, perform: onChange) + } +}