├── .gitignore ├── Assets ├── Advanced.png ├── Animations.png ├── AppIcon.png ├── Basics.png ├── Customization.png ├── ExampleApp.png ├── ExampleAppHeaders.png ├── General.png ├── Header.png ├── Result.png ├── SocialPreview.png ├── Styles.png ├── SwipeActions.mp4 └── SwipeActions.png ├── Example ├── SwipeActionsExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── SwipeActionsExample │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon.png │ │ └── Contents.json │ ├── Contents.json │ └── SwipeActions.imageset │ │ ├── Contents.json │ │ └── SwipeActions.png │ ├── Container.swift │ ├── ContentView+Advanced.swift │ ├── ContentView+Animations.swift │ ├── ContentView+Basic.swift │ ├── ContentView+Customization.swift │ ├── ContentView+Group.swift │ ├── ContentView+Styles.swift │ ├── ContentView.swift │ ├── DebugView.swift │ └── SwipeActionsExampleApp.swift ├── LICENSE ├── Package.swift ├── README.md └── Sources └── SwipeActions.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | 11 | .swiftpm 12 | -------------------------------------------------------------------------------- /Assets/Advanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Advanced.png -------------------------------------------------------------------------------- /Assets/Animations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Animations.png -------------------------------------------------------------------------------- /Assets/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/AppIcon.png -------------------------------------------------------------------------------- /Assets/Basics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Basics.png -------------------------------------------------------------------------------- /Assets/Customization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Customization.png -------------------------------------------------------------------------------- /Assets/ExampleApp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/ExampleApp.png -------------------------------------------------------------------------------- /Assets/ExampleAppHeaders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/ExampleAppHeaders.png -------------------------------------------------------------------------------- /Assets/General.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/General.png -------------------------------------------------------------------------------- /Assets/Header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Header.png -------------------------------------------------------------------------------- /Assets/Result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Result.png -------------------------------------------------------------------------------- /Assets/SocialPreview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/SocialPreview.png -------------------------------------------------------------------------------- /Assets/Styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/Styles.png -------------------------------------------------------------------------------- /Assets/SwipeActions.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/SwipeActions.mp4 -------------------------------------------------------------------------------- /Assets/SwipeActions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Assets/SwipeActions.png -------------------------------------------------------------------------------- /Example/SwipeActionsExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3C338E8529E7B10C00F85B55 /* SwipeActionsExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E8429E7B10C00F85B55 /* SwipeActionsExampleApp.swift */; }; 11 | 3C338E8729E7B10C00F85B55 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E8629E7B10C00F85B55 /* ContentView.swift */; }; 12 | 3C338E8929E7B10E00F85B55 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C338E8829E7B10E00F85B55 /* Assets.xcassets */; }; 13 | 3C338E9829E7B14C00F85B55 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9229E7B14C00F85B55 /* Container.swift */; }; 14 | 3C338E9929E7B14C00F85B55 /* ContentView+Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9329E7B14C00F85B55 /* ContentView+Styles.swift */; }; 15 | 3C338E9A29E7B14C00F85B55 /* ContentView+Advanced.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9429E7B14C00F85B55 /* ContentView+Advanced.swift */; }; 16 | 3C338E9B29E7B14C00F85B55 /* ContentView+Customization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9529E7B14C00F85B55 /* ContentView+Customization.swift */; }; 17 | 3C338E9C29E7B14C00F85B55 /* ContentView+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9629E7B14C00F85B55 /* ContentView+Animations.swift */; }; 18 | 3C338E9D29E7B14C00F85B55 /* ContentView+Basic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C338E9729E7B14C00F85B55 /* ContentView+Basic.swift */; }; 19 | 3C338EA029E7B16E00F85B55 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = 3C338E9F29E7B16E00F85B55 /* SwipeActions */; }; 20 | 3CB7C4BA29E7BDC600D522C4 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB7C4B929E7BDC600D522C4 /* DebugView.swift */; }; 21 | 3CB7C4D629E8C49400D522C4 /* ContentView+Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB7C4D529E8C49400D522C4 /* ContentView+Group.swift */; }; 22 | /* End PBXBuildFile section */ 23 | 24 | /* Begin PBXFileReference section */ 25 | 3C338E8129E7B10C00F85B55 /* SwipeActionsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwipeActionsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 3C338E8429E7B10C00F85B55 /* SwipeActionsExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsExampleApp.swift; sourceTree = ""; }; 27 | 3C338E8629E7B10C00F85B55 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 28 | 3C338E8829E7B10E00F85B55 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 29 | 3C338E9229E7B14C00F85B55 /* Container.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; 30 | 3C338E9329E7B14C00F85B55 /* ContentView+Styles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContentView+Styles.swift"; sourceTree = ""; }; 31 | 3C338E9429E7B14C00F85B55 /* ContentView+Advanced.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContentView+Advanced.swift"; sourceTree = ""; }; 32 | 3C338E9529E7B14C00F85B55 /* ContentView+Customization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContentView+Customization.swift"; sourceTree = ""; }; 33 | 3C338E9629E7B14C00F85B55 /* ContentView+Animations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContentView+Animations.swift"; sourceTree = ""; }; 34 | 3C338E9729E7B14C00F85B55 /* ContentView+Basic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContentView+Basic.swift"; sourceTree = ""; }; 35 | 3C67AF2D29F9957100BB37A8 /* SwipeActions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SwipeActions; path = ..; sourceTree = ""; }; 36 | 3CB7C4B929E7BDC600D522C4 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; 37 | 3CB7C4D529E8C49400D522C4 /* ContentView+Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+Group.swift"; sourceTree = ""; }; 38 | /* End PBXFileReference section */ 39 | 40 | /* Begin PBXFrameworksBuildPhase section */ 41 | 3C338E7E29E7B10C00F85B55 /* Frameworks */ = { 42 | isa = PBXFrameworksBuildPhase; 43 | buildActionMask = 2147483647; 44 | files = ( 45 | 3C338EA029E7B16E00F85B55 /* SwipeActions in Frameworks */, 46 | ); 47 | runOnlyForDeploymentPostprocessing = 0; 48 | }; 49 | /* End PBXFrameworksBuildPhase section */ 50 | 51 | /* Begin PBXGroup section */ 52 | 3C338E7829E7B10C00F85B55 = { 53 | isa = PBXGroup; 54 | children = ( 55 | 3C67AF2D29F9957100BB37A8 /* SwipeActions */, 56 | 3C338E8329E7B10C00F85B55 /* SwipeActionsExample */, 57 | 3C338E8229E7B10C00F85B55 /* Products */, 58 | ); 59 | sourceTree = ""; 60 | }; 61 | 3C338E8229E7B10C00F85B55 /* Products */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | 3C338E8129E7B10C00F85B55 /* SwipeActionsExample.app */, 65 | ); 66 | name = Products; 67 | sourceTree = ""; 68 | }; 69 | 3C338E8329E7B10C00F85B55 /* SwipeActionsExample */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | 3C338E8429E7B10C00F85B55 /* SwipeActionsExampleApp.swift */, 73 | 3C338E8629E7B10C00F85B55 /* ContentView.swift */, 74 | 3C338E9729E7B14C00F85B55 /* ContentView+Basic.swift */, 75 | 3C338E9529E7B14C00F85B55 /* ContentView+Customization.swift */, 76 | 3C338E9329E7B14C00F85B55 /* ContentView+Styles.swift */, 77 | 3C338E9629E7B14C00F85B55 /* ContentView+Animations.swift */, 78 | 3C338E9429E7B14C00F85B55 /* ContentView+Advanced.swift */, 79 | 3CB7C4D529E8C49400D522C4 /* ContentView+Group.swift */, 80 | 3CB7C4B929E7BDC600D522C4 /* DebugView.swift */, 81 | 3C338E9229E7B14C00F85B55 /* Container.swift */, 82 | 3C338E8829E7B10E00F85B55 /* Assets.xcassets */, 83 | ); 84 | path = SwipeActionsExample; 85 | sourceTree = ""; 86 | }; 87 | /* End PBXGroup section */ 88 | 89 | /* Begin PBXNativeTarget section */ 90 | 3C338E8029E7B10C00F85B55 /* SwipeActionsExample */ = { 91 | isa = PBXNativeTarget; 92 | buildConfigurationList = 3C338E8F29E7B10E00F85B55 /* Build configuration list for PBXNativeTarget "SwipeActionsExample" */; 93 | buildPhases = ( 94 | 3C338E7D29E7B10C00F85B55 /* Sources */, 95 | 3C338E7E29E7B10C00F85B55 /* Frameworks */, 96 | 3C338E7F29E7B10C00F85B55 /* Resources */, 97 | ); 98 | buildRules = ( 99 | ); 100 | dependencies = ( 101 | ); 102 | name = SwipeActionsExample; 103 | packageProductDependencies = ( 104 | 3C338E9F29E7B16E00F85B55 /* SwipeActions */, 105 | ); 106 | productName = SwipeActionsExample; 107 | productReference = 3C338E8129E7B10C00F85B55 /* SwipeActionsExample.app */; 108 | productType = "com.apple.product-type.application"; 109 | }; 110 | /* End PBXNativeTarget section */ 111 | 112 | /* Begin PBXProject section */ 113 | 3C338E7929E7B10C00F85B55 /* Project object */ = { 114 | isa = PBXProject; 115 | attributes = { 116 | BuildIndependentTargetsInParallel = 1; 117 | LastSwiftUpdateCheck = 1420; 118 | LastUpgradeCheck = 1420; 119 | TargetAttributes = { 120 | 3C338E8029E7B10C00F85B55 = { 121 | CreatedOnToolsVersion = 14.2; 122 | }; 123 | }; 124 | }; 125 | buildConfigurationList = 3C338E7C29E7B10C00F85B55 /* Build configuration list for PBXProject "SwipeActionsExample" */; 126 | compatibilityVersion = "Xcode 14.0"; 127 | developmentRegion = en; 128 | hasScannedForEncodings = 0; 129 | knownRegions = ( 130 | en, 131 | Base, 132 | ); 133 | mainGroup = 3C338E7829E7B10C00F85B55; 134 | packageReferences = ( 135 | 3C338E9E29E7B16E00F85B55 /* XCRemoteSwiftPackageReference "SwipeActions" */, 136 | ); 137 | productRefGroup = 3C338E8229E7B10C00F85B55 /* Products */; 138 | projectDirPath = ""; 139 | projectRoot = ""; 140 | targets = ( 141 | 3C338E8029E7B10C00F85B55 /* SwipeActionsExample */, 142 | ); 143 | }; 144 | /* End PBXProject section */ 145 | 146 | /* Begin PBXResourcesBuildPhase section */ 147 | 3C338E7F29E7B10C00F85B55 /* Resources */ = { 148 | isa = PBXResourcesBuildPhase; 149 | buildActionMask = 2147483647; 150 | files = ( 151 | 3C338E8929E7B10E00F85B55 /* Assets.xcassets in Resources */, 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXResourcesBuildPhase section */ 156 | 157 | /* Begin PBXSourcesBuildPhase section */ 158 | 3C338E7D29E7B10C00F85B55 /* Sources */ = { 159 | isa = PBXSourcesBuildPhase; 160 | buildActionMask = 2147483647; 161 | files = ( 162 | 3C338E9929E7B14C00F85B55 /* ContentView+Styles.swift in Sources */, 163 | 3C338E8729E7B10C00F85B55 /* ContentView.swift in Sources */, 164 | 3C338E9829E7B14C00F85B55 /* Container.swift in Sources */, 165 | 3CB7C4BA29E7BDC600D522C4 /* DebugView.swift in Sources */, 166 | 3C338E9A29E7B14C00F85B55 /* ContentView+Advanced.swift in Sources */, 167 | 3C338E9B29E7B14C00F85B55 /* ContentView+Customization.swift in Sources */, 168 | 3C338E9D29E7B14C00F85B55 /* ContentView+Basic.swift in Sources */, 169 | 3C338E9C29E7B14C00F85B55 /* ContentView+Animations.swift in Sources */, 170 | 3CB7C4D629E8C49400D522C4 /* ContentView+Group.swift in Sources */, 171 | 3C338E8529E7B10C00F85B55 /* SwipeActionsExampleApp.swift in Sources */, 172 | ); 173 | runOnlyForDeploymentPostprocessing = 0; 174 | }; 175 | /* End PBXSourcesBuildPhase section */ 176 | 177 | /* Begin XCBuildConfiguration section */ 178 | 3C338E8D29E7B10E00F85B55 /* Debug */ = { 179 | isa = XCBuildConfiguration; 180 | buildSettings = { 181 | ALWAYS_SEARCH_USER_PATHS = NO; 182 | CLANG_ANALYZER_NONNULL = YES; 183 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 184 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 185 | CLANG_ENABLE_MODULES = YES; 186 | CLANG_ENABLE_OBJC_ARC = YES; 187 | CLANG_ENABLE_OBJC_WEAK = YES; 188 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 189 | CLANG_WARN_BOOL_CONVERSION = YES; 190 | CLANG_WARN_COMMA = YES; 191 | CLANG_WARN_CONSTANT_CONVERSION = YES; 192 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 193 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 194 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 195 | CLANG_WARN_EMPTY_BODY = YES; 196 | CLANG_WARN_ENUM_CONVERSION = YES; 197 | CLANG_WARN_INFINITE_RECURSION = YES; 198 | CLANG_WARN_INT_CONVERSION = YES; 199 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 200 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 201 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 202 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 203 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 204 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 205 | CLANG_WARN_STRICT_PROTOTYPES = YES; 206 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 207 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 208 | CLANG_WARN_UNREACHABLE_CODE = YES; 209 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 210 | COPY_PHASE_STRIP = NO; 211 | DEBUG_INFORMATION_FORMAT = dwarf; 212 | ENABLE_STRICT_OBJC_MSGSEND = YES; 213 | ENABLE_TESTABILITY = YES; 214 | GCC_C_LANGUAGE_STANDARD = gnu11; 215 | GCC_DYNAMIC_NO_PIC = NO; 216 | GCC_NO_COMMON_BLOCKS = YES; 217 | GCC_OPTIMIZATION_LEVEL = 0; 218 | GCC_PREPROCESSOR_DEFINITIONS = ( 219 | "DEBUG=1", 220 | "$(inherited)", 221 | ); 222 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 223 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 224 | GCC_WARN_UNDECLARED_SELECTOR = YES; 225 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 226 | GCC_WARN_UNUSED_FUNCTION = YES; 227 | GCC_WARN_UNUSED_VARIABLE = YES; 228 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 229 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 230 | MTL_FAST_MATH = YES; 231 | ONLY_ACTIVE_ARCH = YES; 232 | SDKROOT = iphoneos; 233 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 234 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 235 | }; 236 | name = Debug; 237 | }; 238 | 3C338E8E29E7B10E00F85B55 /* Release */ = { 239 | isa = XCBuildConfiguration; 240 | buildSettings = { 241 | ALWAYS_SEARCH_USER_PATHS = NO; 242 | CLANG_ANALYZER_NONNULL = YES; 243 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 244 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 245 | CLANG_ENABLE_MODULES = YES; 246 | CLANG_ENABLE_OBJC_ARC = YES; 247 | CLANG_ENABLE_OBJC_WEAK = YES; 248 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 249 | CLANG_WARN_BOOL_CONVERSION = YES; 250 | CLANG_WARN_COMMA = YES; 251 | CLANG_WARN_CONSTANT_CONVERSION = YES; 252 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 253 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 254 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 255 | CLANG_WARN_EMPTY_BODY = YES; 256 | CLANG_WARN_ENUM_CONVERSION = YES; 257 | CLANG_WARN_INFINITE_RECURSION = YES; 258 | CLANG_WARN_INT_CONVERSION = YES; 259 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 260 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 261 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 262 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 263 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 264 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 265 | CLANG_WARN_STRICT_PROTOTYPES = YES; 266 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 267 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 268 | CLANG_WARN_UNREACHABLE_CODE = YES; 269 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 270 | COPY_PHASE_STRIP = NO; 271 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 272 | ENABLE_NS_ASSERTIONS = NO; 273 | ENABLE_STRICT_OBJC_MSGSEND = YES; 274 | GCC_C_LANGUAGE_STANDARD = gnu11; 275 | GCC_NO_COMMON_BLOCKS = YES; 276 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 277 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 278 | GCC_WARN_UNDECLARED_SELECTOR = YES; 279 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 280 | GCC_WARN_UNUSED_FUNCTION = YES; 281 | GCC_WARN_UNUSED_VARIABLE = YES; 282 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 283 | MTL_ENABLE_DEBUG_INFO = NO; 284 | MTL_FAST_MATH = YES; 285 | SDKROOT = iphoneos; 286 | SWIFT_COMPILATION_MODE = wholemodule; 287 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 288 | VALIDATE_PRODUCT = YES; 289 | }; 290 | name = Release; 291 | }; 292 | 3C338E9029E7B10E00F85B55 /* Debug */ = { 293 | isa = XCBuildConfiguration; 294 | buildSettings = { 295 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 296 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 297 | CODE_SIGN_STYLE = Automatic; 298 | CURRENT_PROJECT_VERSION = 1; 299 | DEVELOPMENT_TEAM = WV6XDLHK3W; 300 | ENABLE_PREVIEWS = YES; 301 | GENERATE_INFOPLIST_FILE = YES; 302 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 303 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 304 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 305 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 306 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 307 | LD_RUNPATH_SEARCH_PATHS = ( 308 | "$(inherited)", 309 | "@executable_path/Frameworks", 310 | ); 311 | MARKETING_VERSION = 1.0; 312 | PRODUCT_BUNDLE_IDENTIFIER = app.getfind.SwipeActionsExample; 313 | PRODUCT_NAME = "$(TARGET_NAME)"; 314 | SWIFT_EMIT_LOC_STRINGS = YES; 315 | SWIFT_VERSION = 5.0; 316 | TARGETED_DEVICE_FAMILY = "1,2"; 317 | }; 318 | name = Debug; 319 | }; 320 | 3C338E9129E7B10E00F85B55 /* Release */ = { 321 | isa = XCBuildConfiguration; 322 | buildSettings = { 323 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 324 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 325 | CODE_SIGN_STYLE = Automatic; 326 | CURRENT_PROJECT_VERSION = 1; 327 | DEVELOPMENT_TEAM = WV6XDLHK3W; 328 | ENABLE_PREVIEWS = YES; 329 | GENERATE_INFOPLIST_FILE = YES; 330 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 331 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 332 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 333 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 334 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 335 | LD_RUNPATH_SEARCH_PATHS = ( 336 | "$(inherited)", 337 | "@executable_path/Frameworks", 338 | ); 339 | MARKETING_VERSION = 1.0; 340 | PRODUCT_BUNDLE_IDENTIFIER = app.getfind.SwipeActionsExample; 341 | PRODUCT_NAME = "$(TARGET_NAME)"; 342 | SWIFT_EMIT_LOC_STRINGS = YES; 343 | SWIFT_VERSION = 5.0; 344 | TARGETED_DEVICE_FAMILY = "1,2"; 345 | }; 346 | name = Release; 347 | }; 348 | /* End XCBuildConfiguration section */ 349 | 350 | /* Begin XCConfigurationList section */ 351 | 3C338E7C29E7B10C00F85B55 /* Build configuration list for PBXProject "SwipeActionsExample" */ = { 352 | isa = XCConfigurationList; 353 | buildConfigurations = ( 354 | 3C338E8D29E7B10E00F85B55 /* Debug */, 355 | 3C338E8E29E7B10E00F85B55 /* Release */, 356 | ); 357 | defaultConfigurationIsVisible = 0; 358 | defaultConfigurationName = Release; 359 | }; 360 | 3C338E8F29E7B10E00F85B55 /* Build configuration list for PBXNativeTarget "SwipeActionsExample" */ = { 361 | isa = XCConfigurationList; 362 | buildConfigurations = ( 363 | 3C338E9029E7B10E00F85B55 /* Debug */, 364 | 3C338E9129E7B10E00F85B55 /* Release */, 365 | ); 366 | defaultConfigurationIsVisible = 0; 367 | defaultConfigurationName = Release; 368 | }; 369 | /* End XCConfigurationList section */ 370 | 371 | /* Begin XCRemoteSwiftPackageReference section */ 372 | 3C338E9E29E7B16E00F85B55 /* XCRemoteSwiftPackageReference "SwipeActions" */ = { 373 | isa = XCRemoteSwiftPackageReference; 374 | repositoryURL = "https://github.com/aheze/SwipeActions"; 375 | requirement = { 376 | kind = upToNextMajorVersion; 377 | minimumVersion = 0.0.1; 378 | }; 379 | }; 380 | /* End XCRemoteSwiftPackageReference section */ 381 | 382 | /* Begin XCSwiftPackageProductDependency section */ 383 | 3C338E9F29E7B16E00F85B55 /* SwipeActions */ = { 384 | isa = XCSwiftPackageProductDependency; 385 | package = 3C338E9E29E7B16E00F85B55 /* XCRemoteSwiftPackageReference "SwipeActions" */; 386 | productName = SwipeActions; 387 | }; 388 | /* End XCSwiftPackageProductDependency section */ 389 | }; 390 | rootObject = 3C338E7929E7B10C00F85B55 /* Project object */; 391 | } 392 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Example/SwipeActionsExample/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/SwipeActions.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SwipeActions.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Assets.xcassets/SwipeActions.imageset/SwipeActions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aheze/SwipeActions/7f149902425a69d63a38fd9212a345994f6e3573/Example/SwipeActionsExample/Assets.xcassets/SwipeActions.imageset/SwipeActions.png -------------------------------------------------------------------------------- /Example/SwipeActionsExample/Container.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Container.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | struct Container: View { 13 | var title: String 14 | var details: [String] 15 | var cornerRadius = Double(32) 16 | @ViewBuilder var background: Background 17 | 18 | init(title: String, details: String..., @ViewBuilder background: () -> Background) { 19 | self.title = title 20 | self.details = details 21 | self.background = background() 22 | } 23 | 24 | var body: some View { 25 | VStack(alignment: .leading, spacing: 8) { 26 | Text(title) 27 | .font(.title3) 28 | .fontWeight(.bold) 29 | 30 | if !details.isEmpty { 31 | VStack(alignment: .leading, spacing: 4) { 32 | ForEach(details, id: \.self) { detail in 33 | Text(detail) 34 | .multilineTextAlignment(.leading) 35 | .font(.system(.caption, design: .monospaced)) 36 | } 37 | } 38 | } 39 | } 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | .padding(.horizontal, 24) 42 | .padding(.vertical, 24) 43 | .background(background) 44 | .mask( 45 | RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) 46 | ) 47 | } 48 | } 49 | 50 | extension Container { 51 | init(title: String, details: [String], cornerRadius: Double = 32, @ViewBuilder background: () -> Background) { 52 | self.title = title 53 | self.details = details 54 | self.cornerRadius = cornerRadius 55 | self.background = background() 56 | } 57 | } 58 | 59 | extension Container where Background == Color { 60 | init(title: String, details: String..., cornerRadius: Double = 32, backgroundColor: Color = .primary.opacity(0.05)) { 61 | self.init(title: title, details: details, cornerRadius: cornerRadius) { 62 | backgroundColor 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Advanced.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Advanced.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | import SwipeActions 12 | 13 | extension ContentView { 14 | @ViewBuilder var advanced: some View { 15 | TapToOpenView() 16 | 17 | SwipeView { 18 | Container( 19 | title: "Continuous / Single Swipe Gesture", 20 | details: 21 | #".swipeAllowSingleSwipeAcross(true)"#, 22 | #"/// Lets you show leading and trailing actions in a single swipe, instead of rubber-banding."# 23 | ) { 24 | LinearGradient( 25 | colors: [.yellow, .orange], 26 | startPoint: .bottomLeading, 27 | endPoint: .topTrailing 28 | ) 29 | .brightness(-0.5) 30 | } 31 | .foregroundColor(.white) 32 | } leadingActions: { _ in 33 | SwipeAction("Leading") {} 34 | } trailingActions: { context in 35 | SwipeAction("Trailing") {} 36 | } 37 | .swipeAllowSingleSwipeAcross(true) 38 | 39 | SwipeView { 40 | Container( 41 | title: "Adjusted Thresholds", 42 | details: 43 | #".allowSwipeToTrigger()"#, 44 | #".swipeReadyToExpandPadding(0)"#, 45 | #".swipeReadyToTriggerPadding(0)"#, 46 | #".swipeMinimumPointToTrigger(0)"#, 47 | #"/// Much easier to open, but harder to close."# 48 | ) { 49 | LinearGradient( 50 | colors: [.pink, .purple], 51 | startPoint: .bottomLeading, 52 | endPoint: .topTrailing 53 | ) 54 | .brightness(-0.5) 55 | } 56 | .foregroundColor(.white) 57 | } trailingActions: { context in 58 | SwipeAction("Trailing") {} 59 | .allowSwipeToTrigger() 60 | } 61 | .swipeReadyToExpandPadding(0) 62 | .swipeReadyToTriggerPadding(0) 63 | .swipeMinimumPointToTrigger(0) 64 | } 65 | } 66 | 67 | struct TapToOpenView: View { 68 | @State var open = PassthroughSubject() 69 | @State var pressing = false 70 | 71 | var body: some View { 72 | SwipeView { 73 | Button { 74 | open.send() 75 | } label: { 76 | Container( 77 | title: "Tap to Open!", 78 | details: 79 | #"@State var open = PassthroughSubject()"#, 80 | #"open.send()"#, 81 | #""#, 82 | #".onReceive(open) { _ in"#, 83 | #" context.wrappedValue.state = .expanded"#, 84 | #"}"# 85 | ) { 86 | LinearGradient( 87 | colors: [.blue, .green], 88 | startPoint: .bottomLeading, 89 | endPoint: .topTrailing 90 | ) 91 | .brightness(-0.5) 92 | } 93 | .foregroundColor(.white) 94 | .brightness(pressing ? -0.2 : 0) 95 | } 96 | .buttonStyle(SwipeActionButtonStyle()) 97 | ._onButtonGesture { pressing in 98 | self.pressing = pressing 99 | } perform: {} 100 | } trailingActions: { context in 101 | SwipeAction("Tap to Close!") { 102 | context.state.wrappedValue = .closed 103 | } 104 | .onReceive(open) { _ in 105 | context.state.wrappedValue = .expanded 106 | } 107 | } 108 | .swipeActionWidth(180) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Animations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Animations.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | extension ContentView { 13 | @ViewBuilder var animations: some View { 14 | SwipeView { 15 | Container( 16 | title: "Default", 17 | details: 18 | #".swipeOffsetCloseAnimation(stiffness: 160, damping: 70)"#, 19 | #".swipeOffsetExpandAnimation(stiffness: 160, damping: 70)"#, 20 | #".swipeOffsetTriggerAnimation(stiffness: 160, damping: 70)"# 21 | ) { 22 | Color.blue.opacity(0.05) 23 | .overlay( 24 | RoundedRectangle(cornerRadius: 32, style: .continuous) 25 | .strokeBorder(Color.blue, lineWidth: 2) 26 | ) 27 | } 28 | } trailingActions: { context in 29 | SwipeAction("1") {} 30 | SwipeAction("2") {} 31 | SwipeAction("3") {} 32 | } 33 | .swipeOffsetCloseAnimation(stiffness: 160, damping: 70) 34 | .swipeOffsetExpandAnimation(stiffness: 160, damping: 70) 35 | .swipeOffsetTriggerAnimation(stiffness: 160, damping: 70) 36 | 37 | SwipeView { 38 | Container( 39 | title: "Heavy", 40 | details: 41 | #".swipeOffsetCloseAnimation(stiffness: 500, damping: 600)"#, 42 | #".swipeOffsetExpandAnimation(stiffness: 500, damping: 600)"#, 43 | #".swipeOffsetTriggerAnimation(stiffness: 500, damping: 600)"# 44 | ) { 45 | Color.green.opacity(0.05) 46 | .overlay( 47 | RoundedRectangle(cornerRadius: 32, style: .continuous) 48 | .strokeBorder(Color.green, lineWidth: 2) 49 | ) 50 | } 51 | } trailingActions: { context in 52 | SwipeAction("1") {} 53 | SwipeAction("2") {} 54 | SwipeAction("3") {} 55 | } 56 | .swipeOffsetCloseAnimation(stiffness: 500, damping: 600) 57 | .swipeOffsetExpandAnimation(stiffness: 500, damping: 600) 58 | .swipeOffsetTriggerAnimation(stiffness: 500, damping: 600) 59 | 60 | SwipeView { 61 | Container( 62 | title: "Light", 63 | details: 64 | #".swipeOffsetCloseAnimation(stiffness: 20, damping: 60)"#, 65 | #".swipeOffsetExpandAnimation(stiffness: 20, damping: 60)"#, 66 | #".swipeOffsetTriggerAnimation(stiffness: 20, damping: 60)"# 67 | ) { 68 | Color.yellow.opacity(0.05) 69 | .overlay( 70 | RoundedRectangle(cornerRadius: 32, style: .continuous) 71 | .strokeBorder(Color.yellow, lineWidth: 2) 72 | ) 73 | } 74 | } trailingActions: { context in 75 | SwipeAction("1") {} 76 | SwipeAction("2") {} 77 | SwipeAction("3") {} 78 | } 79 | .swipeOffsetCloseAnimation(stiffness: 20, damping: 60) 80 | .swipeOffsetExpandAnimation(stiffness: 20, damping: 60) 81 | .swipeOffsetTriggerAnimation(stiffness: 20, damping: 60) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Basic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Basic.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | extension ContentView { 13 | @ViewBuilder var basics: some View { 14 | SwipeView { 15 | Container(title: "Basic") 16 | } trailingActions: { _ in 17 | SwipeAction("Tap Me!") {} 18 | } 19 | 20 | SwipeView { 21 | Container(title: "Left and Right") 22 | } leadingActions: { _ in 23 | SwipeAction("Left") {} 24 | } trailingActions: { _ in 25 | SwipeAction("Right") {} 26 | } 27 | 28 | SwipeView { 29 | Container(title: "Multiple", details: #"SwipeAction("1") {}"#, #"SwipeAction("2") {}"#) 30 | } trailingActions: { _ in 31 | SwipeAction("1") {} 32 | SwipeAction("2") {} 33 | } 34 | 35 | if showingSwipeToTrigger { 36 | SwipeView { 37 | Container(title: "Swipe to Trigger", details: ".allowSwipeToTrigger()") 38 | } trailingActions: { _ in 39 | SwipeAction("Clear") { 40 | withAnimation(.spring()) { 41 | showingSwipeToTrigger = false 42 | } 43 | 44 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 45 | withAnimation(.spring()) { 46 | showingSwipeToTrigger = true 47 | } 48 | } 49 | } 50 | .allowSwipeToTrigger() 51 | } 52 | } 53 | 54 | if showingMultiplePlusSwipeToTrigger { 55 | SwipeView { 56 | Container( 57 | title: "Multiple + Swipe to Trigger", 58 | details: 59 | #"SwipeAction("1") {}"#, 60 | #"SwipeAction("Dismiss") {}"#, 61 | ".allowSwipeToTrigger()" 62 | ) 63 | } trailingActions: { _ in 64 | SwipeAction("1") {} 65 | SwipeAction("Dismiss") { 66 | withAnimation(.spring()) { 67 | showingMultiplePlusSwipeToTrigger = false 68 | } 69 | 70 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 71 | withAnimation(.spring()) { 72 | showingMultiplePlusSwipeToTrigger = true 73 | } 74 | } 75 | } 76 | .allowSwipeToTrigger() 77 | } 78 | } 79 | 80 | SwipeView { 81 | Text("Swipe to Trigger, Then Return") 82 | .frame(maxWidth: .infinity) 83 | .padding(.vertical, 32) 84 | .background(Color.blue.opacity(0.1)) 85 | .cornerRadius(32) 86 | } trailingActions: { context in 87 | SwipeAction("Bounce Back") { 88 | context.state.wrappedValue = .closed 89 | } 90 | .allowSwipeToTrigger() 91 | } 92 | .swipeActionWidth(140) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Customization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Customization.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | extension ContentView { 13 | @ViewBuilder var customization: some View { 14 | if showingCustomizationClear { 15 | SwipeView { 16 | Container(title: "Notification", details: "Swipe for options") { 17 | VisualEffectView(.systemThinMaterial) 18 | } 19 | } trailingActions: { _ in 20 | SwipeAction {} label: { highlight in 21 | Text("Options") 22 | } background: { highlight in 23 | VisualEffectView(.systemThinMaterial) 24 | .brightness(highlight ? -0.1 : 0) 25 | } 26 | 27 | SwipeAction { 28 | withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { 29 | showingCustomizationClear = false 30 | } 31 | 32 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 33 | withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { 34 | showingCustomizationClear = true 35 | } 36 | } 37 | 38 | } label: { highlight in 39 | Text("Clear") 40 | } background: { highlight in 41 | VisualEffectView(.systemThinMaterial) 42 | .brightness(highlight ? -0.1 : 0) 43 | } 44 | .allowSwipeToTrigger() 45 | } 46 | } 47 | 48 | SwipeView { 49 | Container( 50 | title: "Built-In Templates", 51 | details: 52 | #"SwipeAction("#, 53 | #" systemImage: "square.and.arrow.up","#, 54 | #" backgroundColor: .blue"#, 55 | #") {}"# 56 | ) { 57 | VisualEffectView(.systemThickMaterial) 58 | } 59 | } trailingActions: { _ in 60 | SwipeAction( 61 | systemImage: "square.and.arrow.up", 62 | backgroundColor: .blue 63 | ) {} 64 | .font(.title.weight(.bold)) 65 | .foregroundColor(.white) 66 | } 67 | 68 | SwipeView { 69 | Container( 70 | title: "Only Change Label Opacity", 71 | details: 72 | #"SwipeAction("#, 73 | #" systemImage: "checkmark.circle.fill","#, 74 | #" backgroundColor: .purple"#, 75 | #") {}"#, 76 | #" .swipeActionChangeLabelVisibilityOnly(true)"# 77 | ) { 78 | VisualEffectView(.systemChromeMaterial) 79 | } 80 | } trailingActions: { _ in 81 | SwipeAction( 82 | systemImage: "checkmark.circle.fill", 83 | backgroundColor: .purple 84 | ) {} 85 | .swipeActionChangeLabelVisibilityOnly(true) 86 | .font(.title.weight(.bold)) 87 | .foregroundColor(.white) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Group.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Group.swift 3 | // SwipeActionsExample 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/13/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | extension ContentView { 13 | var group: some View { 14 | SwipeViewGroup { 15 | VStack(spacing: 8) { 16 | SwipeView { 17 | Text("Only one of us...") 18 | .frame(maxWidth: .infinity) 19 | .padding(.vertical, 32) 20 | .background(Color.blue.opacity(0.1)) 21 | .cornerRadius(32) 22 | } leadingActions: { _ in 23 | SwipeAction("Leading") {} 24 | } trailingActions: { context in 25 | SwipeAction("Trailing") {} 26 | } 27 | 28 | SwipeView { 29 | Text("... can be open...") 30 | .frame(maxWidth: .infinity) 31 | .padding(.vertical, 32) 32 | .background(Color.blue.opacity(0.1)) 33 | .cornerRadius(32) 34 | } leadingActions: { _ in 35 | SwipeAction("Leading") {} 36 | } trailingActions: { context in 37 | SwipeAction("Trailing") {} 38 | } 39 | 40 | SwipeView { 41 | Text("... at the same time.") 42 | .frame(maxWidth: .infinity) 43 | .padding(.vertical, 32) 44 | .background(Color.blue.opacity(0.1)) 45 | .cornerRadius(32) 46 | } leadingActions: { _ in 47 | SwipeAction("Leading") {} 48 | } trailingActions: { context in 49 | SwipeAction("Trailing") {} 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView+Styles.swift 3 | // Swipe 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | extension ContentView { 13 | @ViewBuilder var styles: some View { 14 | SwipeView { 15 | Container(title: "Mask Style (Default)", details: ".swipeActionsStyle(.mask)", cornerRadius: 0, backgroundColor: .clear) 16 | .contentShape(Rectangle()) 17 | } leadingActions: { _ in 18 | SwipeAction("One", backgroundColor: .red) {} 19 | .foregroundColor(.white) 20 | 21 | SwipeAction("Two", backgroundColor: .orange) {} 22 | .foregroundColor(.white) 23 | } trailingActions: { _ in 24 | SwipeAction("Three", backgroundColor: .green) {} 25 | .foregroundColor(.white) 26 | 27 | SwipeAction("Four", backgroundColor: .blue) {} 28 | .foregroundColor(.white) 29 | } 30 | .swipeActionsStyle(.mask) 31 | .swipeActionCornerRadius(0) 32 | .swipeSpacing(0) 33 | .swipeActionsMaskCornerRadius(0) 34 | 35 | Divider() 36 | 37 | SwipeView { 38 | Container(title: "Equal Widths Style", details: ".swipeActionsStyle(.equalWidths)", ".swipeActionChangeLabelVisibilityOnly(true)", cornerRadius: 0, backgroundColor: .clear) 39 | .contentShape(Rectangle()) 40 | } leadingActions: { _ in 41 | SwipeAction("One", backgroundColor: .red) {} 42 | .swipeActionChangeLabelVisibilityOnly(true) 43 | .foregroundColor(.white) 44 | 45 | SwipeAction("Two", backgroundColor: .orange) {} 46 | .swipeActionChangeLabelVisibilityOnly(true) 47 | .foregroundColor(.white) 48 | } trailingActions: { _ in 49 | SwipeAction("Three", backgroundColor: .green) {} 50 | .swipeActionChangeLabelVisibilityOnly(true) 51 | .foregroundColor(.white) 52 | 53 | SwipeAction("Four", backgroundColor: .blue) {} 54 | .swipeActionChangeLabelVisibilityOnly(true) 55 | .foregroundColor(.white) 56 | } 57 | .swipeActionsStyle(.equalWidths) 58 | .swipeActionCornerRadius(0) 59 | .swipeSpacing(0) 60 | .swipeActionsMaskCornerRadius(0) 61 | 62 | Divider() 63 | 64 | SwipeView { 65 | Container(title: "Cascade Style", details: ".swipeActionsStyle(.cascade)", cornerRadius: 0, backgroundColor: .clear) 66 | .contentShape(Rectangle()) 67 | } leadingActions: { _ in 68 | SwipeAction("One", backgroundColor: .red) {} 69 | .foregroundColor(.white) 70 | 71 | SwipeAction("Two", backgroundColor: .orange) {} 72 | .foregroundColor(.white) 73 | } trailingActions: { _ in 74 | SwipeAction("Three", backgroundColor: .green) {} 75 | .foregroundColor(.white) 76 | 77 | SwipeAction("Four", backgroundColor: .blue) {} 78 | .foregroundColor(.white) 79 | } 80 | .swipeActionsStyle(.cascade) 81 | .swipeActionsVisibleStartPoint(0) 82 | .swipeActionsVisibleEndPoint(0) 83 | .swipeActionCornerRadius(0) 84 | .swipeSpacing(0) 85 | .swipeActionsMaskCornerRadius(0) 86 | 87 | Divider() 88 | 89 | if showingStylesSwipeToDelete { 90 | SwipeView { 91 | Container(title: "Swipe to Delete", details: ".transition(.swipeDelete)", cornerRadius: 0, backgroundColor: .clear) 92 | .contentShape(Rectangle()) 93 | } trailingActions: { _ in 94 | SwipeAction(systemImage: "trash", backgroundColor: .red, highlightOpacity: 1) { 95 | withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { 96 | showingStylesSwipeToDelete = false 97 | } 98 | 99 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 100 | withAnimation(.spring(response: 0.6, dampingFraction: 1, blendDuration: 1)) { 101 | showingStylesSwipeToDelete = true 102 | } 103 | } 104 | } 105 | .allowSwipeToTrigger() 106 | .font(.title3.weight(.medium)) 107 | .foregroundColor(.white) 108 | } 109 | .swipeActionCornerRadius(0) 110 | .swipeSpacing(0) 111 | .swipeActionsMaskCornerRadius(0) 112 | .transition(.swipeDelete) 113 | 114 | Divider() 115 | } 116 | 117 | SwipeView { 118 | Container(title: "Don't Fade", details: ".swipeActionsVisibleStartPoint(0)", ".swipeActionsVisibleEndPoint(0)", cornerRadius: 0, backgroundColor: .clear) 119 | .contentShape(Rectangle()) 120 | } trailingActions: { _ in 121 | SwipeAction("One", backgroundColor: .green) {} 122 | .foregroundColor(.white) 123 | 124 | SwipeAction("Two", backgroundColor: .blue) {} 125 | .foregroundColor(.white) 126 | } 127 | .swipeActionsVisibleStartPoint(0) 128 | .swipeActionsVisibleEndPoint(0) 129 | .swipeActionCornerRadius(0) 130 | .swipeSpacing(0) 131 | .swipeActionsMaskCornerRadius(0) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwipeActionsExample 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | import SwipeActions 12 | 13 | enum DemoSectionKind: String, CaseIterable { 14 | case basics 15 | case customization 16 | case styles 17 | case animations 18 | case advanced 19 | case group 20 | } 21 | 22 | struct ContentView: View { 23 | @Environment(\.backgroundColor) var backgroundColor 24 | @Environment(\.secondaryBackgroundColor) var secondaryBackgroundColor 25 | 26 | @State var showingSwipeToTrigger = true 27 | @State var showingMultiplePlusSwipeToTrigger = true 28 | @State var showingCustomizationClear = true 29 | @State var showingStylesSwipeToDelete = true 30 | @State var showingAlternateFooter = false 31 | 32 | @State var expandedSectionKinds = DemoSectionKind.allCases 33 | @State var showingDebug = false 34 | 35 | var body: some View { 36 | VStack { 37 | if #available(iOS 16.0, *) { 38 | NavigationStack { 39 | content 40 | .background(secondaryBackgroundColor) 41 | .navigationTitle("SwipeActions") 42 | } 43 | } else { 44 | NavigationView { 45 | content 46 | .background(secondaryBackgroundColor) 47 | .navigationTitle("SwipeActions") 48 | } 49 | } 50 | } 51 | .sheet(isPresented: $showingDebug) { 52 | DebugView() 53 | } 54 | } 55 | 56 | var content: some View { 57 | ScrollView { 58 | VStack(spacing: 12) { 59 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .basics) { 60 | VStack(spacing: 8) { 61 | basics 62 | } 63 | } 64 | 65 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .customization) { 66 | VStack(spacing: 8) { 67 | customization 68 | } 69 | .padding(12) 70 | .background( 71 | LinearGradient( 72 | colors: [.orange, .yellow], 73 | startPoint: .top, 74 | endPoint: .bottom 75 | ) 76 | .overlay( 77 | Circle() 78 | .fill(Color.pink) 79 | .frame(width: 500, height: 500) 80 | .blur(radius: 100) 81 | .offset(x: -250, y: -250), 82 | alignment: .topLeading 83 | ) 84 | .overlay( 85 | Circle() 86 | .fill(Color.green) 87 | .frame(width: 800, height: 800) 88 | .blur(radius: 150) 89 | .offset(x: 500, y: 500), 90 | alignment: .bottomTrailing 91 | ) 92 | ) 93 | .cornerRadius(44) 94 | } 95 | 96 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .styles) { 97 | VStack(spacing: 0) { 98 | styles 99 | } 100 | .background(backgroundColor) 101 | .mask( 102 | RoundedRectangle(cornerRadius: 24, style: .continuous) 103 | ) 104 | } 105 | 106 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .animations) { 107 | VStack(spacing: 8) { 108 | animations 109 | } 110 | } 111 | 112 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .advanced) { 113 | VStack(spacing: 8) { 114 | advanced 115 | } 116 | } 117 | 118 | DemoSection(expandedSectionKinds: $expandedSectionKinds, kind: .group) { 119 | group 120 | } 121 | 122 | SwipeView { 123 | VStack { 124 | if showingAlternateFooter { 125 | Text("😀") 126 | .font(.system(size: 100)) 127 | .transition(.scale(scale: 1.2).combined(with: .opacity)) 128 | } else { 129 | VStack(spacing: 16) { 130 | Image("SwipeActions") 131 | .resizable() 132 | .aspectRatio(contentMode: .fit) 133 | .frame(width: 120, height: 120) 134 | 135 | Text("[Made by A. Zheng](https://twitter.com/aheze0)") 136 | .font(.system(.body, design: .monospaced).weight(.semibold)) 137 | 138 | Text("[View on GitHub](https://github.com/aheze/SwipeActions)") 139 | .font(.system(.body, design: .monospaced).weight(.semibold)) 140 | } 141 | .multilineTextAlignment(.center) 142 | .accentColor(.primary) 143 | .padding(.top, 20) 144 | .transition(.scale(scale: 0.8).combined(with: .opacity)) 145 | } 146 | } 147 | .frame(maxWidth: .infinity) 148 | .contentShape(Rectangle()) /// Allow blank space to be swipeable too. 149 | } leadingActions: { _ in 150 | } trailingActions: { context in 151 | SwipeAction(systemImage: "face.smiling") { 152 | context.state.wrappedValue = .closed 153 | withAnimation(.spring()) { 154 | showingAlternateFooter.toggle() 155 | } 156 | } 157 | .allowSwipeToTrigger() 158 | .font(.largeTitle) 159 | } 160 | } 161 | .padding(.top, 8) 162 | .padding(.horizontal, 20) 163 | .padding(.bottom, 32) 164 | } 165 | .toolbar { 166 | ToolbarItemGroup(placement: .navigationBarTrailing) { 167 | let shouldExpandAll: Bool = { 168 | if expandedSectionKinds.count == DemoSectionKind.allCases.count { 169 | return false 170 | } else { 171 | return true 172 | } 173 | }() 174 | 175 | Button { 176 | withAnimation { 177 | showingDebug.toggle() 178 | } 179 | } label: { 180 | Image(systemName: "slider.horizontal.3") 181 | } 182 | 183 | Button { 184 | withAnimation(.spring(response: 0.4, dampingFraction: 1, blendDuration: 1)) { 185 | if shouldExpandAll { 186 | expandedSectionKinds = DemoSectionKind.allCases 187 | } else { 188 | expandedSectionKinds = [] 189 | } 190 | } 191 | } label: { 192 | Image(systemName: shouldExpandAll ? "arrow.up.backward.and.arrow.down.forward" : "arrow.down.forward.and.arrow.up.backward") 193 | .animation(nil) 194 | } 195 | } 196 | } 197 | } 198 | } 199 | 200 | struct DemoSection: View { 201 | @Binding var expandedSectionKinds: [DemoSectionKind] 202 | var kind: DemoSectionKind 203 | @ViewBuilder var content: Content 204 | 205 | var body: some View { 206 | let expanded = Binding { 207 | expandedSectionKinds.contains(kind) 208 | } set: { newValue in 209 | if newValue { 210 | if !expandedSectionKinds.contains(kind) { 211 | expandedSectionKinds.append(kind) 212 | } 213 | } else { 214 | expandedSectionKinds = expandedSectionKinds.filter { $0 != kind } 215 | } 216 | } 217 | 218 | VStack(alignment: .leading, spacing: 8) { 219 | SwipeView { 220 | Button { 221 | withAnimation(.spring(response: 0.4, dampingFraction: 1, blendDuration: 1)) { 222 | expanded.wrappedValue.toggle() 223 | } 224 | } label: { 225 | HStack { 226 | Text(kind.rawValue.capitalized) 227 | .frame(maxWidth: .infinity, alignment: .leading) 228 | 229 | Image(systemName: "chevron.right") 230 | .rotationEffect(.degrees(expanded.wrappedValue ? 90 : 0)) 231 | } 232 | .foregroundColor(.primary) 233 | .font(.title3.weight(.bold)) 234 | .padding(.horizontal, 24) 235 | .padding(.vertical) 236 | .background(VisualEffectView(.systemChromeMaterial)) 237 | .cornerRadius(32) 238 | .environment(\.colorScheme, .dark) 239 | } 240 | } leadingActions: { _ in 241 | } trailingActions: { context in 242 | SwipeAction { 243 | withAnimation(.spring(response: 0.4, dampingFraction: 1, blendDuration: 1)) { 244 | expanded.wrappedValue.toggle() 245 | context.state.wrappedValue = .closed 246 | } 247 | } label: { highlight in 248 | Text(expanded.wrappedValue ? "Close" : "Open") 249 | .font(.title3.weight(.medium)) 250 | .environment(\.colorScheme, .dark) 251 | } background: { highlight in 252 | VisualEffectView(.systemChromeMaterial) 253 | .environment(\.colorScheme, .dark) 254 | } 255 | .allowSwipeToTrigger() 256 | } 257 | .swipeActionWidth(120) 258 | 259 | if expanded.wrappedValue { 260 | content 261 | .padding(.bottom, 24) 262 | } 263 | } 264 | } 265 | } 266 | 267 | /// Use UIKit blurs in SwiftUI. 268 | struct VisualEffectView: UIViewRepresentable { 269 | /// The blur's style. 270 | public var style: UIBlurEffect.Style 271 | 272 | /// Use UIKit blurs in SwiftUI. 273 | public init(_ style: UIBlurEffect.Style) { 274 | self.style = style 275 | } 276 | 277 | public func makeUIView(context: Context) -> UIVisualEffectView { 278 | UIVisualEffectView() 279 | } 280 | 281 | public func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 282 | uiView.effect = UIBlurEffect(style: style) 283 | } 284 | } 285 | 286 | private struct BackgroundColorKey: EnvironmentKey { 287 | static let defaultValue = Color(.systemBackground) 288 | } 289 | 290 | private struct SecondaryBackgroundColorKey: EnvironmentKey { 291 | static let defaultValue = Color(.secondarySystemBackground) 292 | } 293 | 294 | extension EnvironmentValues { 295 | /// Inner view 296 | var backgroundColor: Color { 297 | get { self[BackgroundColorKey.self] } 298 | set { self[BackgroundColorKey.self] = newValue } 299 | } 300 | 301 | /// Outer view 302 | var secondaryBackgroundColor: Color { 303 | get { self[SecondaryBackgroundColorKey.self] } 304 | set { self[SecondaryBackgroundColorKey.self] = newValue } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/DebugView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebugView.swift 3 | // SwipeActionsExample 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwipeActions 11 | 12 | struct DebugView: View { 13 | var body: some View { 14 | VStack { 15 | SwipeViewGroup { 16 | RowSwipeView() 17 | RowSwipeView() 18 | RowSwipeView() 19 | } 20 | } 21 | .padding() 22 | } 23 | } 24 | 25 | struct RowSwipeView: View { 26 | var body: some View { 27 | SwipeView { 28 | Text("Hello!") 29 | .frame(maxWidth: .infinity) 30 | .padding(.vertical, 32) 31 | .background(Color.blue.opacity(0.1)) 32 | .cornerRadius(32) 33 | } leadingActions: { _ in 34 | SwipeAction("Leading") {} 35 | } trailingActions: { context in 36 | SwipeAction("Trailing") {} 37 | } 38 | } 39 | } 40 | 41 | // struct DebugView: View { 42 | // @State var showingCustomizationClear = true 43 | // @State var hue = false 44 | // @State var bright = false 45 | // @Environment(\.presentationMode) var presentationMode 46 | // 47 | // var body: some View { 48 | // SwipeView { 49 | // Text("Hello") 50 | // .frame(maxWidth: .infinity) 51 | // .padding(.vertical, 32) 52 | // .background(Color.blue.opacity(0.1)) 53 | // .cornerRadius(32) 54 | // } leadingActions: { _ in 55 | // } trailingActions: { _ in 56 | // SwipeAction("World") {} 57 | // } 58 | // .padding() 59 | // 60 | //// Color.clear.overlay { 61 | //// LinearGradient( 62 | //// colors: [.blue, .teal], 63 | //// startPoint: .top, 64 | //// endPoint: .bottom 65 | //// ) 66 | //// .overlay( 67 | //// Circle() 68 | //// .fill(Color.green) 69 | //// .frame(width: 500, height: 500) 70 | //// .blur(radius: 100) 71 | //// .offset(x: -250, y: -250), 72 | //// alignment: .topLeading 73 | //// ) 74 | //// .overlay( 75 | //// Circle() 76 | //// .fill(Color.purple) 77 | //// .frame(width: 800, height: 800) 78 | //// .blur(radius: 150) 79 | //// .offset(x: 500, y: 500), 80 | //// alignment: .bottomTrailing 81 | //// ) 82 | //// .drawingGroup() 83 | //// .hueRotation(.degrees(hue ? 90 : 0)) 84 | //// .brightness(bright ? 1 : 0) 85 | //// .ignoresSafeArea() 86 | //// .onTapGesture { 87 | //// presentationMode.wrappedValue.dismiss() 88 | //// } 89 | //// } 90 | //// .overlay { 91 | //// VStack(spacing: 12) { 92 | //// if showingCustomizationClear { 93 | //// view 94 | //// } 95 | //// view 96 | //// view 97 | //// } 98 | //// .padding(.horizontal, 20) 99 | //// } 100 | // } 101 | // 102 | // var view: some View { 103 | // SwipeView { 104 | // HStack { 105 | // VStack(alignment: .leading, spacing: 8) { 106 | // Text("SwipeActions") 107 | // .font(.title3) 108 | // .fontWeight(.semibold) 109 | // 110 | // Text("Swipe for options") 111 | // .multilineTextAlignment(.leading) 112 | // .font(.system(.body, design: .monospaced)) 113 | // } 114 | // .frame(maxWidth: .infinity, alignment: .leading) 115 | // 116 | // Image(systemName: "moon.fill") 117 | // .foregroundStyle(.secondary) 118 | // .font(.title3) 119 | // } 120 | // .padding(.horizontal, 24) 121 | // .padding(.vertical, 24) 122 | // .background(.thinMaterial) 123 | // .mask( 124 | // RoundedRectangle(cornerRadius: 32, style: .continuous) 125 | // ) 126 | // } leadingActions: { _ in 127 | // SwipeAction { 128 | // withAnimation(.spring(response: 0.8, dampingFraction: 0.4, blendDuration: 1)) { 129 | // hue = true 130 | // } 131 | // } label: { highlight in 132 | // Image(systemName: "square.and.arrow.up") 133 | // .font(.title3) 134 | // .opacity(0.75) 135 | // } background: { highlight in 136 | // VisualEffectView(.systemThinMaterial) 137 | // .brightness(highlight ? -0.1 : 0) 138 | // } 139 | // } trailingActions: { _ in 140 | // SwipeAction { 141 | // withAnimation(.spring(response: 0.8, dampingFraction: 0.4, blendDuration: 1)) { 142 | // hue = true 143 | // } 144 | // } label: { highlight in 145 | // Text("Options") 146 | // .opacity(0.75) 147 | // } background: { highlight in 148 | // VisualEffectView(.systemThinMaterial) 149 | // .brightness(highlight ? -0.1 : 0) 150 | // } 151 | // 152 | // SwipeAction { 153 | // withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { 154 | // showingCustomizationClear = false 155 | // } 156 | // 157 | // DispatchQueue.main.asyncAfter(deadline: .now() + 6) { 158 | // withAnimation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 1)) { 159 | // showingCustomizationClear = true 160 | // } 161 | // } 162 | // 163 | // DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 164 | // withAnimation(.spring(response: 2.5, dampingFraction: 1, blendDuration: 1)) { 165 | // bright = true 166 | // } 167 | // } 168 | // 169 | // } label: { highlight in 170 | // Text("Clear") 171 | // .opacity(0.75) 172 | // } background: { highlight in 173 | // VisualEffectView(.systemThinMaterial) 174 | // .brightness(highlight ? -0.1 : 0) 175 | // } 176 | // .swipeActionEdgeStyling() 177 | // } 178 | // .swipeToTriggerTrailingEdge(true) 179 | // .swipeActionWidth(120) 180 | // } 181 | // } 182 | -------------------------------------------------------------------------------- /Example/SwipeActionsExample/SwipeActionsExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwipeActionsExampleApp.swift 3 | // SwipeActionsExample 4 | // 5 | // Created by A. Zheng (github.com/aheze) on 4/12/23. 6 | // Copyright © 2023 A. Zheng. All rights reserved. 7 | // 8 | 9 | 10 | import SwiftUI 11 | 12 | @main 13 | struct SwipeActionsExampleApp: App { 14 | @Environment(\.colorScheme) var colorScheme: ColorScheme 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | ContentView() 19 | .environment(\.backgroundColor, Color(colorScheme == .light ? .systemBackground : .secondarySystemBackground)) 20 | .environment(\.secondaryBackgroundColor, Color(colorScheme == .light ? .secondarySystemBackground : .systemBackground)) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 A. Zheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwipeActions", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SwipeActions", 15 | targets: ["SwipeActions"] 16 | ), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "SwipeActions", 27 | dependencies: [], 28 | path: "Sources" 29 | ) 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SwipeActions 2 | 3 | Add customizable swipe actions to any view. 4 | 5 | - Enable swipe actions on any view, not just Lists. 6 | - Customize literally everything — corner radius, color, etc... 7 | - Supports drag-to-delete and advanced gesture handling. 8 | - Fine-tune animations and styling to your taste. 9 | - Programmatically show/hide swipe actions. 10 | - Automatically close when interacting with other views. 11 | - Made with 100% SwiftUI. Supports iOS 14+. 12 | - Lightweight, no dependencies. One file. 13 | 14 | 15 | ![General](Assets/General.png) | ![Basics](Assets/Basics.png) | ![Customization](Assets/Customization.png) 16 | | --- | --- | --- | 17 | ![Styles](Assets/Styles.png) | ![Animations](Assets/Animations.png) | ![Advanced](Assets/Advanced.png) 18 | 19 | 20 | 21 | ### Installation 22 | 23 | SwipeActions is available via the [Swift Package Manager](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). Alternatively, because all of SwipeActions is contained within a single file, drag [`SwipeActions.swift`](https://github.com/aheze/SwipeActions/blob/main/Sources/SwipeActions.swift) into your project. Requires iOS 14+. 24 | 25 | ``` 26 | https://github.com/aheze/SwipeActions 27 | ``` 28 | 29 | ### Usage 30 | 31 | ```swift 32 | import SwiftUI 33 | import SwipeActions 34 | 35 | struct ContentView: View { 36 | var body: some View { 37 | SwipeView { 38 | Text("Hello") 39 | .frame(maxWidth: .infinity) 40 | .padding(.vertical, 32) 41 | .background(Color.blue.opacity(0.1)) 42 | .cornerRadius(32) 43 | } trailingActions: { _ in 44 | SwipeAction("World") { 45 | print("Tapped!") 46 | } 47 | } 48 | .padding() 49 | } 50 | } 51 | ``` 52 | 53 | The result, 'World' displayed on the right. 54 | 55 | 56 | ### Examples 57 | 58 | Check out the [example app](https://github.com/aheze/SwipeActions/archive/refs/heads/main.zip) for all examples and advanced usage! 59 | 60 | ![2 screenshots of the example app](Assets/ExampleApp.png) 61 | 62 | ### Customization 63 | 64 | SwipeActions supports over 20 modifiers for customization. To use them, simply attach the modifier to `SwipeAction`/`SwipeView`. 65 | 66 | ```swift 67 | SwipeView { 68 | Text("Hello") 69 | } leadingActions: { _ in 70 | } trailingActions: { _ in 71 | SwipeAction("World") { 72 | print("Tapped!") 73 | } 74 | .allowSwipeToTrigger() /// Modifiers for `SwipeAction` go here. 75 | } 76 | .swipeActionsStyle(.cascade) /// Modifiers for `SwipeView` go here. 77 | ``` 78 | 79 | ```swift 80 | // MARK: - Available modifiers for `SwipeAction` (the side views) 81 | 82 | /** 83 | Apply this to the edge action to enable drag-to-trigger. 84 | 85 | SwipeView { 86 | Text("Swipe") 87 | } leadingActions: { _ in 88 | SwipeAction("1") {} 89 | .allowSwipeToTrigger() 90 | 91 | SwipeAction("2") {} 92 | } trailingActions: { _ in 93 | SwipeAction("3") {} 94 | 95 | SwipeAction("4") {} 96 | .allowSwipeToTrigger() 97 | } 98 | */ 99 | func allowSwipeToTrigger(_ value: Bool = true) 100 | 101 | /// Constrain the action's content size (helpful for text). 102 | func swipeActionLabelFixedSize(_ value: Bool = true) 103 | 104 | /// Additional horizontal padding. 105 | func swipeActionLabelHorizontalPadding(_ value: Double = 16) 106 | 107 | /// The opacity of the swipe actions, determined by `actionsVisibleStartPoint` and `actionsVisibleEndPoint`. 108 | func swipeActionChangeLabelVisibilityOnly(_ value: Bool) 109 | ``` 110 | 111 | ```swift 112 | // MARK: - Available modifiers for `SwipeView` (the main view) 113 | 114 | /// The minimum distance needed to drag to start the gesture. Should be more than 0 for best compatibility with other gestures/buttons. 115 | func swipeMinimumDistance(_ value: Double) 116 | 117 | /// The style to use (`mask`, `equalWidths`, or `cascade`). 118 | func swipeActionsStyle(_ value: SwipeActionStyle) 119 | 120 | /// The corner radius that encompasses all actions. 121 | func swipeActionsMaskCornerRadius(_ value: Double) 122 | 123 | /// At what point the actions start becoming visible. 124 | func swipeActionsVisibleStartPoint(_ value: Double) 125 | 126 | /// At what point the actions become fully visible. 127 | func swipeActionsVisibleEndPoint(_ value: Double) 128 | 129 | /// The corner radius for each action. 130 | func swipeActionCornerRadius(_ value: Double) 131 | 132 | /// The width for each action. 133 | func swipeActionWidth(_ value: Double) 134 | 135 | /// Spacing between actions and the label view. 136 | func swipeSpacing(_ value: Double) 137 | 138 | /// The point where the user must drag to expand actions. 139 | func swipeReadyToExpandPadding(_ value: Double) 140 | 141 | /// The point where the user must drag to enter the `triggering` state. 142 | func swipeReadyToTriggerPadding(_ value: Double) 143 | 144 | /// Ensure that the user must drag a significant amount to trigger the edge action, even if the actions' total width is small. 145 | func swipeMinimumPointToTrigger(_ value: Double) 146 | 147 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is true. 148 | func swipeEnableTriggerHaptics(_ value: Bool) 149 | 150 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is false, or when there's no actions on one side. 151 | func swipeStretchRubberBandingPower(_ value: Double) 152 | 153 | /// If true, you can change from the leading to the trailing actions in one single swipe. 154 | func swipeAllowSingleSwipeAcross(_ value: Bool) 155 | 156 | /// The animation used for adjusting the content's view when it's triggered. 157 | func swipeActionContentTriggerAnimation(_ value: Animation) 158 | 159 | /// Values for controlling the close animation. 160 | func swipeOffsetCloseAnimation(stiffness: Double, damping: Double) 161 | 162 | /// Values for controlling the expand animation. 163 | func swipeOffsetExpandAnimation(stiffness: Double, damping: Double) 164 | 165 | /// Values for controlling the trigger animation. 166 | func swipeOffsetTriggerAnimation(stiffness: Double, damping: Double) 167 | ``` 168 | 169 | Example usage of these modifiers is available in the [example app](https://github.com/aheze/SwipeActions/archive/refs/heads/main.zip). 170 | 171 | ### Notes 172 | 173 | - To automatically close swipe views when another one is swiped (accordion style), use `SwipeViewGroup`. 174 | 175 | ```swift 176 | SwipeViewGroup { 177 | SwipeView {} /// Only one of the actions will be shown. 178 | SwipeView {} 179 | SwipeView {} 180 | } 181 | ``` 182 | 183 | - To programmatically show/hide actions, use the `context` parameter. 184 | 185 | ```swift 186 | import Combine 187 | import SwiftUI 188 | import SwipeActions 189 | 190 | struct ProgrammaticSwipeView: View { 191 | @State var open = PassthroughSubject() 192 | 193 | var body: some View { 194 | SwipeView { 195 | Button { 196 | open.send() /// Fire the `PassthroughSubject`. 197 | } label: { 198 | Text("Tap to Open") 199 | .frame(maxWidth: .infinity) 200 | .padding(.vertical, 32) 201 | .background(Color.blue.opacity(0.1)) 202 | .cornerRadius(32) 203 | } 204 | } trailingActions: { context in 205 | SwipeAction("Tap to Close") { 206 | context.state.wrappedValue = .closed 207 | } 208 | .onReceive(open) { _ in /// Receive the `PassthroughSubject`. 209 | context.state.wrappedValue = .expanded 210 | } 211 | } 212 | } 213 | } 214 | ``` 215 | 216 | - To enable swiping on transparent areas, add `.contentShape(Rectangle())`. 217 | 218 | ```swift 219 | SwipeView { 220 | Text("Lots of empty space here.") 221 | .frame(maxWidth: .infinity) 222 | .padding(.vertical, 32) 223 | .contentShape(Rectangle()) /// Enable swiping on the empty space. 224 | } trailingActions: { _ in 225 | SwipeAction("Hello!") { } 226 | } 227 | ``` 228 | 229 | - Everything in the example app is swipeable — even the gray-capsule headers! 230 | 231 | The 'Styles' header swiped to the left and the 'Open' action shown on the right. 232 | 233 | 234 | ### Community 235 | 236 | Author | Contributing | Need Help? 237 | --- | --- | --- 238 | SwipeActions is made by [aheze](https://github.com/aheze). | All contributions are welcome. Just [fork](https://github.com/aheze/SwipeActions/fork) the repo, then make a pull request. | Open an [issue](https://github.com/aheze/SwipeActions/issues) or join the [Discord server](https://discord.com/invite/Pmq8fYcus2). You can also ping me on [Twitter](https://twitter.com/aheze0). 239 | 240 | ### License 241 | 242 | ``` 243 | MIT License 244 | 245 | Copyright (c) 2023 A. Zheng 246 | 247 | Permission is hereby granted, free of charge, to any person obtaining a copy 248 | of this software and associated documentation files (the "Software"), to deal 249 | in the Software without restriction, including without limitation the rights 250 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 251 | copies of the Software, and to permit persons to whom the Software is 252 | furnished to do so, subject to the following conditions: 253 | 254 | The above copyright notice and this permission notice shall be included in all 255 | copies or substantial portions of the Software. 256 | 257 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 258 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 259 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 260 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 261 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 262 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 263 | SOFTWARE. 264 | ``` 265 | 266 | --- 267 | 268 | https://user-images.githubusercontent.com/49819455/231671743-baca394e-fc74-4062-83eb-2024b8add924.mp4 269 | -------------------------------------------------------------------------------- /Sources/SwipeActions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | SwipeActions.swift 4 | SwipeActions 5 | 6 | Created by A. Zheng (github.com/aheze) on 4/12/23. 7 | Copyright © 2023 A. Zheng. All rights reserved. 8 | 9 | MIT License 10 | 11 | Copyright (c) 2023 A. Zheng 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | */ 31 | 32 | import SwiftUI 33 | 34 | // MARK: - Structures 35 | 36 | /// The swipe gesture's current state. 37 | public enum SwipeState { 38 | /// The default state. 39 | case closed 40 | 41 | /// All actions are shown. 42 | case expanded 43 | 44 | /// The last action is highlighted. Only applies if `swipeToTriggerLeadingEdge` / `swipeToTriggerTrailingEdge` are true. 45 | case triggering 46 | 47 | /// The last action is highlighted and fills the whole row. Only applies if `swipeToTriggerLeadingEdge` / `swipeToTriggerTrailingEdge` are true. 48 | case triggered 49 | } 50 | 51 | /// Either `leading` or `trailing`. 52 | public enum SwipeSide { 53 | case leading 54 | case trailing 55 | 56 | /// When leading actions are shown, the offset is positive. It's the opposite for trailing actions. 57 | var signWhenDragged: Int { 58 | switch self { 59 | case .leading: 60 | return 1 61 | case .trailing: 62 | return -1 63 | } 64 | } 65 | 66 | /// Convert to `SwiftUI`'s `Alignment` struct. 67 | var alignment: Alignment { 68 | switch self { 69 | case .leading: 70 | return .leading 71 | case .trailing: 72 | return .trailing 73 | } 74 | } 75 | 76 | /// Used when there's only one action. 77 | var edgeTriggerAlignment: Alignment { 78 | switch self { 79 | case .leading: 80 | return .trailing 81 | case .trailing: 82 | return .leading 83 | } 84 | } 85 | } 86 | 87 | /// Context for the swipe action. 88 | public struct SwipeContext { 89 | /// The current state. 90 | public var state: Binding 91 | 92 | /// How many actions are provided. 93 | public var numberOfActions = 0 94 | 95 | /// The side that this context applies to. 96 | public var side: SwipeSide 97 | 98 | /// The opacity of the swipe actions, determined by `actionsVisibleStartPoint` and `actionsVisibleEndPoint`. 99 | public var opacity = Double(0) 100 | 101 | /// If the user is swiping or not. 102 | public var currentlyDragging = false 103 | } 104 | 105 | /// The style to reveal actions. 106 | public enum SwipeActionStyle { 107 | /// Fully render actions, but reveal them using a mask. 108 | case mask 109 | 110 | /// All actions have equal widths, taking up all available space together. 111 | case equalWidths 112 | 113 | /// A "overlapping" style. 114 | case cascade 115 | } 116 | 117 | /// Options for configuring the swipe view. 118 | public struct SwipeOptions { 119 | /// If swiping is currently enabled. 120 | var swipeEnabled = true 121 | 122 | /// The minimum distance needed to drag to start the gesture. Should be more than 0 for best compatibility with other gestures/buttons. 123 | var swipeMinimumDistance = Double(2) 124 | 125 | /// The style to use (`mask`, `equalWidths`, or `cascade`). 126 | var actionsStyle = SwipeActionStyle.mask 127 | 128 | /// The corner radius that encompasses all actions. 129 | var actionsMaskCornerRadius = Double(20) 130 | 131 | /// At what point the actions start becoming visible. 132 | var actionsVisibleStartPoint = Double(50) 133 | 134 | /// At what point the actions become fully visible. 135 | var actionsVisibleEndPoint = Double(100) 136 | 137 | /// The corner radius for each action. 138 | var actionCornerRadius = Double(32) 139 | 140 | /// The width for each action. 141 | var actionWidth = Double(100) 142 | 143 | /// Spacing between actions and the label view. 144 | var spacing = Double(8) 145 | 146 | /// The point where the user must drag to expand actions. 147 | var readyToExpandPadding = Double(50) 148 | 149 | /// The point where the user must drag to enter the `triggering` state. 150 | var readyToTriggerPadding = Double(20) 151 | 152 | /// Ensure that the user must drag a significant amount to trigger the edge action, even if the actions' total width is small. 153 | var minimumPointToTrigger = Double(200) 154 | 155 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is true. 156 | var enableTriggerHaptics = true 157 | 158 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is false, or when there's no actions on one side. 159 | var stretchRubberBandingPower = Double(0.7) 160 | 161 | /// If true, you can change from the leading to the trailing actions in one single swipe. 162 | var allowSingleSwipeAcross = false 163 | 164 | /// The animation used for adjusting the content's view when it's triggered. 165 | var actionContentTriggerAnimation = Animation.spring(response: 0.2, dampingFraction: 1, blendDuration: 1) 166 | 167 | /// The animation used at the start of the gesture, after dragging the `swipeMinimumDistance`. 168 | // var swipeMinimumDistanceAnimation = Animation.spring(response: 0.3, dampingFraction: 1, blendDuration: 1) 169 | // var asd = "" 170 | 171 | /// Values for controlling the close animation. 172 | var offsetCloseAnimationStiffness = Double(160), offsetCloseAnimationDamping = Double(70) 173 | 174 | /// Values for controlling the expand animation. 175 | var offsetExpandAnimationStiffness = Double(160), offsetExpandAnimationDamping = Double(70) 176 | 177 | /// Values for controlling the trigger animation. 178 | var offsetTriggerAnimationStiffness = Double(160), offsetTriggerAnimationDamping = Double(70) 179 | } 180 | 181 | // MARK: - Environment 182 | 183 | public struct SwipeContextKey: EnvironmentKey { 184 | public static let defaultValue = SwipeContext(state: .constant(nil), side: .leading) 185 | } 186 | 187 | public struct SwipeViewGroupSelectionKey: EnvironmentKey { 188 | public static let defaultValue: Binding = .constant(nil) 189 | } 190 | 191 | public extension EnvironmentValues { 192 | var swipeContext: SwipeContext { 193 | get { self[SwipeContextKey.self] } 194 | set { self[SwipeContextKey.self] = newValue } 195 | } 196 | 197 | var swipeViewGroupSelection: Binding { 198 | get { self[SwipeViewGroupSelectionKey.self] } 199 | set { self[SwipeViewGroupSelectionKey.self] = newValue } 200 | } 201 | } 202 | 203 | // MARK: - Group view 204 | 205 | /** 206 | To only allow one swipe view open at a time, use this view. 207 | 208 | SwipeViewGroup { 209 | SwipeView {} /// Only one will be shown. 210 | SwipeView {} 211 | SwipeView {} 212 | } 213 | 214 | */ 215 | public struct SwipeViewGroup: View { 216 | @ViewBuilder var content: () -> Content 217 | 218 | @State var selection: UUID? 219 | 220 | public init(@ViewBuilder content: @escaping () -> Content) { 221 | self.content = content 222 | } 223 | 224 | public var body: some View { 225 | content() 226 | .environment(\.swipeViewGroupSelection, $selection) 227 | } 228 | } 229 | 230 | // MARK: - Action view 231 | 232 | /// For use in `SwipeView`'s `leading` or `trailing` side. 233 | public struct SwipeAction: View { 234 | // MARK: - Properties 235 | 236 | /// Set to true to enable drag-to-trigger on the edge action. If `nil`, this is not the edge action. 237 | public var allowSwipeToTrigger: Bool? 238 | 239 | /// Constrain the action's content size (helpful for text). 240 | public var labelFixedSize = true 241 | 242 | /// Additional horizontal padding. 243 | public var labelHorizontalPadding = Double(16) 244 | 245 | /// Whether to ramp the opacity of the entire view or just the label. 246 | public var changeLabelVisibilityOnly = false 247 | 248 | /// Code to run when the action triggers. 249 | public var action: () -> Void 250 | 251 | /// The parameter indicates if it's highlighted or not. 252 | public var label: (Bool) -> Label 253 | 254 | /// The background of the swipe action. 255 | public var background: (Bool) -> Background 256 | 257 | // MARK: - Internal state 258 | 259 | /// Read the `swipeContext` from the parent `SwipeView`. 260 | @Environment(\.swipeContext) var swipeContext 261 | 262 | /// Keeps track of whether the action is pressed/triggered or not. 263 | @State var highlighted = false 264 | 265 | /// For use in `SwipeView`'s `leading` or `trailing` side. 266 | public init( 267 | action: @escaping () -> Void, 268 | @ViewBuilder label: @escaping (Bool) -> Label, 269 | @ViewBuilder background: @escaping (Bool) -> Background 270 | ) { 271 | self.action = action 272 | self.label = label 273 | self.background = background 274 | } 275 | 276 | public var body: some View { 277 | /// Usually `.center`, but if there's only one action and it's triggered, move it closer to the center. 278 | let labelAlignment: Alignment = { 279 | guard let allowSwipeToTrigger, allowSwipeToTrigger else { return .center } 280 | if swipeContext.numberOfActions == 1 { 281 | if swipeContext.state.wrappedValue == .triggering || swipeContext.state.wrappedValue == .triggered { 282 | return swipeContext.side.edgeTriggerAlignment 283 | } 284 | } 285 | return .center 286 | }() 287 | 288 | let (totalOpacity, labelOpacity): (Double, Double) = { 289 | if changeLabelVisibilityOnly { 290 | return (1, swipeContext.opacity) 291 | } else { 292 | return (swipeContext.opacity, 1) 293 | } 294 | }() 295 | 296 | Button(action: action) { 297 | background(highlighted) 298 | .overlay( 299 | label(highlighted) 300 | .opacity(labelOpacity) 301 | .fixedSize(horizontal: labelFixedSize, vertical: labelFixedSize) 302 | .padding(.horizontal, labelHorizontalPadding), 303 | alignment: labelAlignment 304 | ) 305 | } 306 | .opacity(totalOpacity) 307 | ._onButtonGesture { pressing in 308 | self.highlighted = pressing 309 | } perform: {} 310 | .buttonStyle(SwipeActionButtonStyle()) 311 | .onChange(of: swipeContext.state.wrappedValue) { state in /// Read changes in state. 312 | guard let allowSwipeToTrigger, allowSwipeToTrigger else { return } 313 | 314 | if let state { 315 | if state == .triggering || state == .triggered { 316 | highlighted = true 317 | } else { 318 | highlighted = false 319 | } 320 | 321 | if state == .triggered { 322 | action() 323 | } 324 | } else { 325 | highlighted = false 326 | } 327 | } 328 | .preference(key: AllowSwipeToTriggerKey.self, value: allowSwipeToTrigger) 329 | } 330 | } 331 | 332 | // MARK: - Main view 333 | 334 | /// A view for adding swipe actions. 335 | public struct SwipeView: View where Label: View, LeadingActions: View, TrailingActions: View { 336 | // MARK: - Properties 337 | 338 | /// Options for configuring the swipe view. 339 | public var options = SwipeOptions() 340 | 341 | @ViewBuilder public var label: () -> Label 342 | @ViewBuilder public var leadingActions: (SwipeContext) -> LeadingActions 343 | @ViewBuilder public var trailingActions: (SwipeContext) -> TrailingActions 344 | 345 | // MARK: - Environment 346 | 347 | /// Read the `swipeViewGroupSelection` from the parent `SwipeViewGroup` (if it exists). 348 | @Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection 349 | 350 | // MARK: - Internal state 351 | 352 | /// The ID of the view. Set `options.id` to override this. 353 | @State var id = UUID() 354 | 355 | /// The size of the parent view. 356 | @State var size = CGSize.zero 357 | 358 | // MARK: - Actions state 359 | 360 | /// The current side that's showing the actions. 361 | @State var currentSide: SwipeSide? 362 | 363 | /// The `closed/expanded/triggering/triggered/none` state for the leading side. 364 | @State var leadingState: SwipeState? 365 | 366 | /// The `closed/expanded/triggering/triggered/none` state for the trailing side. 367 | @State var trailingState: SwipeState? 368 | 369 | /// These properties are set automatically via `SwipeActionsLayout`. 370 | @State var numberOfLeadingActions = 0 371 | @State var numberOfTrailingActions = 0 372 | 373 | /// Enable triggering the leading edge via a drag. 374 | @State var swipeToTriggerLeadingEdge = false 375 | 376 | /// Enable triggering the trailing edge via a drag. 377 | @State var swipeToTriggerTrailingEdge = false 378 | 379 | // MARK: - Gesture state 380 | 381 | /// When you touch down with a second finger, the drag gesture freezes, but `currentlyDragging` will be accurate. 382 | @GestureState var currentlyDragging = false 383 | 384 | /// Upon a gesture freeze / cancellation, use this to end the gesture. 385 | @State var latestDragGestureValueBackup: DragGesture.Value? 386 | 387 | /// The gesture's current velocity. 388 | @GestureVelocity var velocity: CGVector 389 | 390 | /// The offset dragged in the current drag session. 391 | @State var currentOffset = Double(0) 392 | 393 | /// The offset dragged in previous drag sessions. 394 | @State var savedOffset = Double(0) 395 | 396 | /// A view for adding swipe actions. 397 | public init( 398 | @ViewBuilder label: @escaping () -> Label, 399 | @ViewBuilder leadingActions: @escaping (SwipeContext) -> LeadingActions, 400 | @ViewBuilder trailingActions: @escaping (SwipeContext) -> TrailingActions 401 | ) { 402 | self.label = label 403 | self.leadingActions = leadingActions 404 | self.trailingActions = trailingActions 405 | } 406 | 407 | public var body: some View { 408 | HStack { 409 | label() 410 | .offset(x: offset) /// Apply the offset here. 411 | } 412 | .readSize { size = $0 } /// Read the size of the parent label. 413 | .background( /// Leading swipe actions. 414 | actionsView(side: .leading, state: $leadingState, numberOfActions: $numberOfLeadingActions) { context in 415 | leadingActions(context) 416 | .environment(\.swipeContext, context) 417 | .onPreferenceChange(AllowSwipeToTriggerKey.self) { allow in 418 | 419 | /// Unwrap the value first (if it's not the edge action, `allow` is `nil`). 420 | if let allow { 421 | swipeToTriggerLeadingEdge = allow 422 | } 423 | } 424 | }, 425 | alignment: .leading 426 | ) 427 | .background( /// Trailing swipe actions. 428 | actionsView(side: .trailing, state: $trailingState, numberOfActions: $numberOfTrailingActions) { context in 429 | trailingActions(context) 430 | .environment(\.swipeContext, context) 431 | .onPreferenceChange(AllowSwipeToTriggerKey.self) { allow in 432 | if let allow { 433 | swipeToTriggerTrailingEdge = allow 434 | } 435 | } 436 | }, 437 | alignment: .trailing 438 | ) 439 | 440 | // MARK: - Add gestures 441 | 442 | .highPriorityGesture( /// Add the drag gesture. 443 | DragGesture(minimumDistance: options.swipeMinimumDistance) 444 | .updating($currentlyDragging) { value, state, transaction in 445 | state = true 446 | } 447 | .onChanged(onChanged) 448 | .onEnded(onEnded) 449 | .updatingVelocity($velocity), 450 | including: options.swipeEnabled ? .all : .subviews /// Enable/disable swiping here. 451 | ) 452 | .onChange(of: currentlyDragging) { currentlyDragging in /// Detect gesture cancellations. 453 | if !currentlyDragging, let latestDragGestureValueBackup { 454 | /// Gesture cancelled. 455 | let velocity = velocity.dx / currentOffset 456 | end(value: latestDragGestureValueBackup, velocity: velocity) 457 | } 458 | } 459 | 460 | // MARK: - Trigger haptics 461 | 462 | .onChange(of: leadingState) { [leadingState] newValue in 463 | /// Make sure the change was from `triggering` to `nil`, or the other way around. 464 | let changed = 465 | leadingState == .triggering && newValue == nil || 466 | leadingState == nil && newValue == .triggering 467 | 468 | if changed, options.enableTriggerHaptics { /// Generate haptic feedback if necessary. 469 | let generator = UIImpactFeedbackGenerator(style: .rigid) 470 | generator.impactOccurred() 471 | } 472 | } 473 | .onChange(of: trailingState) { [trailingState] newValue in 474 | 475 | let changed = 476 | trailingState == .triggering && newValue == nil || 477 | trailingState == nil && newValue == .triggering 478 | 479 | if changed, options.enableTriggerHaptics { 480 | let generator = UIImpactFeedbackGenerator(style: .rigid) 481 | generator.impactOccurred() 482 | } 483 | } 484 | 485 | // MARK: - Receive `SwipeViewGroup` events 486 | 487 | .onChange(of: currentlyDragging) { newValue in 488 | if newValue { 489 | swipeViewGroupSelection.wrappedValue = id 490 | } 491 | } 492 | .onChange(of: leadingState) { newValue in 493 | if newValue == .closed, swipeViewGroupSelection.wrappedValue == id { 494 | swipeViewGroupSelection.wrappedValue = nil 495 | } 496 | } 497 | .onChange(of: trailingState) { newValue in 498 | if newValue == .closed, swipeViewGroupSelection.wrappedValue == id { 499 | swipeViewGroupSelection.wrappedValue = nil 500 | } 501 | } 502 | .onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in 503 | if swipeViewGroupSelection.wrappedValue != id { 504 | currentSide = nil 505 | 506 | if leadingState != .closed { 507 | leadingState = .closed 508 | close(velocity: 0) 509 | } 510 | 511 | if trailingState != .closed { 512 | trailingState = .closed 513 | close(velocity: 0) 514 | } 515 | } 516 | } 517 | } 518 | } 519 | 520 | // MARK: - Actions view 521 | 522 | extension SwipeView { 523 | /// The swipe actions. 524 | @ViewBuilder func actionsView( 525 | side: SwipeSide, 526 | state: Binding, 527 | numberOfActions: Binding, 528 | @ViewBuilder actions: (SwipeContext) -> Actions 529 | ) -> some View { 530 | let draggedLength = offset * Double(side.signWhenDragged) /// Flip the offset if necessary. 531 | let visibleWidth: Double = { 532 | var width = draggedLength 533 | width -= options.spacing /// Minus the side spacing. 534 | width = max(0, width) /// Prevent from becoming negative. 535 | return width 536 | }() 537 | 538 | let opacity: Double = { 539 | /// Subtract the start point from the dragged length, which cancels it out initially. 540 | let offset = max(0, draggedLength - options.actionsVisibleStartPoint) 541 | 542 | /// Calculate the opacity percent. 543 | let percent = offset / (options.actionsVisibleEndPoint - options.actionsVisibleStartPoint) 544 | 545 | /// Make sure the opacity doesn't exceed 1. 546 | let opacity = min(1, percent) 547 | 548 | return opacity 549 | }() 550 | 551 | _VariadicView.Tree( 552 | SwipeActionsLayout( 553 | numberOfActions: numberOfActions, 554 | side: side, 555 | options: options, 556 | state: state.wrappedValue, 557 | visibleWidth: visibleWidth 558 | ) 559 | ) { 560 | let stateBinding = Binding { 561 | state.wrappedValue 562 | } set: { newValue in 563 | state.wrappedValue = newValue 564 | 565 | if newValue == .closed { 566 | currentSide = nil /// If closed, set `currentSide` to nil. 567 | } else { 568 | currentSide = side /// Set the current side to the action's side. 569 | } 570 | 571 | /// Update the visual state to the client's new selection. 572 | updateOffset(side: side, to: newValue) 573 | } 574 | 575 | let context = SwipeContext( 576 | state: stateBinding, 577 | numberOfActions: numberOfActions.wrappedValue, 578 | side: side, 579 | opacity: opacity, 580 | currentlyDragging: currentlyDragging 581 | ) 582 | 583 | actions(context) /// Call the `actions` view and pass in context. 584 | } 585 | .mask( 586 | Color.clear.overlay( 587 | /// Clip the swipe actions as they're being revealed. 588 | RoundedRectangle(cornerRadius: options.actionsMaskCornerRadius, style: .continuous) 589 | .frame(width: visibleWidth), 590 | alignment: side.alignment 591 | ) 592 | ) 593 | } 594 | } 595 | 596 | // MARK: - Actions Layout 597 | 598 | struct SwipeActionsLayout: _VariadicView_UnaryViewRoot { 599 | @Binding var numberOfActions: Int 600 | var side: SwipeSide 601 | var options: SwipeOptions 602 | var state: SwipeState? 603 | var visibleWidth: Double 604 | 605 | @ViewBuilder 606 | public func body(children: _VariadicView.Children) -> some View { 607 | /// The ID of the edge action. 608 | let edgeID: AnyHashable? = { 609 | switch side { 610 | case .leading: 611 | return children.first?.id 612 | case .trailing: 613 | return children.last?.id 614 | } 615 | }() 616 | 617 | HStack(spacing: options.spacing) { 618 | ForEach(Array(zip(children.indices, children)), id: \.1.id) { index, child in 619 | let isEdge = child.id == edgeID 620 | 621 | let shown: Bool = { 622 | if state == .triggering || state == .triggered { 623 | if !isEdge { 624 | return false 625 | } 626 | } 627 | 628 | return true 629 | }() 630 | 631 | let width: CGFloat? = { 632 | if state == .triggering || state == .triggered { 633 | if isEdge { 634 | return visibleWidth 635 | } else { 636 | return 0 637 | } 638 | } 639 | 640 | /** 641 | Use this when rubber banding (the actions should stretch a bit). 642 | 643 | Also applies when `options.actionsStyle` is `.equalWidths`. 644 | */ 645 | let evenlyDistributedActionWidth: Double = { 646 | if numberOfActions > 0 { 647 | let visibleWidthWithoutSpacing = visibleWidth - options.spacing * Double(numberOfActions - 1) 648 | let evenlyDistributedActionWidth = visibleWidthWithoutSpacing / Double(numberOfActions) 649 | return evenlyDistributedActionWidth 650 | } else { 651 | return options.actionWidth /// At first `numberOfTrailingActions` is 0, so just return `options.actionWidth`. 652 | } 653 | }() 654 | 655 | switch options.actionsStyle { 656 | case .mask: 657 | return max(evenlyDistributedActionWidth, options.actionWidth) 658 | case .equalWidths: 659 | return evenlyDistributedActionWidth 660 | case .cascade: 661 | return max(evenlyDistributedActionWidth, options.actionWidth) 662 | } 663 | }() 664 | 665 | if options.actionsStyle == .cascade { 666 | /// Overlapping views require a `zIndex`. 667 | let zIndex: Int = { 668 | switch side { 669 | case .leading: 670 | return children.count - index - 1 /// Left-most views should be on top. 671 | case .trailing: 672 | return index 673 | } 674 | }() 675 | 676 | Color.clear.overlay( 677 | child 678 | .frame(maxHeight: .infinity) 679 | .frame(width: width) 680 | .opacity(shown ? 1 : 0) 681 | .mask( 682 | RoundedRectangle(cornerRadius: options.actionCornerRadius, style: .continuous) 683 | ), 684 | alignment: side.edgeTriggerAlignment 685 | ) 686 | .zIndex(Double(zIndex)) 687 | } else { 688 | child 689 | .frame(maxHeight: .infinity) 690 | .frame(width: width) 691 | .opacity(shown ? 1 : 0) 692 | .mask( 693 | RoundedRectangle(cornerRadius: options.actionCornerRadius, style: .continuous) 694 | ) 695 | } 696 | } 697 | } 698 | .frame(width: options.actionsStyle == .cascade ? visibleWidth : nil) 699 | .animation(options.actionContentTriggerAnimation, value: state) 700 | .onAppear { /// Set the number of actions here. 701 | numberOfActions = children.count 702 | } 703 | .onChange(of: children.count) { count in 704 | numberOfActions = count 705 | } 706 | } 707 | } 708 | 709 | // MARK: - Calculated values 710 | 711 | extension SwipeView { 712 | /// The total offset of the content. 713 | var offset: Double { 714 | currentOffset + savedOffset 715 | } 716 | 717 | /// Calculate the total width for actions. 718 | func actionsWidth(numberOfActions: Int) -> Double { 719 | let count = Double(numberOfActions) 720 | let totalWidth = count * options.actionWidth 721 | let totalSpacing = (count - 1) * options.spacing 722 | let actionsWidth = totalWidth + totalSpacing 723 | 724 | return actionsWidth 725 | } 726 | 727 | /// If `allowSwipeAcross` is disabled, make sure the user can't swipe from one side to the other in a single swipe. 728 | func getDisallowedSide(totalOffset: Double) -> SwipeSide? { 729 | guard !options.allowSingleSwipeAcross else { return nil } 730 | if let currentSide { 731 | switch currentSide { 732 | case .leading: 733 | if totalOffset < 0 { 734 | /// Disallow showing trailing actions. 735 | return .trailing 736 | } 737 | case .trailing: 738 | if totalOffset > 0 { 739 | /// Disallow showing leading actions. 740 | return .leading 741 | } 742 | } 743 | } 744 | return nil 745 | } 746 | 747 | // MARK: - Trailing 748 | 749 | var trailingReadyToExpandOffset: Double { 750 | -options.readyToExpandPadding 751 | } 752 | 753 | var trailingExpandedOffset: Double { 754 | let expandedOffset = -(actionsWidth(numberOfActions: numberOfTrailingActions) + options.spacing) 755 | return expandedOffset 756 | } 757 | 758 | var trailingReadyToTriggerOffset: Double { 759 | var readyToTriggerOffset = trailingExpandedOffset - options.readyToTriggerPadding 760 | let minimumOffsetToTrigger = -options.minimumPointToTrigger /// Sometimes if there's only one action, the trigger drag distance is too small. This makes sure it's big enough. 761 | if readyToTriggerOffset > minimumOffsetToTrigger { 762 | readyToTriggerOffset = minimumOffsetToTrigger 763 | } 764 | return readyToTriggerOffset 765 | } 766 | 767 | var trailingTriggeredOffset: Double { 768 | let triggeredOffset = -(size.width + options.spacing) 769 | return triggeredOffset 770 | } 771 | 772 | // MARK: - Leading 773 | 774 | var leadingReadyToExpandOffset: Double { 775 | options.readyToExpandPadding 776 | } 777 | 778 | var leadingExpandedOffset: Double { 779 | let expandedOffset = actionsWidth(numberOfActions: numberOfLeadingActions) + options.spacing 780 | return expandedOffset 781 | } 782 | 783 | var leadingReadyToTriggerOffset: Double { 784 | var readyToTriggerOffset = leadingExpandedOffset + options.readyToTriggerPadding 785 | let minimumOffsetToTrigger = options.minimumPointToTrigger 786 | 787 | if readyToTriggerOffset < minimumOffsetToTrigger { 788 | readyToTriggerOffset = minimumOffsetToTrigger 789 | } 790 | return readyToTriggerOffset 791 | } 792 | 793 | var leadingTriggeredOffset: Double { 794 | let triggeredOffset = size.width + options.spacing 795 | return triggeredOffset 796 | } 797 | } 798 | 799 | // MARK: - State 800 | 801 | extension SwipeView { 802 | /// Call this after programmatically setting the state to update the view's offset. 803 | func updateOffset(side: SwipeSide, to state: SwipeState?) { 804 | guard let state else { return } 805 | switch state { 806 | case .closed: 807 | close(velocity: 0) 808 | case .expanded: 809 | expand(side: side, velocity: 0) 810 | case .triggering: 811 | break 812 | case .triggered: 813 | trigger(side: side, velocity: 0) 814 | } 815 | } 816 | 817 | func close(velocity: Double) { 818 | withAnimation(.interpolatingSpring(stiffness: options.offsetTriggerAnimationStiffness, damping: options.offsetTriggerAnimationDamping, initialVelocity: velocity)) { 819 | savedOffset = 0 820 | currentOffset = 0 821 | } 822 | } 823 | 824 | func trigger(side: SwipeSide, velocity: Double) { 825 | withAnimation(.interpolatingSpring(stiffness: options.offsetTriggerAnimationStiffness, damping: options.offsetTriggerAnimationDamping, initialVelocity: velocity)) { 826 | switch side { 827 | case .leading: 828 | savedOffset = leadingTriggeredOffset 829 | case .trailing: 830 | savedOffset = trailingTriggeredOffset 831 | } 832 | currentOffset = 0 833 | } 834 | } 835 | 836 | func expand(side: SwipeSide, velocity: Double) { 837 | withAnimation(.interpolatingSpring(stiffness: options.offsetExpandAnimationStiffness, damping: options.offsetExpandAnimationDamping, initialVelocity: velocity)) { 838 | switch side { 839 | case .leading: 840 | savedOffset = leadingExpandedOffset 841 | case .trailing: 842 | savedOffset = trailingExpandedOffset 843 | } 844 | currentOffset = 0 845 | } 846 | } 847 | } 848 | 849 | // MARK: - Gestures 850 | 851 | extension SwipeView { 852 | func onChanged(value: DragGesture.Value) { 853 | /// Back up the value. 854 | latestDragGestureValueBackup = value 855 | 856 | /// Set the current side. 857 | if currentSide == nil { 858 | let dx = value.location.x - value.startLocation.x 859 | if dx > 0 { 860 | currentSide = .leading 861 | } else { 862 | currentSide = .trailing 863 | } 864 | 865 | /// The gesture just started, so animate the change (in case `swipeMinimumDistance > 0`). 866 | // withAnimation(options.swipeMinimumDistanceAnimation) { 867 | // change(value: value) 868 | // } 869 | } else { 870 | change(value: value) 871 | } 872 | } 873 | 874 | func change(value: DragGesture.Value) { 875 | /// The total offset of the swipe view. 876 | let totalOffset = savedOffset + value.translation.width 877 | 878 | /// Get the disallowed side if it exists. 879 | let disallowedSide = getDisallowedSide(totalOffset: totalOffset) 880 | 881 | /// Apply rubber banding if an empty side is reached, or if a side is disallowed. 882 | if numberOfLeadingActions == 0 || disallowedSide == .leading, totalOffset > 0 { 883 | let constrainedExceededOffset = pow(totalOffset, options.stretchRubberBandingPower) 884 | currentOffset = constrainedExceededOffset - savedOffset 885 | leadingState = nil 886 | trailingState = nil 887 | } else if numberOfTrailingActions == 0 || disallowedSide == .trailing, totalOffset < 0 { 888 | let constrainedExceededOffset = -pow(-totalOffset, options.stretchRubberBandingPower) 889 | currentOffset = constrainedExceededOffset - savedOffset 890 | leadingState = nil 891 | trailingState = nil 892 | } else { /// Otherwise, attempt to trigger the swipe actions. 893 | /// Flag to keep track of whether `currentOffset` was set or not — if `false`, then set to the default of `value.translation.width`. 894 | var setCurrentOffset = false 895 | 896 | if totalOffset > leadingReadyToTriggerOffset { 897 | setCurrentOffset = true 898 | if swipeToTriggerLeadingEdge { 899 | currentOffset = value.translation.width 900 | leadingState = .triggering 901 | trailingState = nil 902 | } else { 903 | let exceededOffset = totalOffset - leadingReadyToTriggerOffset 904 | let constrainedExceededOffset = pow(exceededOffset, options.stretchRubberBandingPower) 905 | let constrainedTotalOffset = leadingReadyToTriggerOffset + constrainedExceededOffset 906 | currentOffset = constrainedTotalOffset - savedOffset 907 | leadingState = nil 908 | trailingState = nil 909 | } 910 | } 911 | 912 | if totalOffset < trailingReadyToTriggerOffset { 913 | setCurrentOffset = true 914 | if swipeToTriggerTrailingEdge { 915 | currentOffset = value.translation.width 916 | trailingState = .triggering 917 | leadingState = nil 918 | } else { 919 | let exceededOffset = totalOffset - trailingReadyToTriggerOffset 920 | let constrainedExceededOffset = -pow(-exceededOffset, options.stretchRubberBandingPower) 921 | let constrainedTotalOffset = trailingReadyToTriggerOffset + constrainedExceededOffset 922 | currentOffset = constrainedTotalOffset - savedOffset 923 | leadingState = nil 924 | trailingState = nil 925 | } 926 | } 927 | 928 | /// If the offset wasn't modified already (due to rubber banding), use `value.translation.width` as the default. 929 | if !setCurrentOffset { 930 | currentOffset = value.translation.width 931 | leadingState = nil 932 | trailingState = nil 933 | } 934 | } 935 | } 936 | 937 | func onEnded(value: DragGesture.Value) { 938 | latestDragGestureValueBackup = nil 939 | let velocity = velocity.dx / currentOffset 940 | end(value: value, velocity: velocity) 941 | } 942 | 943 | /// Represents the end of a gesture. 944 | func end(value: DragGesture.Value, velocity: CGFloat) { 945 | let totalOffset = savedOffset + value.translation.width 946 | let totalPredictedOffset = (savedOffset + value.predictedEndTranslation.width) * 0.5 947 | 948 | if getDisallowedSide(totalOffset: totalPredictedOffset) != nil { 949 | currentSide = nil 950 | leadingState = .closed 951 | trailingState = .closed 952 | close(velocity: velocity) 953 | return 954 | } 955 | 956 | if trailingState == .triggering { 957 | trailingState = .triggered 958 | trigger(side: .trailing, velocity: velocity) 959 | } else if leadingState == .triggering { 960 | leadingState = .triggered 961 | trigger(side: .leading, velocity: velocity) 962 | } else { 963 | if totalPredictedOffset > leadingReadyToExpandOffset, numberOfLeadingActions > 0 { 964 | leadingState = .expanded 965 | expand(side: .leading, velocity: velocity) 966 | } else if totalPredictedOffset < trailingReadyToExpandOffset, numberOfTrailingActions > 0 { 967 | trailingState = .expanded 968 | expand(side: .trailing, velocity: velocity) 969 | } else { 970 | currentSide = nil 971 | leadingState = .closed 972 | trailingState = .closed 973 | let draggedPastTrailingSide = totalOffset > 0 974 | if draggedPastTrailingSide { /// if the finger is on the right of the view, make the velocity negative to return to closed quicker. 975 | close(velocity: velocity * -0.1) 976 | } else { 977 | close(velocity: velocity) 978 | } 979 | } 980 | } 981 | } 982 | } 983 | 984 | // MARK: Convenience views 985 | 986 | public extension SwipeAction where Label == Text, Background == Color { 987 | init( 988 | _ title: LocalizedStringKey, 989 | backgroundColor: Color = Color.primary.opacity(0.1), 990 | highlightOpacity: Double = 0.5, 991 | action: @escaping () -> Void 992 | ) { 993 | self.init(action: action) { highlight in 994 | Text(title) 995 | } background: { highlight in 996 | backgroundColor 997 | .opacity(highlight ? highlightOpacity : 1) 998 | } 999 | } 1000 | } 1001 | 1002 | public extension SwipeAction where Label == Image, Background == Color { 1003 | init( 1004 | systemImage: String, 1005 | backgroundColor: Color = Color.primary.opacity(0.1), 1006 | highlightOpacity: Double = 0.5, 1007 | action: @escaping () -> Void 1008 | ) { 1009 | self.init(action: action) { highlight in 1010 | Image(systemName: systemImage) 1011 | } background: { highlight in 1012 | backgroundColor 1013 | .opacity(highlight ? highlightOpacity : 1) 1014 | } 1015 | } 1016 | } 1017 | 1018 | public extension SwipeAction where Label == VStack>, Text)>>, Background == Color { 1019 | init( 1020 | _ title: LocalizedStringKey, 1021 | systemImage: String, 1022 | imageFont: Font? = .title2, 1023 | backgroundColor: Color = Color.primary.opacity(0.1), 1024 | highlightOpacity: Double = 0.5, 1025 | action: @escaping () -> Void 1026 | ) { 1027 | self.init(action: action) { highlight in 1028 | VStack(spacing: 8) { 1029 | Image(systemName: systemImage) 1030 | .font(imageFont) as! ModifiedContent> 1031 | 1032 | Text(title) 1033 | } 1034 | } background: { highlight in 1035 | backgroundColor 1036 | .opacity(highlight ? highlightOpacity : 1) 1037 | } 1038 | } 1039 | } 1040 | 1041 | /// A `SwipeView` with leading actions only. 1042 | public extension SwipeView where TrailingActions == EmptyView { 1043 | init( 1044 | @ViewBuilder label: @escaping () -> Label, 1045 | @ViewBuilder leadingActions: @escaping (SwipeContext) -> LeadingActions 1046 | ) { 1047 | self.init(label: label, leadingActions: leadingActions) { _ in } 1048 | } 1049 | } 1050 | 1051 | /// A `SwipeView` with trailing actions only. 1052 | public extension SwipeView where LeadingActions == EmptyView { 1053 | init( 1054 | @ViewBuilder label: @escaping () -> Label, 1055 | @ViewBuilder trailingActions: @escaping (SwipeContext) -> TrailingActions 1056 | ) { 1057 | self.init(label: label, leadingActions: { _ in }, trailingActions: trailingActions) 1058 | } 1059 | } 1060 | 1061 | /// A `SwipeView` with no actions. 1062 | public extension SwipeView where LeadingActions == EmptyView, TrailingActions == EmptyView { 1063 | init(@ViewBuilder label: @escaping () -> Label) { 1064 | self.init(label: label) { _ in } trailingActions: { _ in } 1065 | } 1066 | } 1067 | 1068 | // MARK: - Convenience modifiers 1069 | 1070 | public extension SwipeAction { 1071 | /** 1072 | Apply this to the edge action to enable drag-to-trigger. 1073 | 1074 | SwipeView { 1075 | Text("Swipe") 1076 | } leadingActions: { _ in 1077 | SwipeAction("1") {} 1078 | .allowSwipeToTrigger() 1079 | 1080 | SwipeAction("2") {} 1081 | } trailingActions: { _ in 1082 | SwipeAction("3") {} 1083 | 1084 | SwipeAction("4") {} 1085 | .allowSwipeToTrigger() 1086 | } 1087 | */ 1088 | func allowSwipeToTrigger(_ value: Bool = true) -> some View { 1089 | var view = self 1090 | view.allowSwipeToTrigger = value 1091 | return view 1092 | } 1093 | 1094 | /// Constrain the action's content size (helpful for text). 1095 | func swipeActionLabelFixedSize(_ value: Bool = true) -> SwipeAction { 1096 | var view = self 1097 | view.labelFixedSize = value 1098 | return view 1099 | } 1100 | 1101 | /// Additional horizontal padding. 1102 | func swipeActionLabelHorizontalPadding(_ value: Double = 16) -> SwipeAction { 1103 | var view = self 1104 | view.labelHorizontalPadding = value 1105 | return view 1106 | } 1107 | 1108 | /// The opacity of the swipe actions, determined by `actionsVisibleStartPoint` and `actionsVisibleEndPoint`. 1109 | func swipeActionChangeLabelVisibilityOnly(_ value: Bool) -> SwipeAction { 1110 | var view = self 1111 | view.changeLabelVisibilityOnly = value 1112 | return view 1113 | } 1114 | } 1115 | 1116 | public extension SwipeView { 1117 | /// If swiping is currently enabled. 1118 | func swipeEnabled(_ value: Bool) -> SwipeView { 1119 | var view = self 1120 | view.options.swipeEnabled = value 1121 | return view 1122 | } 1123 | 1124 | /// The minimum distance needed to drag to start the gesture. Should be more than 0 for best compatibility with other gestures/buttons. 1125 | func swipeMinimumDistance(_ value: Double) -> SwipeView { 1126 | var view = self 1127 | view.options.swipeMinimumDistance = value 1128 | return view 1129 | } 1130 | 1131 | /// The style to use (`mask`, `equalWidths`, or `cascade`). 1132 | func swipeActionsStyle(_ value: SwipeActionStyle) -> SwipeView { 1133 | var view = self 1134 | view.options.actionsStyle = value 1135 | return view 1136 | } 1137 | 1138 | /// The corner radius that encompasses all actions. 1139 | func swipeActionsMaskCornerRadius(_ value: Double) -> SwipeView { 1140 | var view = self 1141 | view.options.actionsMaskCornerRadius = value 1142 | return view 1143 | } 1144 | 1145 | /// At what point the actions start becoming visible. 1146 | func swipeActionsVisibleStartPoint(_ value: Double) -> SwipeView { 1147 | var view = self 1148 | view.options.actionsVisibleStartPoint = value 1149 | return view 1150 | } 1151 | 1152 | /// At what point the actions become fully visible. 1153 | func swipeActionsVisibleEndPoint(_ value: Double) -> SwipeView { 1154 | var view = self 1155 | view.options.actionsVisibleEndPoint = value 1156 | return view 1157 | } 1158 | 1159 | /// The corner radius for each action. 1160 | func swipeActionCornerRadius(_ value: Double) -> SwipeView { 1161 | var view = self 1162 | view.options.actionCornerRadius = value 1163 | return view 1164 | } 1165 | 1166 | /// The width for each action. 1167 | func swipeActionWidth(_ value: Double) -> SwipeView { 1168 | var view = self 1169 | view.options.actionWidth = value 1170 | return view 1171 | } 1172 | 1173 | /// Spacing between actions and the label view. 1174 | func swipeSpacing(_ value: Double) -> SwipeView { 1175 | var view = self 1176 | view.options.spacing = value 1177 | return view 1178 | } 1179 | 1180 | /// The point where the user must drag to expand actions. 1181 | func swipeReadyToExpandPadding(_ value: Double) -> SwipeView { 1182 | var view = self 1183 | view.options.readyToExpandPadding = value 1184 | return view 1185 | } 1186 | 1187 | /// The point where the user must drag to enter the `triggering` state. 1188 | func swipeReadyToTriggerPadding(_ value: Double) -> SwipeView { 1189 | var view = self 1190 | view.options.readyToTriggerPadding = value 1191 | return view 1192 | } 1193 | 1194 | /// Ensure that the user must drag a significant amount to trigger the edge action, even if the actions' total width is small. 1195 | func swipeMinimumPointToTrigger(_ value: Double) -> SwipeView { 1196 | var view = self 1197 | view.options.minimumPointToTrigger = value 1198 | return view 1199 | } 1200 | 1201 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is true. 1202 | func swipeEnableTriggerHaptics(_ value: Bool) -> SwipeView { 1203 | var view = self 1204 | view.options.enableTriggerHaptics = value 1205 | return view 1206 | } 1207 | 1208 | /// Applies if `swipeToTriggerLeadingEdge/swipeToTriggerTrailingEdge` is false, or when there's no actions on one side. 1209 | func swipeStretchRubberBandingPower(_ value: Double) -> SwipeView { 1210 | var view = self 1211 | view.options.stretchRubberBandingPower = value 1212 | return view 1213 | } 1214 | 1215 | /// If true, you can change from the leading to the trailing actions in one single swipe. 1216 | func swipeAllowSingleSwipeAcross(_ value: Bool) -> SwipeView { 1217 | var view = self 1218 | view.options.allowSingleSwipeAcross = value 1219 | return view 1220 | } 1221 | 1222 | /// The animation used for adjusting the content's view when it's triggered. 1223 | func swipeActionContentTriggerAnimation(_ value: Animation) -> SwipeView { 1224 | var view = self 1225 | view.options.actionContentTriggerAnimation = value 1226 | return view 1227 | } 1228 | 1229 | /// The animation used at the start of the gesture, after dragging the `swipeMinimumDistance`. 1230 | // func swipeMinimumDistanceAnimation(_ value: Animation) -> SwipeView { 1231 | // var view = self 1232 | // view.options.swipeMinimumDistanceAnimation = value 1233 | // return view 1234 | // } 1235 | 1236 | /// Values for controlling the close animation. 1237 | func swipeOffsetCloseAnimation(stiffness: Double, damping: Double) -> SwipeView { 1238 | var view = self 1239 | view.options.offsetCloseAnimationStiffness = stiffness 1240 | view.options.offsetCloseAnimationDamping = damping 1241 | return view 1242 | } 1243 | 1244 | /// Values for controlling the expand animation. 1245 | func swipeOffsetExpandAnimation(stiffness: Double, damping: Double) -> SwipeView { 1246 | var view = self 1247 | view.options.offsetExpandAnimationStiffness = stiffness 1248 | view.options.offsetExpandAnimationDamping = damping 1249 | return view 1250 | } 1251 | 1252 | /// Values for controlling the trigger animation. 1253 | func swipeOffsetTriggerAnimation(stiffness: Double, damping: Double) -> SwipeView { 1254 | var view = self 1255 | view.options.offsetTriggerAnimationStiffness = stiffness 1256 | view.options.offsetTriggerAnimationDamping = damping 1257 | return view 1258 | } 1259 | } 1260 | 1261 | /// Modifier for a clipped delete transition effect. 1262 | public struct SwipeDeleteModifier: ViewModifier { 1263 | var visibility: Double 1264 | 1265 | public func body(content: Content) -> some View { 1266 | content 1267 | .mask( 1268 | Color.clear.overlay( 1269 | SwipeDeleteMaskShape(animatableData: visibility) 1270 | .padding(.horizontal, -100) /// Prevent horizontal clipping 1271 | .padding(.vertical, -10), /// Prevent vertical clipping 1272 | alignment: .top 1273 | ) 1274 | ) 1275 | } 1276 | } 1277 | 1278 | public extension AnyTransition { 1279 | /// Transition that mimics iOS's default delete transition (clipped to the top). 1280 | static var swipeDelete: AnyTransition { 1281 | .modifier( 1282 | active: SwipeDeleteModifier(visibility: 0), 1283 | identity: SwipeDeleteModifier(visibility: 1) 1284 | ) 1285 | } 1286 | } 1287 | 1288 | /// Custom shape that changes height as `animatableData` changes. 1289 | public struct SwipeDeleteMaskShape: Shape { 1290 | public var animatableData: Double 1291 | 1292 | public func path(in rect: CGRect) -> Path { 1293 | var maskRect = rect 1294 | maskRect.size.height = rect.size.height * animatableData 1295 | return Path(maskRect) 1296 | } 1297 | } 1298 | 1299 | // MARK: - Utilities 1300 | 1301 | /// A style to remove the "press" effect on buttons. 1302 | public struct SwipeActionButtonStyle: ButtonStyle { 1303 | public init() {} 1304 | 1305 | public func makeBody(configuration: Configuration) -> some View { 1306 | return configuration.label 1307 | } 1308 | } 1309 | 1310 | /* 1311 | Get the velocity from a drag gesture. 1312 | 1313 | From https://github.com/FluidGroup/swiftui-GestureVelocity 1314 | 1315 | MIT License 1316 | 1317 | Copyright (c) 2022 Hiroshi Kimura 1318 | 1319 | Permission is hereby granted, free of charge, to any person obtaining a copy 1320 | of this software and associated documentation files (the "Software"), to deal 1321 | in the Software without restriction, including without limitation the rights 1322 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 1323 | copies of the Software, and to permit persons to whom the Software is 1324 | furnished to do so, subject to the following conditions: 1325 | 1326 | The above copyright notice and this permission notice shall be included in all 1327 | copies or substantial portions of the Software. 1328 | 1329 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1330 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1331 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1332 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1333 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 1334 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 1335 | SOFTWARE. 1336 | */ 1337 | @propertyWrapper 1338 | struct GestureVelocity: DynamicProperty { 1339 | @State var previous: DragGesture.Value? 1340 | @State var current: DragGesture.Value? 1341 | 1342 | func update(_ value: DragGesture.Value) { 1343 | if current != nil { 1344 | previous = current 1345 | } 1346 | 1347 | current = value 1348 | } 1349 | 1350 | func reset() { 1351 | previous = nil 1352 | current = nil 1353 | } 1354 | 1355 | var projectedValue: GestureVelocity { 1356 | return self 1357 | } 1358 | 1359 | var wrappedValue: CGVector { 1360 | value 1361 | } 1362 | 1363 | private var value: CGVector { 1364 | guard 1365 | let previous, 1366 | let current 1367 | else { 1368 | return .zero 1369 | } 1370 | 1371 | let timeDelta = current.time.timeIntervalSince(previous.time) 1372 | 1373 | let speedY = Double( 1374 | current.translation.height - previous.translation.height 1375 | ) / timeDelta 1376 | 1377 | let speedX = Double( 1378 | current.translation.width - previous.translation.width 1379 | ) / timeDelta 1380 | 1381 | return .init(dx: speedX, dy: speedY) 1382 | } 1383 | } 1384 | 1385 | extension Gesture where Value == DragGesture.Value { 1386 | func updatingVelocity(_ velocity: GestureVelocity) -> _EndedGesture<_ChangedGesture> { 1387 | onChanged { value in 1388 | velocity.update(value) 1389 | } 1390 | .onEnded { _ in 1391 | velocity.reset() 1392 | } 1393 | } 1394 | } 1395 | 1396 | /** 1397 | Read a view's size. The closure is called whenever the size itself changes. 1398 | 1399 | From https://stackoverflow.com/a/66822461/14351818 1400 | */ 1401 | extension View { 1402 | func readSize(size: @escaping (CGSize) -> Void) -> some View { 1403 | return background( 1404 | GeometryReader { geometry in 1405 | Color.clear 1406 | .preference(key: ContentSizeReaderPreferenceKey.self, value: geometry.size) 1407 | .onPreferenceChange(ContentSizeReaderPreferenceKey.self) { newValue in 1408 | DispatchQueue.main.async { 1409 | size(newValue) 1410 | } 1411 | } 1412 | } 1413 | .hidden() 1414 | ) 1415 | } 1416 | } 1417 | 1418 | struct ContentSizeReaderPreferenceKey: PreferenceKey { 1419 | static var defaultValue: CGSize { return CGSize() } 1420 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } 1421 | } 1422 | 1423 | struct AllowSwipeToTriggerKey: PreferenceKey { 1424 | static var defaultValue: Bool? = nil 1425 | static func reduce(value: inout Bool?, nextValue: () -> Bool?) { value = nextValue() } 1426 | } 1427 | --------------------------------------------------------------------------------