";
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 | 
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 |
14 |
15 | Swift Package Manager
16 |
17 |
18 | Add the Package URL:
19 | |
20 |
21 |
22 |
23 |
24 |
25 | ```
26 | https://github.com/Poppin-Technologies/magnetic-scroll.git
27 | ```
28 |
29 | |
30 |
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 |
37 |
38 | ### ✨ Manually changing the blocks
39 |
40 |
41 | ### 🙌 Magnetic Scroll with `.matchedGeometryEffect` modifier
42 |
43 |
44 | ### 🔥 MagneticCarousel
45 | If you set the `velocityThreshold` to `.infinity`, MagneticScroll becomes a carousel.
46 |
47 |
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 | ### 
103 | ###
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 |
--------------------------------------------------------------------------------