@@ -36,6 +36,75 @@ import ScrollTracking
36
36
37
37
extension CollectionView {
38
38
39
+ /// Attaches an infinite‑scroll style loader to a CollectionView that calls your async closure
40
+ /// as the user approaches the end of the scrollable content.
41
+ ///
42
+ /// Use this modifier to automatically request and append more data to a `CollectionView` when the
43
+ /// user scrolls near the end. The modifier observes the underlying scroll view produced by the
44
+ /// selected layout and triggers `onLoad` once the remaining distance to the end is within
45
+ /// `leadingScreens` times the visible length. If the content is initially smaller than the viewport,
46
+ /// the loader triggers immediately so you can populate the view.
47
+ ///
48
+ /// The `isLoading` binding is managed for you: it is set to `true` just before `onLoad` runs and
49
+ /// reset to `false` when it finishes. While `isLoading` is `true`, additional triggers are suppressed.
50
+ /// Only one load task runs at a time, and subsequent triggers are slightly debounced to avoid rapid
51
+ /// re‑invocation when the user hovers near the threshold.
52
+ ///
53
+ /// - Parameters:
54
+ /// - isEnabled: Toggles the behavior on or off. When `false`, no loading is triggered. Default is `true`.
55
+ /// - leadingScreens: The prefetch threshold expressed in multiples of the visible scrollable length
56
+ /// (height for vertical layouts). For example, `2` triggers when the user is within two screenfuls
57
+ /// of the end. Default is `2`.
58
+ /// - isLoading: A binding that reflects the current loading state. This modifier sets it to `true`
59
+ /// before calling `onLoad` and back to `false` when `onLoad` completes.
60
+ /// - onLoad: An async closure executed on the main actor when the threshold is crossed. Perform your
61
+ /// data fetch and append logic here.
62
+ ///
63
+ /// - Returns: A view that monitors scrolling and triggers `onLoad` according to the provided parameters.
64
+ ///
65
+ /// - Important: Avoid starting additional loads inside `onLoad` while `isLoading` is `true`. The
66
+ /// modifier already prevents re‑entrancy by tracking the current load task and debouncing subsequent
67
+ /// triggers.
68
+ ///
69
+ /// - Note:
70
+ /// - If the content length is smaller than the viewport, `onLoad` is triggered once on appear so
71
+ /// you can fetch enough items to fill the screen.
72
+ /// - Use non‑negative values for `leadingScreens`. Values near `0` trigger close to the end; larger
73
+ /// values prefetch earlier.
74
+ ///
75
+ /// - SeeAlso:
76
+ /// - ``onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)`` (non‑binding overload)
77
+ /// - ``ScrollView/onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)``
78
+ /// - ``List/onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)``
79
+ ///
80
+ /// - Platform:
81
+ /// - On iOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2 or later, the modifier uses SwiftUI
82
+ /// scroll geometry to observe position.
83
+ /// - On earlier supported iOS versions, it relies on scroll view introspection to observe content offset.
84
+ ///
85
+ /// - Example:
86
+ /// ```swift
87
+ /// struct FeedView: View {
88
+ /// @State private var items: [Item] = []
89
+ /// @State private var isLoading = false
90
+ ///
91
+ /// var body: some View {
92
+ /// CollectionView(layout: .list) {
93
+ /// ForEach(items) { item in
94
+ /// Row(item: item)
95
+ /// }
96
+ /// }
97
+ /// .onAdditionalLoading(isEnabled: true,
98
+ /// leadingScreens: 1.5,
99
+ /// isLoading: $isLoading) {
100
+ /// // Fetch more and append
101
+ /// try? await Task.sleep(for: .seconds(1))
102
+ /// let more = await fetchMoreItems()
103
+ /// items.append(contentsOf: more)
104
+ /// }
105
+ /// }
106
+ /// }
107
+ /// ```
39
108
@ViewBuilder
40
109
public func onAdditionalLoading(
41
110
isEnabled: Bool = true ,
@@ -55,6 +124,85 @@ extension CollectionView {
55
124
56
125
}
57
126
127
+ /// Triggers a load-more action as the user approaches the end of the scrollable content,
128
+ /// without managing any loading state internally.
129
+ ///
130
+ /// This modifier observes the scroll position of the collection and invokes `onLoad` when
131
+ /// the visible region nears the end of the content by the amount specified in `leadingScreens`.
132
+ /// It is conditionally available when the ScrollTracking module can be imported.
133
+ ///
134
+ /// Use this overload when you already manage loading state externally (e.g., in a view model)
135
+ /// and simply want a callback to fire when additional content should be fetched. If you want
136
+ /// the modifier to help manage loading state and support async work, consider the binding-based,
137
+ /// async overload instead.
138
+ ///
139
+ /// - Parameters:
140
+ /// - isEnabled: A Boolean that enables or disables additional loading. When `false`, no callbacks
141
+ /// are fired. Defaults to `true`.
142
+ /// - leadingScreens: The prefetch distance, expressed as a multiple of the current viewport height.
143
+ /// For example, `2` means `onLoad` is triggered once the user scrolls within two screen-heights
144
+ /// of the end of the content. Defaults to `2`.
145
+ /// - isLoading: A Boolean that indicates whether a load is currently in progress. When `true`,
146
+ /// additional triggers are suppressed. This value is read-only from the modifier’s perspective;
147
+ /// you are responsible for updating it in your own state to avoid duplicate loads.
148
+ /// - onLoad: A closure executed on the main actor when the threshold is crossed and `isLoading` is `false`.
149
+ /// Use this to kick off your loading logic (e.g., dispatch an async task or call into a view model).
150
+ ///
151
+ /// - Returns: A view that monitors scroll position and invokes `onLoad` as the user approaches the end.
152
+ ///
153
+ /// - Discussion:
154
+ /// - The callback will not be invoked if the content is not scrollable, if `isEnabled` is `false`,
155
+ /// or while `isLoading` is `true`.
156
+ /// - Because this overload does not mutate `isLoading`, your code must set and clear loading state
157
+ /// to prevent repeated triggers.
158
+ /// - Choose `leadingScreens` based on your data-fetch latency and UI needs; values between `0.5` and `3`
159
+ /// are common depending on how early you want to prefetch.
160
+ /// - The `onLoad` closure runs on the main actor; if you need to perform asynchronous work,
161
+ /// start a `Task { ... }` inside the closure or delegate to your view model.
162
+ ///
163
+ /// - SeeAlso: The binding-based async overload:
164
+ /// `onAdditionalLoading(isEnabled:leadingScreens:isLoading:onLoad:)` where `isLoading` is a `Binding<Bool>`
165
+ /// and `onLoad` is `async`, which can simplify state management for loading.
166
+ ///
167
+ /// - Example:
168
+ /// ```swift
169
+ /// struct FeedView: View {
170
+ /// @StateObject private var viewModel = FeedViewModel()
171
+ ///
172
+ /// var body: some View {
173
+ /// CollectionView(layout: viewModel.layout) {
174
+ /// ForEach(viewModel.items) { item in
175
+ /// FeedRow(item: item)
176
+ /// }
177
+ /// }
178
+ /// .onAdditionalLoading(
179
+ /// isEnabled: true,
180
+ /// leadingScreens: 1.5,
181
+ /// isLoading: viewModel.isLoading
182
+ /// ) {
183
+ /// // Executed on the main actor
184
+ /// viewModel.loadMore()
185
+ /// }
186
+ /// }
187
+ /// }
188
+ /// ```
189
+ @ViewBuilder
190
+ public func onAdditionalLoading(
191
+ isEnabled: Bool = true ,
192
+ leadingScreens: Double = 2 ,
193
+ isLoading: Bool ,
194
+ onLoad: @escaping @MainActor ( ) -> Void
195
+ ) -> some View {
196
+ self . onAdditionalLoading (
197
+ additionalLoading: . init(
198
+ isEnabled: isEnabled,
199
+ leadingScreens: leadingScreens,
200
+ isLoading: isLoading,
201
+ onLoad: onLoad
202
+ )
203
+ )
204
+ }
205
+
58
206
}
59
207
60
208
#endif
0 commit comments