diff --git a/Development/Development.xcodeproj/project.pbxproj b/Development/Development.xcodeproj/project.pbxproj index 96c808d..8072d4e 100644 --- a/Development/Development.xcodeproj/project.pbxproj +++ b/Development/Development.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 4B08E0AB2CF5805500B05999 /* BookScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B08E0AA2CF5805200B05999 /* BookScrollView.swift */; }; + 4B08E0AD2CF5947100B05999 /* ScrollTracking in Frameworks */ = {isa = PBXBuildFile; productRef = 4B08E0AC2CF5947100B05999 /* ScrollTracking */; }; 4B26A67B2A33239500B75FB4 /* DevelopmentApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B26A67A2A33239500B75FB4 /* DevelopmentApp.swift */; }; 4B26A67D2A33239500B75FB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B26A67C2A33239500B75FB4 /* ContentView.swift */; }; 4B26A67F2A33239600B75FB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B26A67E2A33239600B75FB4 /* Assets.xcassets */; }; @@ -39,6 +41,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4B08E0AA2CF5805200B05999 /* BookScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookScrollView.swift; sourceTree = ""; }; 4B26A6772A33239500B75FB4 /* Development.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Development.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4B26A67A2A33239500B75FB4 /* DevelopmentApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevelopmentApp.swift; sourceTree = ""; }; 4B26A67C2A33239500B75FB4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -59,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4B08E0AD2CF5947100B05999 /* ScrollTracking in Frameworks */, 4BC34FAF2CDB1B9200D22811 /* CollectionView in Frameworks */, 4BEAFA4E2A3CE48800478C59 /* AsyncMultiplexImage-Nuke in Frameworks */, 4BEAFA4C2A3CE48800478C59 /* AsyncMultiplexImage in Frameworks */, @@ -91,6 +95,7 @@ 4B26A6792A33239500B75FB4 /* Development */ = { isa = PBXGroup; children = ( + 4B08E0AA2CF5805200B05999 /* BookScrollView.swift */, 4BC34FB02CDB1C0500D22811 /* BookCollectionView.swift */, 4B26A67A2A33239500B75FB4 /* DevelopmentApp.swift */, 4B26A67C2A33239500B75FB4 /* ContentView.swift */, @@ -144,6 +149,7 @@ 4BEAFA4B2A3CE48800478C59 /* AsyncMultiplexImage */, 4BEAFA4D2A3CE48800478C59 /* AsyncMultiplexImage-Nuke */, 4BC34FAE2CDB1B9200D22811 /* CollectionView */, + 4B08E0AC2CF5947100B05999 /* ScrollTracking */, ); productName = Development; productReference = 4B26A6772A33239500B75FB4 /* Development.app */; @@ -209,6 +215,7 @@ 4B3722682A33C701005FF24A /* BookUIKitBasedCompositional.swift in Sources */, 4BD04C172B2C13BB00FE41D9 /* Logger.swift in Sources */, 4BD04C192B2C15E100FE41D9 /* Color.swift in Sources */, + 4B08E0AB2CF5805500B05999 /* BookScrollView.swift in Sources */, 4BC34FB12CDB1C0C00D22811 /* BookCollectionView.swift in Sources */, 4BD04C152B2C05C600FE41D9 /* BookPlainCollectionView.swift in Sources */, 4B910EBC2A77A2F50079D26D /* BookUIKitBasedFlow.swift in Sources */, @@ -348,7 +355,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -378,7 +385,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -435,6 +442,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 4B08E0AC2CF5947100B05999 /* ScrollTracking */ = { + isa = XCSwiftPackageProductDependency; + productName = ScrollTracking; + }; 4B9981D72A34F9B500840751 /* CompositionKit */ = { isa = XCSwiftPackageProductDependency; package = 4B9981D62A34F9B500840751 /* XCRemoteSwiftPackageReference "CompositionKit" */; diff --git a/Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ec38286..683ff7d 100644 --- a/Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Development/Development.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e89878bf140720e29e318b93f44c5d0df6cceb734010ef6111491735cb630699", + "originHash" : "5262eaf10f856dee60593c62bee81f6931c0200844ea3211d6cd5f1c6b6fede5", "pins" : [ { "identity" : "compositionkit", @@ -46,6 +46,15 @@ "version" : "0.2.1" } }, + { + "identity" : "swift-with-prerender", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidGroup/swift-with-prerender", + "state" : { + "revision" : "83ea5d0f5a9fd0082c61e090f4b656c7b58ee0be", + "version" : "1.0.0" + } + }, { "identity" : "swiftui-async-multiplex-image", "kind" : "remoteSourceControl", @@ -64,6 +73,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect.git", + "state" : { + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } + }, { "identity" : "swiftui-support", "kind" : "remoteSourceControl", diff --git a/Development/Development/BookScrollView.swift b/Development/Development/BookScrollView.swift new file mode 100644 index 0000000..3f4cffa --- /dev/null +++ b/Development/Development/BookScrollView.swift @@ -0,0 +1,42 @@ +// +// BookScrollView.swift +// Development +// +// Created by Muukii on 2024/11/26. +// + +import SwiftUI +import ScrollTracking + + +struct OnAdditionalLoading_Previews: View, PreviewProvider { + + @State var items: [Int] = (0..<100).map { $0 } + @State var isLoading: Bool = false + + var body: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { + ForEach(items, id: \.self) { index in + Text("Item \(index)") + .font(.title) + } + } + if isLoading { + Text(isLoading ? "Loading..." : "End") + } + } + .onAdditionalLoading(isLoading: $isLoading) { + print("👨🏻 load") + try? await Task.sleep(for: .seconds(1)) + items.append(contentsOf: (items.count..<(items.count + 100)).map { $0 }) + print("appended") + } + } + + static var previews: some View { + Self() + } +} + + diff --git a/Development/Development/ContentView.swift b/Development/Development/ContentView.swift index 8736714..938c9c3 100644 --- a/Development/Development/ContentView.swift +++ b/Development/Development/ContentView.swift @@ -32,6 +32,10 @@ struct ContentView: View { NavigationLink("CollectionView") { BookCollectionViewSingleSection() } + + NavigationLink("ScrollView") { + OnAdditionalLoading_Previews() + } } } } diff --git a/Package.resolved b/Package.resolved index 6386276..60e386b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4aef57bde9b5b89e0d20cab1e28a4ed09b3902b921945318f4111732a8a36afe", + "originHash" : "b7bce46448d0e06c904fcba0f0481bdb3de7ca4595549fef018391002bba99b9", "pins" : [ { "identity" : "swift-indexed-collection", @@ -9,6 +9,24 @@ "revision" : "9b17bf06eae73fee93dae9a0fa6de2e33900d9c5", "version" : "0.2.1" } + }, + { + "identity" : "swift-with-prerender", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidGroup/swift-with-prerender", + "state" : { + "revision" : "83ea5d0f5a9fd0082c61e090f4b656c7b58ee0be", + "version" : "1.0.0" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect", + "state" : { + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index f89273e..712b17c 100644 --- a/Package.swift +++ b/Package.swift @@ -15,10 +15,16 @@ let package = Package( .library( name: "CollectionView", targets: ["CollectionView"] - ) + ), + .library( + name: "ScrollTracking", + targets: ["ScrollTracking"] + ), ], dependencies: [ .package(url: "https://github.com/FluidGroup/swift-indexed-collection", from: "0.2.1"), + .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.3.0"), + .package(url: "https://github.com/FluidGroup/swift-with-prerender", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -34,9 +40,17 @@ let package = Package( .product(name: "IndexedCollection", package: "swift-indexed-collection"), ] ), + .target( + name: "ScrollTracking", + dependencies: [ + .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), + .product(name: "WithPrerender", package: "swift-with-prerender"), + ] + ), .testTarget( name: "DynamicListTests", dependencies: ["DynamicList"] ), - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/Sources/ScrollTracking/ScrollTracking.swift b/Sources/ScrollTracking/ScrollTracking.swift new file mode 100644 index 0000000..ef4830c --- /dev/null +++ b/Sources/ScrollTracking/ScrollTracking.swift @@ -0,0 +1,166 @@ +import Combine +import SwiftUI +import SwiftUIIntrospect +import os.lock +import WithPrerender + +extension ScrollView { + + @ViewBuilder + public func onAdditionalLoading( + isEnabled: Bool = true, + leadingScreens: CGFloat = 2, + isLoading: Binding, + _ handler: @MainActor @escaping () async -> Void + ) -> some View { + + modifier( + _Modifier( + isEnabled: isEnabled, + leadingScreens: leadingScreens, + isLoading: isLoading, + handler: handler + ) + ) + + } + +} + +private final class Controller: ObservableObject { + var scrollViewSubscription: AnyCancellable? + let currentLoadingTask: OSAllocatedUnfairLock?> = .init(initialState: nil) +} + +private struct _Modifier: ViewModifier { + + @StateObject var controller: Controller = .init() + + private let isEnabled: Bool + private let leadingScreens: CGFloat + private let isLoading: Binding + private let handler: @MainActor () async -> Void + + nonisolated init( + isEnabled: Bool, + leadingScreens: CGFloat, + isLoading: Binding, + handler: @MainActor @escaping () async -> Void + ) { + self.isEnabled = isEnabled + self.isLoading = isLoading + self.leadingScreens = leadingScreens + self.handler = handler + } + + func body(content: Content) -> some View { + + if #available(iOS 18, *) { + content.onScrollGeometryChange(for: ScrollGeometry.self) { geometry in + + return geometry + + } action: { _, geometry in + + let triggers = calculate( + contentOffsetY: geometry.contentOffset.y, + boundsHeight: geometry.containerSize.height, + contentSizeHeight: geometry.contentSize.height, + leadingScreens: leadingScreens + ) + + if triggers { + MainActor.assumeIsolated { + trigger() + } + } + + } + } else { + + content.introspect(.scrollView, on: .iOS(.v15, .v16, .v17)) { scrollView in + + controller.scrollViewSubscription?.cancel() + + controller.scrollViewSubscription = scrollView.publisher(for: \.contentOffset).sink { + [weak scrollView] offset in + + guard let scrollView else { + return + } + + let triggers = calculate( + contentOffsetY: offset.y, + boundsHeight: scrollView.bounds.height, + contentSizeHeight: scrollView.contentSize.height, + leadingScreens: leadingScreens + ) + + if triggers { + trigger() + } + + } + } + } + } + + private func trigger() { + + guard isEnabled else { + return + } + + let taskBox = controller.currentLoadingTask + + taskBox.withLockUnchecked { currentTask in + + guard currentTask == nil else { + return + } + + withPrerender { + isLoading.wrappedValue = true + } + + let task = Task { @MainActor in + await withTaskCancellationHandler { + await handler() + isLoading.wrappedValue = false + taskBox.withLock { $0 = nil } + } onCancel: { + isLoading.wrappedValue = false + taskBox.withLock { $0 = nil } + } + } + + currentTask = task + + } + + } + +} + +private func calculate( + contentOffsetY: CGFloat, + boundsHeight: CGFloat, + contentSizeHeight: CGFloat, + leadingScreens: CGFloat +) -> Bool { + + guard leadingScreens > 0 || boundsHeight != .zero else { + return false + } + + let viewLength = boundsHeight + let offset = contentOffsetY + let contentLength = contentSizeHeight + + let hasSmallContent = (offset == 0.0) && (contentLength < viewLength) + + let triggerDistance = viewLength * leadingScreens + let remainingDistance = contentLength - viewLength - offset + + return (hasSmallContent || remainingDistance <= triggerDistance) +}