├── .DS_Store ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Examples └── MagneticScrollExample │ ├── MagneticScrollExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── MagneticScrollExample │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── MagneticScrollExample.entitlements │ ├── MagneticScrollExampleApp.swift │ └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md └── Sources ├── .DS_Store └── MagneticScroll ├── .DS_Store ├── Extensions ├── Haptic.swift └── View.swift ├── Helpers ├── OffsetObservingScrollView.swift └── PositionObservingView.swift ├── Logic ├── MagneticBlock.swift ├── MagneticScrollConfiguration.swift └── Organizer.swift ├── Utilities └── SizePreferenceKey.swift └── View ├── Block.swift └── MagneticScrollView.swift /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poppin-Technologies/magnetic-scroll/17f5206d4008da5f0fd623c974238bb00d944079/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | Examples/.DS_Store 92 | Examples/MagneticScrollExample/.DS_Store 93 | Preview Assets.xcassets/Contents.json 94 | Examples/MagneticScrollExample/MagneticScrollExample/Preview Content/Preview Assets.xcassets/Contents.json 95 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | EE447A7C2A4B5C6600491BCA /* MagneticScroll in Frameworks */ = {isa = PBXBuildFile; productRef = EE447A7B2A4B5C6600491BCA /* MagneticScroll */; }; 11 | EE92D1F22A4B5850007A0574 /* MagneticScrollExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE92D1F12A4B5850007A0574 /* MagneticScrollExampleApp.swift */; }; 12 | EE92D1F42A4B5850007A0574 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE92D1F32A4B5850007A0574 /* ContentView.swift */; }; 13 | EE92D1F62A4B5851007A0574 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EE92D1F52A4B5851007A0574 /* Assets.xcassets */; }; 14 | EE92D1FA2A4B5851007A0574 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EE92D1F92A4B5851007A0574 /* Preview Assets.xcassets */; }; 15 | /* End PBXBuildFile section */ 16 | 17 | /* Begin PBXFileReference section */ 18 | EE447A792A4B5B4800491BCA /* magnetic-scroll */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "magnetic-scroll"; path = ../..; sourceTree = ""; }; 19 | EE92D1EE2A4B5850007A0574 /* MagneticScrollExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MagneticScrollExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 20 | EE92D1F12A4B5850007A0574 /* MagneticScrollExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagneticScrollExampleApp.swift; sourceTree = ""; }; 21 | EE92D1F32A4B5850007A0574 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 22 | EE92D1F52A4B5851007A0574 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 23 | EE92D1F72A4B5851007A0574 /* MagneticScrollExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MagneticScrollExample.entitlements; sourceTree = ""; }; 24 | EE92D1F92A4B5851007A0574 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 25 | /* End PBXFileReference section */ 26 | 27 | /* Begin PBXFrameworksBuildPhase section */ 28 | EE92D1EB2A4B5850007A0574 /* Frameworks */ = { 29 | isa = PBXFrameworksBuildPhase; 30 | buildActionMask = 2147483647; 31 | files = ( 32 | EE447A7C2A4B5C6600491BCA /* MagneticScroll in Frameworks */, 33 | ); 34 | runOnlyForDeploymentPostprocessing = 0; 35 | }; 36 | /* End PBXFrameworksBuildPhase section */ 37 | 38 | /* Begin PBXGroup section */ 39 | EE447A782A4B5B4800491BCA /* Packages */ = { 40 | isa = PBXGroup; 41 | children = ( 42 | EE447A792A4B5B4800491BCA /* magnetic-scroll */, 43 | ); 44 | name = Packages; 45 | sourceTree = ""; 46 | }; 47 | EE447A7A2A4B5C6600491BCA /* Frameworks */ = { 48 | isa = PBXGroup; 49 | children = ( 50 | ); 51 | name = Frameworks; 52 | sourceTree = ""; 53 | }; 54 | EE92D1E52A4B5850007A0574 = { 55 | isa = PBXGroup; 56 | children = ( 57 | EE447A782A4B5B4800491BCA /* Packages */, 58 | EE92D1F02A4B5850007A0574 /* MagneticScrollExample */, 59 | EE92D1EF2A4B5850007A0574 /* Products */, 60 | EE447A7A2A4B5C6600491BCA /* Frameworks */, 61 | ); 62 | sourceTree = ""; 63 | }; 64 | EE92D1EF2A4B5850007A0574 /* Products */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | EE92D1EE2A4B5850007A0574 /* MagneticScrollExample.app */, 68 | ); 69 | name = Products; 70 | sourceTree = ""; 71 | }; 72 | EE92D1F02A4B5850007A0574 /* MagneticScrollExample */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | EE92D1F12A4B5850007A0574 /* MagneticScrollExampleApp.swift */, 76 | EE92D1F32A4B5850007A0574 /* ContentView.swift */, 77 | EE92D1F52A4B5851007A0574 /* Assets.xcassets */, 78 | EE92D1F72A4B5851007A0574 /* MagneticScrollExample.entitlements */, 79 | EE92D1F82A4B5851007A0574 /* Preview Content */, 80 | ); 81 | path = MagneticScrollExample; 82 | sourceTree = ""; 83 | }; 84 | EE92D1F82A4B5851007A0574 /* Preview Content */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | EE92D1F92A4B5851007A0574 /* Preview Assets.xcassets */, 88 | ); 89 | path = "Preview Content"; 90 | sourceTree = ""; 91 | }; 92 | /* End PBXGroup section */ 93 | 94 | /* Begin PBXNativeTarget section */ 95 | EE92D1ED2A4B5850007A0574 /* MagneticScrollExample */ = { 96 | isa = PBXNativeTarget; 97 | buildConfigurationList = EE92D1FD2A4B5851007A0574 /* Build configuration list for PBXNativeTarget "MagneticScrollExample" */; 98 | buildPhases = ( 99 | EE92D1EA2A4B5850007A0574 /* Sources */, 100 | EE92D1EB2A4B5850007A0574 /* Frameworks */, 101 | EE92D1EC2A4B5850007A0574 /* Resources */, 102 | ); 103 | buildRules = ( 104 | ); 105 | dependencies = ( 106 | ); 107 | name = MagneticScrollExample; 108 | packageProductDependencies = ( 109 | EE447A7B2A4B5C6600491BCA /* MagneticScroll */, 110 | ); 111 | productName = MagneticScrollExample; 112 | productReference = EE92D1EE2A4B5850007A0574 /* MagneticScrollExample.app */; 113 | productType = "com.apple.product-type.application"; 114 | }; 115 | /* End PBXNativeTarget section */ 116 | 117 | /* Begin PBXProject section */ 118 | EE92D1E62A4B5850007A0574 /* Project object */ = { 119 | isa = PBXProject; 120 | attributes = { 121 | BuildIndependentTargetsInParallel = 1; 122 | LastSwiftUpdateCheck = 1430; 123 | LastUpgradeCheck = 1430; 124 | TargetAttributes = { 125 | EE92D1ED2A4B5850007A0574 = { 126 | CreatedOnToolsVersion = 14.3.1; 127 | }; 128 | }; 129 | }; 130 | buildConfigurationList = EE92D1E92A4B5850007A0574 /* Build configuration list for PBXProject "MagneticScrollExample" */; 131 | compatibilityVersion = "Xcode 14.0"; 132 | developmentRegion = en; 133 | hasScannedForEncodings = 0; 134 | knownRegions = ( 135 | en, 136 | Base, 137 | ); 138 | mainGroup = EE92D1E52A4B5850007A0574; 139 | productRefGroup = EE92D1EF2A4B5850007A0574 /* Products */; 140 | projectDirPath = ""; 141 | projectRoot = ""; 142 | targets = ( 143 | EE92D1ED2A4B5850007A0574 /* MagneticScrollExample */, 144 | ); 145 | }; 146 | /* End PBXProject section */ 147 | 148 | /* Begin PBXResourcesBuildPhase section */ 149 | EE92D1EC2A4B5850007A0574 /* Resources */ = { 150 | isa = PBXResourcesBuildPhase; 151 | buildActionMask = 2147483647; 152 | files = ( 153 | EE92D1FA2A4B5851007A0574 /* Preview Assets.xcassets in Resources */, 154 | EE92D1F62A4B5851007A0574 /* Assets.xcassets in Resources */, 155 | ); 156 | runOnlyForDeploymentPostprocessing = 0; 157 | }; 158 | /* End PBXResourcesBuildPhase section */ 159 | 160 | /* Begin PBXSourcesBuildPhase section */ 161 | EE92D1EA2A4B5850007A0574 /* Sources */ = { 162 | isa = PBXSourcesBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | EE92D1F42A4B5850007A0574 /* ContentView.swift in Sources */, 166 | EE92D1F22A4B5850007A0574 /* MagneticScrollExampleApp.swift in Sources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXSourcesBuildPhase section */ 171 | 172 | /* Begin XCBuildConfiguration section */ 173 | EE92D1FB2A4B5851007A0574 /* Debug */ = { 174 | isa = XCBuildConfiguration; 175 | buildSettings = { 176 | ALWAYS_SEARCH_USER_PATHS = NO; 177 | CLANG_ANALYZER_NONNULL = YES; 178 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 179 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 180 | CLANG_ENABLE_MODULES = YES; 181 | CLANG_ENABLE_OBJC_ARC = YES; 182 | CLANG_ENABLE_OBJC_WEAK = YES; 183 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 184 | CLANG_WARN_BOOL_CONVERSION = YES; 185 | CLANG_WARN_COMMA = YES; 186 | CLANG_WARN_CONSTANT_CONVERSION = YES; 187 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 188 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 189 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 190 | CLANG_WARN_EMPTY_BODY = YES; 191 | CLANG_WARN_ENUM_CONVERSION = YES; 192 | CLANG_WARN_INFINITE_RECURSION = YES; 193 | CLANG_WARN_INT_CONVERSION = YES; 194 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 195 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 196 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 197 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 198 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 199 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 200 | CLANG_WARN_STRICT_PROTOTYPES = YES; 201 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 202 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 203 | CLANG_WARN_UNREACHABLE_CODE = YES; 204 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 205 | COPY_PHASE_STRIP = NO; 206 | DEBUG_INFORMATION_FORMAT = dwarf; 207 | ENABLE_STRICT_OBJC_MSGSEND = YES; 208 | ENABLE_TESTABILITY = YES; 209 | GCC_C_LANGUAGE_STANDARD = gnu11; 210 | GCC_DYNAMIC_NO_PIC = NO; 211 | GCC_NO_COMMON_BLOCKS = YES; 212 | GCC_OPTIMIZATION_LEVEL = 0; 213 | GCC_PREPROCESSOR_DEFINITIONS = ( 214 | "DEBUG=1", 215 | "$(inherited)", 216 | ); 217 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 218 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 219 | GCC_WARN_UNDECLARED_SELECTOR = YES; 220 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 221 | GCC_WARN_UNUSED_FUNCTION = YES; 222 | GCC_WARN_UNUSED_VARIABLE = YES; 223 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 224 | MTL_FAST_MATH = YES; 225 | ONLY_ACTIVE_ARCH = YES; 226 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 227 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 228 | }; 229 | name = Debug; 230 | }; 231 | EE92D1FC2A4B5851007A0574 /* Release */ = { 232 | isa = XCBuildConfiguration; 233 | buildSettings = { 234 | ALWAYS_SEARCH_USER_PATHS = NO; 235 | CLANG_ANALYZER_NONNULL = YES; 236 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 237 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 238 | CLANG_ENABLE_MODULES = YES; 239 | CLANG_ENABLE_OBJC_ARC = YES; 240 | CLANG_ENABLE_OBJC_WEAK = YES; 241 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 242 | CLANG_WARN_BOOL_CONVERSION = YES; 243 | CLANG_WARN_COMMA = YES; 244 | CLANG_WARN_CONSTANT_CONVERSION = YES; 245 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 246 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 247 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 248 | CLANG_WARN_EMPTY_BODY = YES; 249 | CLANG_WARN_ENUM_CONVERSION = YES; 250 | CLANG_WARN_INFINITE_RECURSION = YES; 251 | CLANG_WARN_INT_CONVERSION = YES; 252 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 253 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 254 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 255 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 256 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 257 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 258 | CLANG_WARN_STRICT_PROTOTYPES = YES; 259 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 260 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 261 | CLANG_WARN_UNREACHABLE_CODE = YES; 262 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 263 | COPY_PHASE_STRIP = NO; 264 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 265 | ENABLE_NS_ASSERTIONS = NO; 266 | ENABLE_STRICT_OBJC_MSGSEND = YES; 267 | GCC_C_LANGUAGE_STANDARD = gnu11; 268 | GCC_NO_COMMON_BLOCKS = YES; 269 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 270 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 271 | GCC_WARN_UNDECLARED_SELECTOR = YES; 272 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 273 | GCC_WARN_UNUSED_FUNCTION = YES; 274 | GCC_WARN_UNUSED_VARIABLE = YES; 275 | MTL_ENABLE_DEBUG_INFO = NO; 276 | MTL_FAST_MATH = YES; 277 | SWIFT_COMPILATION_MODE = wholemodule; 278 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 279 | }; 280 | name = Release; 281 | }; 282 | EE92D1FE2A4B5851007A0574 /* Debug */ = { 283 | isa = XCBuildConfiguration; 284 | buildSettings = { 285 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 286 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 287 | CODE_SIGN_ENTITLEMENTS = MagneticScrollExample/MagneticScrollExample.entitlements; 288 | CODE_SIGN_STYLE = Automatic; 289 | CURRENT_PROJECT_VERSION = 1; 290 | DEVELOPMENT_ASSET_PATHS = "\"MagneticScrollExample/Preview Content\""; 291 | DEVELOPMENT_TEAM = 5QXSW95H5M; 292 | ENABLE_HARDENED_RUNTIME = YES; 293 | ENABLE_PREVIEWS = YES; 294 | GENERATE_INFOPLIST_FILE = YES; 295 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 296 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 297 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 298 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 299 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 300 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 301 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 302 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 303 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 304 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 305 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 306 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 307 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 308 | MACOSX_DEPLOYMENT_TARGET = 13.3; 309 | MARKETING_VERSION = 1.0; 310 | PRODUCT_BUNDLE_IDENTIFIER = com.joinpoppin.MagneticScrollExample; 311 | PRODUCT_NAME = "$(TARGET_NAME)"; 312 | SDKROOT = auto; 313 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 314 | SWIFT_EMIT_LOC_STRINGS = YES; 315 | SWIFT_VERSION = 5.0; 316 | TARGETED_DEVICE_FAMILY = "1,2"; 317 | }; 318 | name = Debug; 319 | }; 320 | EE92D1FF2A4B5851007A0574 /* Release */ = { 321 | isa = XCBuildConfiguration; 322 | buildSettings = { 323 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 324 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 325 | CODE_SIGN_ENTITLEMENTS = MagneticScrollExample/MagneticScrollExample.entitlements; 326 | CODE_SIGN_STYLE = Automatic; 327 | CURRENT_PROJECT_VERSION = 1; 328 | DEVELOPMENT_ASSET_PATHS = "\"MagneticScrollExample/Preview Content\""; 329 | DEVELOPMENT_TEAM = 5QXSW95H5M; 330 | ENABLE_HARDENED_RUNTIME = YES; 331 | ENABLE_PREVIEWS = YES; 332 | GENERATE_INFOPLIST_FILE = YES; 333 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 334 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 335 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 336 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 337 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 338 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 339 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 340 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 341 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 342 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 343 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 344 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 345 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 346 | MACOSX_DEPLOYMENT_TARGET = 13.3; 347 | MARKETING_VERSION = 1.0; 348 | PRODUCT_BUNDLE_IDENTIFIER = com.joinpoppin.MagneticScrollExample; 349 | PRODUCT_NAME = "$(TARGET_NAME)"; 350 | SDKROOT = auto; 351 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 352 | SWIFT_EMIT_LOC_STRINGS = YES; 353 | SWIFT_VERSION = 5.0; 354 | TARGETED_DEVICE_FAMILY = "1,2"; 355 | }; 356 | name = Release; 357 | }; 358 | /* End XCBuildConfiguration section */ 359 | 360 | /* Begin XCConfigurationList section */ 361 | EE92D1E92A4B5850007A0574 /* Build configuration list for PBXProject "MagneticScrollExample" */ = { 362 | isa = XCConfigurationList; 363 | buildConfigurations = ( 364 | EE92D1FB2A4B5851007A0574 /* Debug */, 365 | EE92D1FC2A4B5851007A0574 /* Release */, 366 | ); 367 | defaultConfigurationIsVisible = 0; 368 | defaultConfigurationName = Release; 369 | }; 370 | EE92D1FD2A4B5851007A0574 /* Build configuration list for PBXNativeTarget "MagneticScrollExample" */ = { 371 | isa = XCConfigurationList; 372 | buildConfigurations = ( 373 | EE92D1FE2A4B5851007A0574 /* Debug */, 374 | EE92D1FF2A4B5851007A0574 /* Release */, 375 | ); 376 | defaultConfigurationIsVisible = 0; 377 | defaultConfigurationName = Release; 378 | }; 379 | /* End XCConfigurationList section */ 380 | 381 | /* Begin XCSwiftPackageProductDependency section */ 382 | EE447A7B2A4B5C6600491BCA /* MagneticScroll */ = { 383 | isa = XCSwiftPackageProductDependency; 384 | productName = MagneticScroll; 385 | }; 386 | /* End XCSwiftPackageProductDependency section */ 387 | }; 388 | rootObject = EE92D1E62A4B5850007A0574 /* Project object */; 389 | } 390 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections.git", 7 | "state" : { 8 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 9 | "version" : "1.0.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swiftui-introspect", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 16 | "state" : { 17 | "revision" : "730ab9e6cdbb3122ad88277b295c4cecd284a311", 18 | "version" : "0.9.1" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/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 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // MagneticScrollExample 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | import MagneticScroll 10 | 11 | struct MultipleBlocksView: View { 12 | @Namespace var MagneticBlockNameSpace 13 | @State private var activeBlock: Block.ID = "First" 14 | 15 | let ids = [ 16 | "First", 17 | "Second", 18 | "Third", 19 | "Fourth", 20 | "Fifth", 21 | "Sixth" 22 | ] 23 | 24 | let color = Color(red: 0.48, green: 0.24, blue: 0.75) 25 | 26 | var body: some View { 27 | ScrollViewReader { proxy in 28 | MagneticScrollView(activeBlock: $activeBlock) { organizer in 29 | ForEach(ids, id: \.self) { id in 30 | Block(id: id, height: 600, inActiveHeight: 450) { 31 | VStack(spacing: 10.0) { 32 | if activeBlock == id { 33 | Text("This is a header") 34 | .font(.title2) 35 | .fontWeight(.bold) 36 | .foregroundColor(color) 37 | .frame(maxWidth: .infinity, alignment: .center) 38 | // .matchedGeometryEffect(id: "Header", in: MagneticBlockNameSpace) 39 | } 40 | Spacer() 41 | TextField("Input info here", text: .constant(""), onCommit: { 42 | // Next block 43 | }) 44 | .padding() 45 | .border(.black) 46 | .background(Color.init(white: 0.05)) 47 | .cornerRadius(16) 48 | Spacer() 49 | 50 | if activeBlock == id { 51 | VStack { 52 | Text("This is secondary info").opacity(0.5) 53 | Button("Prev Block") { 54 | let i = ids.firstIndex(of: activeBlock)! 55 | self.activeBlock = ids[i - 1] 56 | } 57 | .disabled(ids.firstIndex(of: activeBlock)! == 0) 58 | Button("Next Block") { 59 | let i = ids.firstIndex(of: activeBlock)! 60 | self.activeBlock = ids[i + 1] 61 | } 62 | .disabled(ids.firstIndex(of: activeBlock)! == ids.count - 1) 63 | } 64 | // .matchedGeometryEffect(id: "Content", in: MagneticBlockNameSpace) 65 | 66 | } 67 | } 68 | .padding() 69 | } 70 | .frame(maxWidth: .infinity) 71 | .background(Color(.systemGray6)) 72 | .cornerRadius(16) 73 | .overlay { 74 | Group { 75 | if activeBlock == id { 76 | RoundedRectangle(cornerRadius: 16) 77 | .stroke(lineWidth: 1) 78 | .foregroundColor(color) 79 | } 80 | } 81 | } 82 | .padding(1) 83 | .animation(.spring(response: 0.3, dampingFraction: 1.2), value: activeBlock) 84 | } 85 | } 86 | .formStyle() 87 | .velocityThreshold(0.8) 88 | .setTimeout(0.39) 89 | .padding(.horizontal) 90 | .background(Color.black) 91 | .preferredColorScheme(.dark) 92 | } 93 | } 94 | } 95 | 96 | struct ContentView: View { 97 | var body: some View { 98 | MultipleBlocksView() 99 | } 100 | } 101 | 102 | struct SingleBlocksView: View { 103 | @State private var activeBlock : Block.ID = "First" 104 | @State private var height: CGFloat = 200 105 | var body: some View { 106 | VStack { 107 | MagneticScrollView(activeBlock: $activeBlock) { organizer in 108 | Block(id: "scroll field", height: 400, inActiveHeight: 600) { 109 | Button("Scroll To Bottom") { 110 | activeBlock = "Fifth" 111 | } 112 | .frame(maxWidth: .infinity) 113 | } 114 | .background(Color.green) 115 | Block(id: "First", height: 400, inActiveHeight: 600) { 116 | Text("First Block") 117 | .frame(maxWidth: .infinity) 118 | } 119 | 120 | .background(Color.blue) 121 | 122 | Block(id: "Second", height: 400, inActiveHeight: 600) { 123 | Text("Second Block") 124 | .frame(maxWidth: .infinity) 125 | } 126 | .background(Color.blue) 127 | 128 | Block(id: "Third", height: 400, inActiveHeight: 600) { 129 | Text("Third Block") 130 | .frame(maxWidth: .infinity) 131 | } 132 | .background(Color.blue) 133 | 134 | Block(id: "Fourth", height: 400, inActiveHeight: 600) { 135 | Text("Fourth Block") 136 | } 137 | .background(Color.blue) 138 | 139 | Block(id: "Fifth", height: 400, inActiveHeight: 600) { 140 | Button("Top") { 141 | activeBlock = "scroll field" 142 | } 143 | .background(Color.red) 144 | 145 | } 146 | } 147 | .changesActiveBlockOnTapGesture() 148 | .triggersHapticFeedbackOnBlockChange() 149 | .velocityThreshold(1.0) 150 | } 151 | } 152 | } 153 | 154 | struct ContentView_Previews: PreviewProvider { 155 | static var previews: some View { 156 | ContentView() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/MagneticScrollExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/MagneticScrollExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagneticScrollExampleApp.swift 3 | // MagneticScrollExample 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MagneticScrollExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/MagneticScrollExample/MagneticScrollExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Poppin 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.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections.git", 7 | "state" : { 8 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 9 | "version" : "1.0.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swiftui-introspect", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 16 | "state" : { 17 | "revision" : "6dce3c8f5bfa8bc20120c7497da27e984a8813aa", 18 | "version" : "0.7.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 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: "MagneticScroll", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "MagneticScroll", 13 | targets: ["MagneticScroll"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", .upToNextMajor(from: "0.7.0")), 17 | .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.0")) 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "MagneticScroll", 24 | dependencies: [ 25 | .product(name: "OrderedCollections", package: "swift-collections"), 26 | .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), 27 | ] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![thmb-modified](https://github.com/Poppin-Technologies/magnetic-scroll/assets/69051988/0b0c3d8e-924b-42ee-b6d7-143f2948522b) 2 | # MagneticScroll 3 | 4 | A Library that adds a sticky behavior to the `SwiftUI`'s `ScrollView`, while triggering a smooth haptic feedback as you scroll through the views. 5 | 6 |

Installation

7 |

Requires iOS 14.0+ 8 | 9 | MagneticScroll currently can only be installed through the Swift Package Manager.

10 | 11 | 12 | 13 | 20 | 21 | 22 | 30 |
14 | 15 | Swift Package Manager 16 | 17 |
18 | Add the Package URL: 19 |
23 |
24 | 25 | ``` 26 | https://github.com/Poppin-Technologies/magnetic-scroll.git 27 | ``` 28 | 29 |
31 | 32 | ## Showcase 33 | ### ⚛️ Regular Magnetic Scroll 34 | As you scroll, when the `ScrollView`'s velocity is lesser than `MagneticScrollView`'s velocity, magnetic scroll automatically sticks to the predicted end location. 35 | 36 | regular 37 | 38 | ### ✨ Manually changing the blocks 39 | manual 40 | 41 | ### 🙌 Magnetic Scroll with `.matchedGeometryEffect` modifier 42 | mached 43 | 44 | ### 🔥 MagneticCarousel 45 | If you set the `velocityThreshold` to `.infinity`, MagneticScroll becomes a carousel. 46 | 47 | carousel 48 | 49 | ## Usage 50 | MagneticScroll is designed to operate with a view called `Block`. For MagneticScroll to detect scroll changes, it requires your content to be wrapped within `Block` elements. 51 | ```swift 52 | import SwiftUI 53 | import MagneticScroll 54 | 55 | struct ContentView: View { 56 | // If you were to set activeBlock to "second" or "first" manually, MagneticScroll would automatically scroll to the block with that id. 57 | @State private var activeBlock = "first" 58 | var body: some View { 59 | MagneticScrollView(activeBlock: $activeBlock) { organizer in 60 | Block(id: "first", height: 400, inActiveHeight: 300) { // All of these fields are optional, except the ID, but magnetic scroll works x5 better with constant heights. 61 | Text("Hello World") 62 | } 63 | Block(id: "second", height: 400, inActiveHeight: 300) { 64 | Text("Hello World") 65 | } 66 | } 67 | } 68 | } 69 | ``` 70 | ## Methods 71 | 72 | Here are the methods available for configuring the behavior of `MagneticScrollView`: 73 | 74 | ### 🖱️ changesActiveBlockOnTapGesture(_ value: Bool) 75 | Sets whether the active block should be changed on a tap gesture. 76 | ### 🏁 velocityThreshold(_ threshold: Double) 77 | Sets the velocity threshold for `MagneticScrollView` to react to scroll view velocity. 78 | ### 📳 triggersHapticFeedbackOnBlockChange(_ bool: Bool) 79 | Sets whether haptic feedback should be triggered when the active block changes. 80 | ### 📳 triggersHapticFeedbackOnActiveBlockChange(_ bool: Bool) 81 | Sets whether haptic feedback should be triggered when the active block changes. 82 | ### 📋 formStyle(_ bool:) 83 | Sets whether the form style should be enabled or not. 84 | ### ⏳ scrollAnimationDuration(_ duration: Double) 85 | Sets the scroll animation duration when changing the active block. 86 | ### ⌛ setTimeout(_ duration: Double) 87 | Sets the timeout duration needed to change a block to another. 88 | 89 | ## Organizer 90 | `MagneticScrollView` gives an organizer to control the behavior of itself. Organizer contains a `ScrollViewProxy` so if you want to control the `ScrollView` itself, you can use that. 91 | ### 🪐 activate(with: Block.ID) 92 | Activates a block with given ID. But doesn't scroll to it 93 | ### 👇🏻 scrollTo(id: Block.ID, anchor: UnitPoint) 94 | Scrolls and activates a block with given id and anchor 95 | ### 👆🏻 scrollToCurrentOffset() 96 | Scrolls to the nearest block with the current offset of the `MagneticScrollView`. 97 | 98 | 99 | ## Apps Using MagneticScroll 100 | 101 | #### [Poppin](https://apps.apple.com/us/app/poppin-the-party-platform/id1573674111) - The Party Platform 102 | ### ![logoppin](https://github.com/Poppin-Technologies/magnetic-scroll/assets/69051988/f7cb683f-131d-4fbd-ab32-0dc00d4f8e75) 103 | ### poppin 104 | -------------------------------------------------------------------------------- /Sources/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poppin-Technologies/magnetic-scroll/17f5206d4008da5f0fd623c974238bb00d944079/Sources/.DS_Store -------------------------------------------------------------------------------- /Sources/MagneticScroll/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poppin-Technologies/magnetic-scroll/17f5206d4008da5f0fd623c974238bb00d944079/Sources/MagneticScroll/.DS_Store -------------------------------------------------------------------------------- /Sources/MagneticScroll/Extensions/Haptic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Haptic.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 30.06.2023. 6 | // 7 | 8 | import UIKit 9 | import AudioToolbox 10 | 11 | func generateHapticFeedback() { 12 | AudioServicesPlaySystemSound(1519) 13 | } 14 | 15 | func generateSelectedFeedback() { 16 | let feedbackGenerator = UISelectionFeedbackGenerator() 17 | feedbackGenerator.prepare() 18 | feedbackGenerator.selectionChanged() 19 | } 20 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Extensions/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension View { 12 | func readSize(onChange: @escaping (CGSize) -> Void) -> some View { 13 | background( 14 | GeometryReader { geometryProxy in 15 | Color.clear 16 | .preference(key: SizePreferenceKey.self, value: geometryProxy.size) 17 | } 18 | ) 19 | .onPreferenceChange(SizePreferenceKey.self, perform: onChange) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Helpers/OffsetObservingScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OffsetObservingScrollView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 29.06.2023. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUIIntrospect 10 | 11 | internal struct OffsetObservingScrollView: View { 12 | var axes: Axis.Set = [.vertical] 13 | var showsIndicators = true 14 | @Binding var offset: CGPoint 15 | @ViewBuilder var content: () -> Content 16 | @EnvironmentObject var organizer: MagneticOrganizer 17 | 18 | private let coordinateSpaceName = UUID() 19 | 20 | var body: some View { 21 | ScrollView(axes, showsIndicators: showsIndicators) { 22 | PositionObservingView( 23 | coordinateSpace: .named(coordinateSpaceName), 24 | position: Binding( 25 | get: { offset }, 26 | set: { newOffset in 27 | offset = CGPoint( 28 | x: -newOffset.x, 29 | y: -newOffset.y 30 | ) 31 | } 32 | ), 33 | content: content 34 | ) 35 | } 36 | .coordinateSpace(name: coordinateSpaceName) 37 | .introspect(.scrollView, on: .iOS(.v14),.iOS(.v15), .iOS(.v16), .iOS(.v17)) { scrollView in 38 | scrollView.setValue(0.35, forKeyPath: "contentOffsetAnimationDuration") 39 | scrollView.delegate = organizer 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Helpers/PositionObservingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PositionObservingView.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 29.06.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PositionObservingView: View { 11 | var coordinateSpace: CoordinateSpace 12 | @Binding var position: CGPoint 13 | @ViewBuilder var content: () -> Content 14 | 15 | var body: some View { 16 | content() 17 | .background(GeometryReader { geometry in 18 | Color.clear.preference( 19 | key: PreferenceKey.self, 20 | value: geometry.frame(in: coordinateSpace).origin 21 | ) 22 | }) 23 | .onPreferenceChange(PreferenceKey.self) { position in 24 | self.position = position 25 | } 26 | } 27 | } 28 | 29 | private extension PositionObservingView { 30 | struct PreferenceKey: SwiftUI.PreferenceKey { 31 | static var defaultValue: CGPoint { .zero } 32 | 33 | static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { 34 | // No-op 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Logic/MagneticBlock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagneticBlock.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 29.06.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | internal struct MagneticBlock: Identifiable { 12 | var id: String = "" 13 | var height: CGFloat 14 | } 15 | 16 | @available(iOS 14.0, *) 17 | extension MagneticBlock: Equatable { 18 | static func == (lhs: MagneticBlock, rhs: MagneticBlock) -> Bool { 19 | lhs.id == rhs.id 20 | } 21 | } 22 | 23 | @available(iOS 14.0, *) 24 | extension MagneticBlock: Hashable { 25 | func hash(into hasher: inout Hasher) { 26 | hasher.combine(id) 27 | } 28 | } 29 | 30 | extension MagneticBlock { 31 | static var EmptyBlock: MagneticBlock { 32 | return .init(height: 0) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Logic/MagneticScrollConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagneticScrollConfiguration.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 3.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | Configuration for `MagneticScrollView` 12 | */ 13 | internal class MagneticScrollConfiguration: ObservableObject { 14 | /// If the `activeBlock` should be changed on tap gesture 15 | @Published var changesActiveBlockOnTapGesture: Bool = true 16 | /// Value that decides how `MagneticScrolLView` should react to `Velocity` of `ScrollView` 17 | @Published var scrollVelocityThreshold: Double = 0.9 18 | /// Determines whether haptic feedback should be triggered when any block is scrolled. 19 | @Published var triggersHapticFeedbackOnBlockChange = true 20 | /// The duration of the scroll animation when changing the active block. 21 | @Published var scrollAnimationDuration: Double = 0.35 22 | /// Determines whether haptic feedback should be triggered when the active block changes. 23 | @Published var triggersHapticFeedbackOnActiveBlockChange = false 24 | /// If the form style should be enabled or not. 25 | @Published var formStyle = false 26 | /// The timeout duration to change a block to another. 27 | @Published var timeoutNeeded: Double = 0.39 28 | } 29 | 30 | 31 | extension MagneticScrollConfiguration : Equatable { 32 | static func == (lhs: MagneticScrollConfiguration, rhs: MagneticScrollConfiguration) -> Bool { 33 | return ( 34 | lhs.changesActiveBlockOnTapGesture == rhs.changesActiveBlockOnTapGesture && 35 | lhs.scrollVelocityThreshold == rhs.scrollVelocityThreshold && 36 | lhs.triggersHapticFeedbackOnBlockChange == rhs.triggersHapticFeedbackOnBlockChange && 37 | lhs.scrollAnimationDuration == rhs.scrollAnimationDuration && 38 | lhs.formStyle == rhs.formStyle 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Logic/Organizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagneticOrganizer.swift 3 | // 4 | // 5 | // Created by Demirhan Mehmet Atabey on 29.06.2023. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import OrderedCollections 11 | 12 | /// MagneticOrganizer to control `Block`s. Supplied by `MagneticScrollView` to all subviews. 13 | @available(iOS 14.0, *) 14 | @MainActor public class MagneticOrganizer: NSObject, ObservableObject, UIScrollViewDelegate { 15 | 16 | // MARK: Wrapped Properties 17 | 18 | /// Blocks that's been passed to`MagneticScrollView` 19 | @Published var blocks: OrderedSet = OrderedSet() 20 | /// `MagneticScrollView`s current offset 21 | @Published var scrollViewOffset: CGPoint = .zero 22 | /// Current active block 23 | @Published var activeBlock: MagneticBlock? = nil 24 | /// An array of `CGFloat` to calculate velocity of `MagneticScrollView` 25 | @Published var lastScrollValues: [CGFloat] = [] 26 | /// Whether or not `MagneticScrollView` is scrolling 27 | @Published var isScrolling = false 28 | /// Last change date. 29 | @Published var lastChangeDate: Date = Date.distantFuture 30 | /// Whether or not `UIScrollView` is scrolling 31 | @Published var uiScrollViewScrolling: Bool = false 32 | 33 | var spacing: CGFloat 34 | /// Anchor that blocks will use 35 | var anchor: UnitPoint 36 | 37 | var disableMagneticScroll: Bool = false 38 | 39 | /// The proxy of the magnetic scroll view. 40 | public var proxy: ScrollViewProxy? = nil 41 | 42 | 43 | // MARK: - Private variables 44 | 45 | private var cancellables = Set() 46 | private var previousOffset: CGFloat = 0.0 47 | private var scrollIndex = 0 48 | private var activeHapticBlock: MagneticBlock? = nil 49 | private var configuration: MagneticScrollConfiguration? 50 | 51 | var blocksToActiveBlock : [MagneticBlock] { 52 | guard let activeBlock = activeBlock else { return [] } 53 | guard let indexOfActiveBlock = blocks.firstIndex(of: activeBlock), indexOfActiveBlock != 0 else { return [] } 54 | return Array(blocks.prefix(upTo: indexOfActiveBlock)) 55 | } 56 | 57 | var offsetUntilActiveBlock : CGFloat { 58 | guard let activeBlock = activeBlock else { return 0.0 } 59 | var height: CGFloat = 0.0 60 | 61 | for block in blocks.prefix(upTo: blocks.firstIndex(of: activeBlock) ?? 0) { 62 | height += block.height 63 | } 64 | 65 | return height 66 | } 67 | 68 | // MARK: - Initializers 69 | 70 | /// Initializes the `MagneticOrganizer`. 71 | /// - Parameters: 72 | /// - spacing: The spacing between blocks. Default value is 8. 73 | /// - anchor: The anchor point that blocks will use. Default value is `.center`. 74 | internal init(spacing: CGFloat, anchor: UnitPoint) { 75 | self.spacing = spacing 76 | self.anchor = anchor 77 | 78 | super.init() 79 | self.setupPublishers() 80 | } 81 | 82 | /** Feeds the `MagneticOrganizer` with the given `MagneticBlock`. 83 | - Parameter block: The `MagneticBlock` to feed to the organizer. 84 | */ 85 | internal func feed(with block: MagneticBlock) { 86 | blocks.updateOrInsert(block, at: 0) 87 | } 88 | 89 | /** 90 | Prepares the `MagneticOrganizer` with the given `ScrollViewProxy`. 91 | - Parameter proxy: The `ScrollViewProxy` to prepare with. 92 | */ 93 | internal func prepare(with proxy: ScrollViewProxy, configuration: MagneticScrollConfiguration) { 94 | self.proxy = proxy 95 | self.configuration = configuration 96 | } 97 | 98 | /** 99 | Activates the block with the specified ID. 100 | - Parameter id: The ID of the block to activate. 101 | */ 102 | public func activate(with id: Block.ID) { 103 | guard let block = self.block(with: id) else { return } 104 | 105 | self.scrollTo(block: block) 106 | } 107 | 108 | internal func block(with id: String) -> MagneticBlock? { 109 | return blocks.first(where: { $0.id == id }) 110 | } 111 | 112 | /** 113 | Updates the given block in the `MagneticOrganizer`. 114 | - Parameter block: The block to update. 115 | */ 116 | internal func update(block: MagneticBlock) { 117 | if let blockIndex = blocks.firstIndex(of: block) { 118 | blocks.update(block, at: blockIndex) 119 | } else { 120 | blocks.append(block) 121 | } 122 | } 123 | 124 | /** 125 | Replaces the given block with a new block in the `MagneticOrganizer`. 126 | - Parameter block: The block to replace. 127 | - Parameter newBlock: The new block to insert. 128 | */ 129 | internal func replace(block: MagneticBlock, with newBlock: MagneticBlock) { 130 | guard let blockIndex = blocks.firstIndex(of: block) else { return } 131 | 132 | blocks.remove(at: blockIndex) 133 | blocks.insert(newBlock, at: blockIndex) 134 | } 135 | 136 | // MARK: - Private Methods 137 | 138 | private func setupPublishers() { 139 | $scrollViewOffset 140 | .debounce(for: 0.02, scheduler: DispatchQueue.main) 141 | .sink { [weak self] scrollViewOffset in 142 | guard let self = self else { return } 143 | self.isScrolling = false 144 | } 145 | .store(in: &cancellables) 146 | 147 | // $scrollViewOffset 148 | // .debounce(for: 0.2, scheduler: DispatchQueue.main) 149 | // .sink { [weak self] point in 150 | // guard let self = self else { return } 151 | // self.scrollToOffset() 152 | // } 153 | // .store(in: &cancellables) 154 | 155 | $scrollViewOffset.sink { [weak self] point in 156 | guard let self = self else { return } 157 | self.isScrolling = true 158 | if self.lastScrollValues.count > self.scrollIndex { 159 | self.lastScrollValues[scrollIndex] = point.y 160 | } 161 | else { 162 | self.lastScrollValues.append(point.y) 163 | } 164 | 165 | self.scrollIndex = (self.scrollIndex + 1) % 10 166 | if configuration?.triggersHapticFeedbackOnBlockChange == true { 167 | self.triggerHapticFeedbackOnBlockChange() 168 | } 169 | lastChangeDate = Date() 170 | } 171 | .store(in: &cancellables) 172 | 173 | $lastChangeDate 174 | .debounce(for: 0.3, scheduler: DispatchQueue.main) 175 | .sink { d in 176 | self.scrollToCurrentOffset() 177 | } 178 | .store(in: &cancellables) 179 | 180 | $lastScrollValues 181 | .sink { [weak self] array in 182 | guard let self = self else { return } 183 | if !self.disableMagneticScroll { 184 | guard array.count > 0 else { return } 185 | var totalDifference: Double = 0.0 186 | 187 | for i in 0.. 0 else { return } 242 | guard !uiScrollViewScrolling else { return } 243 | 244 | if activeBlock == nil { 245 | activeBlock = blocks[0] 246 | } 247 | self.lastScrollValues = [] 248 | 249 | 250 | let nonActivatedOffset = (scrollViewOffset.y - offsetUntilActiveBlock) 251 | 252 | if nonActivatedOffset > 0 { 253 | if nonActivatedOffset > (activeBlock!.height / 2) { 254 | let blocksFromActiveBlock = self.blocks(from: activeBlock) 255 | var scrolledOffset: CGFloat = 0.0 256 | for (index, block) in blocksFromActiveBlock.enumerated() { 257 | if index == blocksFromActiveBlock.count - 1 { 258 | self.scrollTo(block: block) 259 | return 260 | } 261 | 262 | let nextBlock = blocksFromActiveBlock[index + 1] 263 | let offset = scrolledOffset + block.height 264 | 265 | if offset + nextBlock.height > nonActivatedOffset { 266 | let distanceToCurrentBlock = nonActivatedOffset - offset 267 | let distanceToNextBlock = (offset + block.height) - nonActivatedOffset 268 | if distanceToNextBlock < distanceToCurrentBlock { 269 | self.scrollTo(block: nextBlock) 270 | break 271 | } 272 | else { 273 | self.scrollTo(block: block) 274 | break 275 | } 276 | } 277 | else { 278 | scrolledOffset += nextBlock.height 279 | } 280 | } 281 | } 282 | } 283 | else { 284 | if nonActivatedOffset < (-1 * (activeBlock!.height / 2)) { 285 | var scrolledOffset: CGFloat = 0.0 286 | 287 | let blocksToActivateBlock: [MagneticBlock] = blocksToActiveBlock.reversed() 288 | for (index, block) in blocksToActivateBlock.enumerated() { 289 | if index == blocksToActivateBlock.count - 1 { 290 | self.scrollTo(block: block) 291 | return 292 | } 293 | 294 | let previousBlock = blocksToActivateBlock[index + 1] 295 | let absoluteOffset = previousBlock.height * -1 296 | 297 | if nonActivatedOffset < (scrolledOffset + absoluteOffset) { 298 | scrolledOffset += previousBlock.height * -1 299 | } 300 | else { 301 | if scrolledOffset > 0.0 { 302 | let centerOfPreviousBlock = previousBlock.height / 2 303 | let centerOfBlock = block.height / 2 304 | 305 | if absoluteOffset + centerOfPreviousBlock < absoluteOffset + centerOfBlock { 306 | self.scrollTo(block: previousBlock) 307 | } 308 | else { 309 | self.scrollTo(block: block) 310 | } 311 | 312 | } 313 | else { 314 | self.scrollTo(block: block) 315 | } 316 | return 317 | } 318 | } 319 | 320 | } 321 | } 322 | } 323 | } 324 | 325 | extension MagneticOrganizer { 326 | func triggerHapticFeedbackOnBlockChange() { 327 | if activeHapticBlock == nil { 328 | self.activeHapticBlock = activeBlock 329 | } 330 | guard let activeHapticBlock = activeHapticBlock else { return } 331 | guard let activeBlockIndex = blocks.firstIndex(of: activeHapticBlock) else { return } 332 | let blocksToActivate = self.blocks(to: activeHapticBlock) 333 | 334 | let activatedOffset = blocksToActivate.reduce(0) { $0 + $1.height } 335 | 336 | let realOffset = self.scrollViewOffset.y - activatedOffset 337 | 338 | if realOffset > 0 { 339 | var nextBlockIndex = activeBlockIndex + 1 340 | if activeBlockIndex == blocks.count - 1 { nextBlockIndex = activeBlockIndex } 341 | let nextBlock = blocks[nextBlockIndex] 342 | 343 | if realOffset > nextBlock.height / 2 { 344 | self.activeHapticBlock = nextBlock 345 | generateHapticFeedback() 346 | } 347 | } 348 | else { 349 | var previousBlockIndex = activeBlockIndex - 1 350 | if activeBlockIndex == 0 { previousBlockIndex = 0 } 351 | 352 | let previousBlock = blocks[previousBlockIndex] 353 | 354 | if abs(realOffset) > previousBlock.height { 355 | self.activeHapticBlock = previousBlock 356 | generateHapticFeedback() 357 | } 358 | } 359 | } 360 | } 361 | 362 | // MARK: - View Extensions 363 | 364 | extension MagneticOrganizer { 365 | func blocks(from block: MagneticBlock?) -> [MagneticBlock] { 366 | guard let block = block else { return [] } 367 | guard let indexOfBlock = blocks.firstIndex(of: block), indexOfBlock != blocks.count - 1 else { return [] } 368 | return Array(blocks.suffix(from: (indexOfBlock + 1))) 369 | } 370 | 371 | func blocks(to block: MagneticBlock?) -> [MagneticBlock] { 372 | guard let block = block else { return [] } 373 | guard let indexOfBlock = blocks.firstIndex(of: block), indexOfBlock != 0 else { return [] } 374 | return Array(blocks.prefix(upTo: indexOfBlock)) 375 | } 376 | } 377 | 378 | extension MagneticOrganizer { 379 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 380 | uiScrollViewScrolling = true 381 | } 382 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 383 | uiScrollViewScrolling = false 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/Utilities/SizePreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SizePreferenceKey.swift 3 | // 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SizePreferenceKey: PreferenceKey { 11 | static var defaultValue: CGSize = .zero 12 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/View/Block.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Block.swift 3 | // 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | A magnetic scroll block. 12 | 13 | ``MagneticScrollView`` displays a vertical stack of blocks. 14 | */ 15 | @available(iOS 14.0, *) 16 | public struct Block: View where Content: View { 17 | 18 | @EnvironmentObject var organizer: MagneticOrganizer 19 | @EnvironmentObject var configuration: MagneticScrollConfiguration 20 | 21 | // MARK: - State 22 | 23 | /// The height of this block. 24 | /// This value is used to calculate where the block is positioned in the scroll view. 25 | var height: CGFloat = .zero 26 | var inActiveHeight: CGFloat = .zero 27 | 28 | @State private var viewHeight: CGFloat = .zero 29 | 30 | // MARK: - Binding 31 | 32 | /// Whether or not block is shown 33 | @Binding var isShown: Bool 34 | 35 | // MARK: - Properties 36 | 37 | /// The ID of this block. 38 | /// The underlying `body` property attaches to this ID. 39 | public var id: String = "" 40 | 41 | /// The content to display. 42 | /// This is a type-erased view. 43 | var content: Content 44 | 45 | // MARK: - Private Properties 46 | 47 | private var magneticBlock: MagneticBlock { 48 | .init(id: id, height: isShown ? (isActive ? (viewHeight == .zero ? height : viewHeight) : inActiveHeight) : 0) 49 | } 50 | 51 | private var isActive: Bool { 52 | return organizer.activeBlock?.id == id 53 | } 54 | 55 | // MARK: - Views 56 | 57 | public var body: some View { 58 | ZStack { 59 | if isShown { 60 | VStack { 61 | content 62 | .readSize { size in 63 | if isActive { 64 | guard height.isZero else { return } 65 | self.viewHeight = size.height 66 | } 67 | else { 68 | guard inActiveHeight.isZero else { return } 69 | self.viewHeight = size.height 70 | } 71 | } 72 | .frame(height: magneticBlock.height) 73 | .frame(maxWidth: .infinity) 74 | } 75 | } 76 | } 77 | .contentShape(Rectangle()) 78 | .onTapGesture { 79 | if organizer.isScrolling { return } 80 | organizer.activeBlock = magneticBlock 81 | organizer.scrollTo(block: magneticBlock) 82 | } 83 | .animation(.spring(), value: magneticBlock.height) 84 | .id(id) 85 | .onAppear { 86 | organizer.feed(with: magneticBlock) 87 | } 88 | 89 | .onChange(of: organizer.activeBlock) { activeBlock in 90 | organizer.update(block: magneticBlock) 91 | } 92 | 93 | .onChange(of: isShown) { _ in 94 | organizer.update(block: magneticBlock) 95 | } 96 | } 97 | 98 | // MARK: - Initalizers 99 | 100 | public init( 101 | id: String = "", 102 | height: CGFloat = .zero, 103 | inActiveHeight: CGFloat = .zero, 104 | isShown: Binding = .constant(true), 105 | @ViewBuilder body: @escaping () -> Content 106 | ) { 107 | self.content = body() 108 | self.id = id 109 | self._isShown = isShown 110 | self.inActiveHeight = inActiveHeight 111 | self.height = height 112 | } 113 | } 114 | 115 | public extension Block { 116 | typealias ID = String 117 | } 118 | -------------------------------------------------------------------------------- /Sources/MagneticScroll/View/MagneticScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagneticScrollView.swift 3 | // 4 | // 5 | // Created by Ben Myers on 6/27/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** A scroll view that organizes `Block`s that's been passed to it. 11 | */ 12 | public struct MagneticScrollView: View where Content: View { 13 | 14 | // MARK: - Properties 15 | 16 | var spacing: CGFloat = 8 17 | 18 | /// Anchor that's used to scroll the blocks 19 | var anchor: UnitPoint = .center 20 | 21 | /// Whether the ScvrollView should show indicators. 22 | var showsIndicators: Bool = false 23 | 24 | /// The currently active block's ID 25 | @Binding var activeBlock: Block.ID 26 | 27 | // MARK: - Views 28 | 29 | private var content: (MagneticOrganizer) -> Content 30 | 31 | // MARK: - Private Properties 32 | 33 | @StateObject private var organizer: MagneticOrganizer 34 | @ObservedObject private var configuration = MagneticScrollConfiguration() 35 | 36 | @ViewBuilder 37 | public var body: some View { 38 | ScrollViewReader { scrollViewProxy in 39 | OffsetObservingScrollView(showsIndicators: showsIndicators, offset: $organizer.scrollViewOffset) { 40 | VStack(spacing: organizer.spacing) { 41 | content(organizer) 42 | } 43 | } 44 | .onAppear { 45 | organizer.prepare(with: scrollViewProxy, configuration: configuration) 46 | } 47 | .onChange(of: activeBlock) { newValue in 48 | organizer.activate(with: newValue) 49 | } 50 | .onChange(of: organizer.activeBlock) { block in 51 | guard let block = block else { return } 52 | if activeBlock != organizer.activeBlock?.id { 53 | activeBlock = block.id 54 | } 55 | } 56 | } 57 | .environmentObject(organizer) 58 | .environmentObject(configuration) 59 | } 60 | 61 | // MARK: - Initalizers 62 | /** 63 | - Parameter spacing: The spacing between blocks in the scroll view. Default value is 10. 64 | - Parameter activeBlock: A binding to the currently active block's ID. 65 | - Parameter body: A closure returning the content of the scroll view. 66 | */ 67 | public init( 68 | spacing: CGFloat = 10, 69 | anchor: UnitPoint = .center, 70 | activeBlock: Binding, 71 | @ViewBuilder content: @escaping (MagneticOrganizer) -> Content 72 | ) { 73 | self.content = content 74 | self.spacing = spacing 75 | self.anchor = anchor 76 | 77 | // Initialize Bindings 78 | self._activeBlock = activeBlock 79 | self._organizer = StateObject(wrappedValue: MagneticOrganizer(spacing: spacing, anchor: anchor)) 80 | } 81 | } 82 | 83 | 84 | // MARK: - View Extensions 85 | 86 | public extension MagneticScrollView { 87 | /** 88 | Sets whether the active block should be changed on tap gesture. 89 | 90 | - Parameters: 91 | - value: A Boolean value indicating whether the active block should be changed on tap gesture. Default value is `true`. 92 | 93 | - Returns: The `MagneticScrollView` instance with the updated configuration. 94 | */ 95 | func changesActiveBlockOnTapGesture(_ value: Bool = true) -> MagneticScrollView { 96 | configuration.changesActiveBlockOnTapGesture = value 97 | return self 98 | } 99 | 100 | /** 101 | Sets the velocity threshold for `MagneticScrollView` to react to scroll view velocity, if you get a junky behavior from `MagneticScrollView`, play with this value. 102 | 103 | - Parameters: 104 | - threshold: A `Double` value representing the scroll velocity threshold. 105 | Higher values result in faster scrolling to the calculated block, 106 | while lower values result in slower scrolling to the calculated block. By default, it is 0.9. 107 | 108 | - Returns: The `MagneticScrollView` instance with the updated configuration. 109 | */ 110 | func velocityThreshold(_ threshold: Double) -> MagneticScrollView { 111 | configuration.scrollVelocityThreshold = threshold 112 | return self 113 | } 114 | 115 | /** 116 | Sets whether haptic feedback should be triggered when the active block changes. 117 | 118 | - Parameters: 119 | - bool: A Boolean value indicating whether haptic feedback should be triggered on block change. Default value is `true`. 120 | 121 | - Returns: The `MagneticScrollView` instance with the updated configuration. 122 | */ 123 | func triggersHapticFeedbackOnBlockChange(_ bool: Bool = true) -> MagneticScrollView { 124 | configuration.triggersHapticFeedbackOnBlockChange = bool 125 | return self 126 | } 127 | 128 | 129 | /** 130 | Sets whether haptic feedback should be triggered when the active block changes. 131 | 132 | - Parameters: 133 | - bool: A Boolean value indicating whether haptic feedback should be triggered on block change. Default value is `true`. 134 | 135 | - Returns: The `MagneticScrollView` instance with the updated configuration. 136 | */ 137 | func triggersHapticFeedbackOnActiveBlockChange(_ bool: Bool = true) -> MagneticScrollView { 138 | configuration.triggersHapticFeedbackOnActiveBlockChange = bool 139 | return self 140 | } 141 | 142 | /** 143 | Sets whether the form style should be enabled or not. 144 | 145 | The `formStyle` configuration allows you to change the blocks by tapping when scrolling but not when not scrolling. 146 | */ 147 | func formStyle(_ bool: Bool = true) -> MagneticScrollView { 148 | configuration.formStyle = bool 149 | return self 150 | } 151 | 152 | /** 153 | Sets the scroll animation duration when changing the active block. 154 | 155 | - Parameters: 156 | - duration: A double value representing the scroll animation duration in seconds. 157 | 158 | - Returns: The `MagneticScrollView` instance with the updated configuration. 159 | */ 160 | func scrollAnimationDuration(_ duration: Double) -> MagneticScrollView { 161 | configuration.scrollAnimationDuration = duration 162 | return self 163 | } 164 | 165 | /** 166 | The timeout duration needed to change a block to another. 167 | 168 | - Parameters: 169 | - duration: A double value representing the timeout duration in seconds. 170 | 171 | - Returns: The `MagneticScrollView` instance with the updated configuration. 172 | */ 173 | func setTimeout(_ duration: Double) -> MagneticScrollView { 174 | configuration.timeoutNeeded = duration 175 | return self 176 | } 177 | } 178 | 179 | 180 | 181 | extension View { 182 | func delaysTouches(for duration: TimeInterval = 0.25, onTap action: @escaping () -> Void = {}) -> some View { 183 | modifier(DelaysTouches(duration: duration, action: action)) 184 | } 185 | } 186 | 187 | fileprivate struct DelaysTouches: ViewModifier { 188 | @State private var disabled = false 189 | @State private var touchDownDate: Date? = nil 190 | 191 | var duration: TimeInterval 192 | var action: () -> Void 193 | 194 | func body(content: Content) -> some View { 195 | Button(action: action) { 196 | content 197 | } 198 | .buttonStyle(DelaysTouchesButtonStyle(disabled: $disabled, duration: duration, touchDownDate: $touchDownDate)) 199 | .disabled(disabled) 200 | } 201 | } 202 | 203 | fileprivate struct DelaysTouchesButtonStyle: ButtonStyle { 204 | @Binding var disabled: Bool 205 | var duration: TimeInterval 206 | @Binding var touchDownDate: Date? 207 | 208 | func makeBody(configuration: Configuration) -> some View { 209 | configuration.label 210 | .onChange(of: configuration.isPressed, perform: handleIsPressed) 211 | } 212 | 213 | private func handleIsPressed(isPressed: Bool) { 214 | if isPressed { 215 | let date = Date() 216 | touchDownDate = date 217 | 218 | DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) { 219 | if date == touchDownDate { 220 | disabled = true 221 | 222 | DispatchQueue.main.async { 223 | disabled = false 224 | } 225 | } 226 | } 227 | } else { 228 | touchDownDate = nil 229 | disabled = false 230 | } 231 | } 232 | } 233 | --------------------------------------------------------------------------------