├── .gitignore
├── .spi.yml
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── InfiniteScrollViews
│ ├── InfiniteScrollView.swift
│ └── PagedInfiniteScrollView.swift
└── Tests
└── InfiniteScrollViewsTests
└── InfiniteScrollViewsTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [ InfiniteScrollViews ]
5 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Antoine Bollengier
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 |
23 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.4
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: "InfiniteScrollViews",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13)
11 | ],
12 | products: [
13 | // Products define the executables and libraries a package produces, making them visible to other packages.
14 | .library(
15 | name: "InfiniteScrollViews",
16 | targets: ["InfiniteScrollViews"]),
17 | ],
18 | targets: [
19 | // Targets are the basic building blocks of a package, defining a module or a test suite.
20 | // Targets can depend on other targets in this package and products from dependencies.
21 | .target(
22 | name: "InfiniteScrollViews"),
23 | //.testTarget(
24 | // name: "InfiniteScrollViewsTests",
25 | // dependencies: ["InfiniteScrollViews"]),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # InfiniteScrollViews
2 |
3 | InfiniteScrollViews groups some useful SwiftUI, UIKit and AppKit components.
4 |
5 | [](https://swiftpackageindex.com/b5i/InfiniteScrollViews)
6 | [](https://swiftpackageindex.com/b5i/InfiniteScrollViews)
7 |
8 | ## A recursive logic
9 | As we can't really generate an infinity of views and put them in a ScrollView, we need to use **a recursive logic**. The way InfiniteScrollView and PagedInfiniteScrollView can display an "infinite" amount of content works thanks to this logic:
10 | 1. You have a generic type **ChangeIndex**, it is a piece of data that the component will give you in exchange of a View/UIViewController/NSViewController.
11 | 2. When you initialize the component, it takes an argument of type **ChangeIndex** to draw its first view.
12 | 3. When the user will scroll up/down or left/right the component will give you a **ChangeIndex** but to get its "next" or "previous" value, it will use the increase and decrease actions that are provided during initialization. It will be used to draw the "next" or "previous" View/UIViewController/NSViewController with the logic in 1.
13 | And it goes on and on indefinitely... with one exception: if you return nil when step 3. happens, it will just end the scroll and act like there's nothing more to display.
14 | Let's see an example:
15 | You want to draw an "infinite" calendar component like the one in the base Calendar app on iOS. All of this in SwiftUI (but it also work on UIKit and on AppKit!)
16 |
17 | ### Example
18 | 1. First let's see how we initialize the view:
19 | ```swift
20 | InfiniteScrollView(
21 | frame: CGRect,
22 | changeIndex: ChangeIndex,
23 | content: @escaping (ChangeIndex) -> Content,
24 | contentFrame: @escaping (ChangeIndex) -> CGRect,
25 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
26 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
27 | orientation: UIInfiniteScrollView.Orientation, // or NSInfiniteScrollView.Orientation
28 | refreshAction: ((@escaping () -> Void) -> ())? = nil,
29 | spacing: CGFloat = 0,
30 | updateBinding: Binding? = nil
31 | )
32 | ```
33 | - frame: the frame of the InfiniteScrollView.
34 | - changeIndex: the first index that will be used to draw the view.
35 | - content: the query from the InfiniteScrollView to draw a View from a ChangeIndex.
36 | - contentFrame: the query from the InfiniteScrollView to get the frame of the View from the content query (They are separated so you can directly declare the View in the closure).
37 | - increaseIndexAction: the query from the InfiniteScrollView to get the value after a certain ChangeIndex (recursive logic).
38 | - decreaseIndexAction: the query from the InfiniteScrollView to get the value before a certain ChangeIndex (recursive logic).
39 | - orientation: the orientation of the InfiniteScrollView.
40 | - refreshAction: action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything. Gives an action that must be used in order for refresh to end.
41 | - spacing: space between the views.
42 | - updateBinding: boolean that can be changed if the InfiniteScrollView's content needs to be updated.
43 | 2. Let's see how content, increaseIndexAction and decreaseIndexAction work:
44 | 1. For our MonthView we need to provide a Date so that it will extract the month to display.
45 | It could be declared like this:
46 | ```swift
47 | content: { currentDate in
48 | MonthView(date: currentDate)
49 | .padding()
50 | }
51 | ```
52 | 2. Now let's see how increase/decrease work:
53 | To increase we need to get the month after the provided Date:
54 | ```swift
55 | increaseIndexAction: { currentDate in
56 | return Calendar.current.date(byAdding: .init(month: 1), to: currentDate)
57 | }
58 | ```
59 | It will add one month to the currentDate and if the operation was unsuccessful then it returns nil and the InfiniteScrollView stops.
60 | 3. The same logic applies to the decrease action:
61 | ```swift
62 | decreaseIndexAction: { currentDate in
63 | return Calendar.current.date(byAdding: .init(month: -1), to: currentDate)
64 | }
65 | ```
66 | Other examples can be found in [InfiniteScrollViewsExample](https://github.com/b5i/InfiniteScrollViewsExample).
67 |
68 | ## SwiftUI
69 | ### InfiniteScrollView
70 | The infinite equivalent of the ScrollView component in SwiftUI.
71 |
72 | ### PagedInfiniteScrollView
73 | The infinite equivalent of the paged TabView component in SwiftUI.
74 |
75 | ## UIKit
76 | ### UIInfiniteScrollView
77 | The infinite equivalent of the UIScrollView component in UIKit.
78 |
79 | ### UIPagedInfiniteScrollView
80 | A simpler version of UIPageViewController in UIKit.
81 |
82 | ## AppKit
83 | ### NSInfiniteScrollView
84 | The infinite equivalent of the NSScrollView component in AppKit.
85 |
86 | ### NSPagedInfiniteScrollView
87 | The infinite equivalent of the NSPageController component in AppKit.
88 |
--------------------------------------------------------------------------------
/Sources/InfiniteScrollViews/InfiniteScrollView.swift:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2023 Antoine Bollengier
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | //
25 | // InfiniteScrollView.swift
26 | //
27 | // Created by Antoine Bollengier (github.com/b5i) on 14.07.2023.
28 | // Copyright © 2023 Antoine Bollengier. All rights reserved.
29 | //
30 | // Work inspired by https://developer.apple.com/library/archive/samplecode/StreetScroller/Introduction/Intro.html#//apple_ref/doc/uid/DTS40011102-Intro-DontLinkElementID_2
31 |
32 | #if canImport(SwiftUI)
33 | import SwiftUI
34 |
35 | /// SwiftUI InfiniteScrollView component.
36 | ///
37 | /// Generic types:
38 | /// - Content: a View.
39 | /// - ChangeIndex: A type of data that will be given to draw the views and that will be increased and drecreased. It could be for example an Int, a Date or whatever you want.
40 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
41 | public struct InfiniteScrollView {
42 | #if os(macOS)
43 | public typealias NSViewType = NSInfiniteScrollView
44 | public typealias Orientation = NSInfiniteScrollView.Orientation
45 | #else
46 | public typealias UIViewType = UIInfiniteScrollView
47 | public typealias Orientation = UIInfiniteScrollView.Orientation
48 | #endif
49 |
50 | /// Frame of the view.
51 | public let frame: CGRect
52 |
53 | /// Data that will be passed to draw the view and get its frame.
54 | public var changeIndex: ChangeIndex
55 |
56 | /// Function called to get the content to display for a particular ChangeIndex.
57 | public let content: (ChangeIndex) -> Content
58 |
59 | /// The frame of the content to be displayed.
60 | public let contentFrame: (ChangeIndex) -> CGRect
61 |
62 | /// Function that get the ChangeIndex after another.
63 | ///
64 | /// Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
65 | ///
66 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
67 | ///
68 | /// ```swift
69 | /// let myArray: Array = [...]
70 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
71 | /// if changeIndex < myArray.count - 1 {
72 | /// return changeIndex + 1
73 | /// } else {
74 | /// return nil /// No more elements in the array.
75 | /// }
76 | /// }
77 | /// ```
78 | ///
79 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
80 | /// ```swift
81 | /// extension Date {
82 | /// func addingXDays(x: Int) -> Date {
83 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
84 | /// }
85 | /// }
86 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
87 | /// return currentDate.addingXDays(x: 30)
88 | /// }
89 | /// ```
90 | public let increaseIndexAction: (ChangeIndex) -> ChangeIndex?
91 |
92 | /// Function that get the ChangeIndex before another.
93 | ///
94 | /// Should return nil if there is no more content to display (end of the ScrollView at the top/left).
95 | ///
96 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
97 | ///
98 | /// ```swift
99 | /// let myArray: Array = [...]
100 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
101 | /// if changeIndex > 0 {
102 | /// return changeIndex - 1
103 | /// } else {
104 | /// return nil /// We reached the beginning of the array.
105 | /// }
106 | /// }
107 | /// ```
108 | ///
109 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
110 | /// ```swift
111 | /// extension Date {
112 | /// func addingXDays(x: Int) -> Date {
113 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
114 | /// }
115 | /// }
116 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
117 | /// return currentDate.addingXDays(x: -30)
118 | /// }
119 | /// ```
120 | public let decreaseIndexAction: (ChangeIndex) -> ChangeIndex?
121 |
122 | /// Orientation of the ScrollView
123 | public let orientation: Orientation
124 |
125 | /// Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything.
126 | ///
127 | /// Gives an action that must be used in order for refresh to end.
128 | public let refreshAction: ((@escaping () -> Void) -> ())?
129 |
130 | /// Space between the views.
131 | public let spacing: CGFloat
132 |
133 | /// Number that will be used to multiply to the view frame height/width so it can scroll.
134 | ///
135 | /// Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
136 | public let contentMultiplier: CGFloat
137 |
138 | /// Boolean that can be changed if the InfiniteScrollView's content needs to be updated.
139 | public var updateBinding: Binding?
140 |
141 | /// Creates a new instance of InfiniteScrollView
142 | /// - Parameters:
143 | /// - frame: Frame of the view.
144 | /// - changeIndex: Data that will be passed to draw the view and get its frame.
145 | /// - content: Function called to get the content to display for a particular ChangeIndex.
146 | /// - contentFrame: The frame of the content to be displayed.
147 | /// - increaseIndexAction: Function that get the ChangeIndex after another. Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
148 | /// - decreaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the ScrollView at the top/left).
149 | /// - orientation: Orientation of the ScrollView.
150 | /// - refreshAction: Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything. Gives an action that must be used in order for refresh to end.
151 | /// - spacing: Space between the views.
152 | /// - contentMultiplier: Number that will be used to multiply to the view frame height/width so it can scroll. Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
153 | /// - updateBinding: Boolean that can be changed if the InfiniteScrollView's content needs to be updated.
154 | public init(
155 | frame: CGRect,
156 | changeIndex: ChangeIndex,
157 | content: @escaping (ChangeIndex) -> Content,
158 | contentFrame: @escaping (ChangeIndex) -> CGRect,
159 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
160 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
161 | orientation: Orientation,
162 | refreshAction: ((@escaping () -> Void) -> ())? = nil,
163 | spacing: CGFloat = 0,
164 | contentMultiplier: CGFloat = 6,
165 | updateBinding: Binding? = nil
166 | ) {
167 | self.frame = frame
168 | self.changeIndex = changeIndex
169 | self.content = content
170 | self.contentFrame = contentFrame
171 | self.increaseIndexAction = increaseIndexAction
172 | self.decreaseIndexAction = decreaseIndexAction
173 | self.orientation = orientation
174 | self.refreshAction = refreshAction
175 | self.spacing = spacing
176 | self.contentMultiplier = contentMultiplier
177 | self.updateBinding = updateBinding
178 | }
179 |
180 | #if os(macOS)
181 | public func makeNSView(context: Context) -> NSInfiniteScrollView {
182 | let convertedClosure: (ChangeIndex) -> NSView = { changeIndex in
183 | return NSHostingController(rootView: content(changeIndex)).view
184 | }
185 | return NSInfiniteScrollView(
186 | frame: frame,
187 | content: convertedClosure,
188 | contentFrame: contentFrame,
189 | changeIndex: changeIndex,
190 | changeIndexIncreaseAction: increaseIndexAction,
191 | changeIndexDecreaseAction: decreaseIndexAction,
192 | contentMultiplier: contentMultiplier,
193 | orientation: orientation,
194 | refreshAction: refreshAction,
195 | spacing: spacing
196 | )
197 | }
198 |
199 | public func updateNSView(_ nsView: NSInfiniteScrollView, context: Context) {
200 | if updateBinding?.wrappedValue ?? false {
201 | nsView.layout()
202 | if !Thread.isMainThread {
203 | DispatchQueue.main.sync {
204 | updateBinding?.wrappedValue = false
205 | }
206 | } else {
207 | updateBinding?.wrappedValue = false
208 | }
209 | }
210 | }
211 | #else
212 | public func makeUIView(context: Context) -> UIInfiniteScrollView {
213 | let convertedClosure: (ChangeIndex) -> UIView = { changeIndex in
214 | return UIHostingController(rootView: content(changeIndex)).view
215 | }
216 | return UIInfiniteScrollView(
217 | frame: frame,
218 | content: convertedClosure,
219 | contentFrame: contentFrame,
220 | changeIndex: changeIndex,
221 | changeIndexIncreaseAction: increaseIndexAction,
222 | changeIndexDecreaseAction: decreaseIndexAction,
223 | contentMultiplier: contentMultiplier,
224 | orientation: orientation,
225 | refreshAction: refreshAction,
226 | spacing: spacing
227 | )
228 | }
229 |
230 | public func updateUIView(_ uiView: UIInfiniteScrollView, context: Context) {
231 | if updateBinding?.wrappedValue ?? false {
232 | uiView.layoutSubviews()
233 | updateBinding?.wrappedValue = false
234 | }
235 | }
236 | #endif
237 | }
238 | #endif
239 |
240 | #if os(macOS)
241 | extension InfiniteScrollView: NSViewRepresentable {}
242 | #else
243 | extension InfiniteScrollView: UIViewRepresentable {}
244 | #endif
245 |
246 | #if os(macOS)
247 | import AppKit
248 |
249 | /// AppKit component of the InfiniteScrollView.
250 | ///
251 | /// Generic types:
252 | /// - ChangeIndex: A type of data that will be given to draw the views and that will be increased and drecreased. It could be for example an Int, a Date or whatever you want.
253 | public class NSInfiniteScrollView: NSScrollView {
254 |
255 | /// Number that will be used to multiply to the view frame height/width so it can scroll.
256 | ///
257 | /// Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
258 | private var contentMultiplier: CGFloat
259 |
260 | /// Data that will be passed to draw the view and get its frame.
261 | private var changeIndex: ChangeIndex
262 |
263 | /// Function called to get the content to display for a particular ChangeIndex.
264 | private let content: (ChangeIndex) -> NSView
265 |
266 | /// The frame of the content to be displayed.
267 | private let contentFrame: (ChangeIndex) -> CGRect
268 |
269 | /// Function that get the ChangeIndex after another.
270 | ///
271 | /// Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
272 | ///
273 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
274 | ///
275 | /// ```swift
276 | /// let myArray: Array = [...]
277 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
278 | /// if changeIndex < myArray.count - 1 {
279 | /// return changeIndex + 1
280 | /// } else {
281 | /// return nil /// No more elements in the array.
282 | /// }
283 | /// }
284 | /// ```
285 | ///
286 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
287 | /// ```swift
288 | /// extension Date {
289 | /// func addingXDays(x: Int) -> Date {
290 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
291 | /// }
292 | /// }
293 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
294 | /// return currentDate.addingXDays(x: 30)
295 | /// }
296 | /// ```
297 | private let changeIndexIncreaseAction: (ChangeIndex) -> ChangeIndex?
298 |
299 | /// Function that get the ChangeIndex before another.
300 | ///
301 | /// Should return nil if there is no more content to display (end of the ScrollView at the top/left).
302 | ///
303 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
304 | ///
305 | /// ```swift
306 | /// let myArray: Array = [...]
307 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
308 | /// if changeIndex > 0 {
309 | /// return changeIndex - 1
310 | /// } else {
311 | /// return nil /// We reached the beginning of the array.
312 | /// }
313 | /// }
314 | /// ```
315 | ///
316 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
317 | /// ```swift
318 | /// extension Date {
319 | /// func addingXDays(x: Int) -> Date {
320 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
321 | /// }
322 | /// }
323 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
324 | /// return currentDate.addingXDays(x: -30)
325 | /// }
326 | /// ```
327 | private let changeIndexDecreaseAction: (ChangeIndex) -> ChangeIndex?
328 |
329 | /// Orientation of the ScrollView.
330 | private let orientation: Orientation
331 |
332 | /// Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything.
333 | ///
334 | /// Gives an action that must be used in order for refresh to end.
335 | public let refreshAction: ((@escaping () -> Void) -> ())?
336 |
337 | /// Space between the views.
338 | private let spacing: CGFloat
339 |
340 | /// Array containing the displayed views and their associated data.
341 | private var visibleLabels: [(NSView, ChangeIndex)]
342 |
343 | /// A integer indicating whether the NSInfiniteScrollView is doing the layout. Used to prevent infinite recursion when moving the scrollView's offset.
344 | private var sameTimeLayout: Int = 0
345 |
346 | /// Creates an instance of UIInfiniteScrollView.
347 | /// - Parameters:
348 | /// - frame: Frame of the view.
349 | /// - content: Function called to get the content to display for a particular ChangeIndex.
350 | /// - contentFrame: The frame of the content to be displayed.
351 | /// - changeIndex: Data that will be passed to draw the view and get its frame, for the first view that will be displayed at init.
352 | /// - changeIndexIncreaseAction: Function that get the ChangeIndex after another. Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
353 | /// - changeIndexDecreaseAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the ScrollView at the top/left).
354 | /// - contentMultiplier: Number that will be used to multiply to the view frame height/width so it can scroll. Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
355 | /// - orientation: Orientation of the ScrollView.
356 | /// - refreshAction: Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything. Gives an action that must be used in order for refresh to end.
357 | /// - spacing: Space between the views.
358 | public init(
359 | frame: CGRect,
360 | content: @escaping (ChangeIndex) -> NSView,
361 | contentFrame: @escaping (ChangeIndex) -> CGRect,
362 | changeIndex: ChangeIndex,
363 | changeIndexIncreaseAction: @escaping (ChangeIndex) -> ChangeIndex?,
364 | changeIndexDecreaseAction: @escaping (ChangeIndex) -> ChangeIndex?,
365 | contentMultiplier: CGFloat = 6,
366 | orientation: Orientation,
367 | refreshAction: ((@escaping () -> Void) -> ())?,
368 | spacing: CGFloat = 0
369 | ) {
370 | self.visibleLabels = []
371 | self.content = content
372 | self.contentFrame = contentFrame
373 | self.changeIndex = changeIndex
374 | self.changeIndexIncreaseAction = changeIndexIncreaseAction
375 | self.changeIndexDecreaseAction = changeIndexDecreaseAction
376 | self.contentMultiplier = contentMultiplier
377 | self.orientation = orientation
378 | self.refreshAction = refreshAction
379 | self.spacing = spacing
380 | super.init(frame: frame)
381 |
382 | self.documentView = NSView(frame: frame)
383 |
384 | /// Increase the size of the ScrollView orientation for the view to be scrollable.
385 | switch orientation {
386 | case .horizontal:
387 | self.documentSize = CGSizeMake(self.frame.size.width * self.contentMultiplier, self.frame.size.height)
388 | case .vertical:
389 | self.documentSize = CGSizeMake(self.frame.size.width, self.frame.size.height * self.contentMultiplier)
390 | }
391 |
392 | self.translatesAutoresizingMaskIntoConstraints = false
393 | self.hasVerticalScroller = false
394 | self.hasHorizontalScroller = false
395 |
396 | self.contentView.postsBoundsChangedNotifications = true
397 | NotificationCenter.default.addObserver(self, selector: #selector(layout), name: NSView.boundsDidChangeNotification, object: contentView)
398 | }
399 |
400 | @available(*, unavailable)
401 | required init?(coder: NSCoder) {
402 | fatalError("init(coder:) has not been implemented, please open a PR if you would like it to be implemented")
403 | }
404 |
405 | deinit {
406 | NotificationCenter.default.removeObserver(self)
407 | }
408 |
409 | /// Recenter content periodically to achieve impression of infinite scrolling
410 | private func recenterIfNecessary(beforeIndexUndefined: Bool, afterIndexUndefined: Bool) {
411 | switch orientation {
412 | case .horizontal:
413 | let currentOffset: CGPoint = self.documentOffset
414 | let contentWidth: CGFloat = self.documentSize.width
415 | let centerOffsetX: CGFloat = (contentWidth - self.bounds.size.width) / 2
416 | let distanceFromCenter: CGFloat = abs(currentOffset.x - centerOffsetX)
417 |
418 | if beforeIndexUndefined {
419 | self.goLeft()
420 | } else if afterIndexUndefined {
421 | self.goRight()
422 | } else {
423 | if distanceFromCenter > (contentWidth / contentMultiplier) {
424 | self.documentOffset = CGPointMake(centerOffsetX, currentOffset.y)
425 |
426 | /// Move content by the same amount so it appears to stay still.
427 | for (label, _) in self.visibleLabels {
428 | var center: CGPoint = self.convert(label.center, to: self)
429 | center.x += (centerOffsetX - currentOffset.x)
430 | label.center = self.convert(center, to: self)
431 | }
432 | }
433 | }
434 | case .vertical:
435 | let currentOffset: CGPoint = self.documentOffset
436 | let contentHeight: CGFloat = self.documentSize.height
437 | let centerOffsetY: CGFloat = (contentHeight - self.bounds.size.height) / 2
438 | let distanceFromCenter: CGFloat = abs(currentOffset.y - centerOffsetY)
439 |
440 | if beforeIndexUndefined {
441 | self.goUp()
442 | } else if afterIndexUndefined {
443 | self.goDown()
444 | } else {
445 | if distanceFromCenter > (contentHeight / contentMultiplier) {
446 | self.documentOffset = CGPointMake(currentOffset.x, centerOffsetY)
447 |
448 | /// Move content by the same amount so it appears to stay still.
449 | for (label, _) in self.visibleLabels {
450 | var center: CGPoint = self.convert(label.center, to: self)
451 | center.y += (centerOffsetY - currentOffset.y)
452 | label.center = self.convert(center, to: self)
453 | }
454 | }
455 | }
456 | }
457 | }
458 |
459 | /// Recenter all the views to the top.
460 | private func goUp() {
461 | /// Move content by the same amount so it appears to stay still.
462 | var pointsFromTop: CGFloat = 0
463 | var changedMainOffset: Bool = false
464 | for (label, _) in self.visibleLabels {
465 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
466 | if !changedMainOffset {
467 | self.documentOffset.y -= origin.y
468 | changedMainOffset = true
469 | }
470 | origin.y = pointsFromTop
471 | label.frame.origin = self.convert(origin, to: self)
472 | pointsFromTop += label.frame.height + spacing
473 | }
474 | }
475 |
476 | /// Recenter all the views to the left.
477 | private func goLeft() {
478 | /// Move content by the same amount so it appears to stay still.
479 | var pointsFromLeft: CGFloat = 0
480 | var changedMainOffset: Bool = false
481 | for (label, _) in self.visibleLabels {
482 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
483 | origin.x = pointsFromLeft
484 | if !changedMainOffset {
485 | self.documentOffset.x -= origin.x
486 | changedMainOffset = true
487 | }
488 | label.frame.origin = self.convert(origin, to: self)
489 | pointsFromLeft += label.frame.width + spacing
490 | }
491 | }
492 |
493 | /// Recenter all the views to the bottom.
494 | private func goDown() {
495 | /// Move content by the same amount so it appears to stay still.
496 | var pointsToTop: CGFloat = self.documentSize.height
497 | var changedMainOffset: Bool = false
498 | for (label, _) in self.visibleLabels.reversed() {
499 | pointsToTop -= label.frame.height
500 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
501 | if !changedMainOffset {
502 | self.documentOffset.y += pointsToTop - origin.y
503 | changedMainOffset = true
504 | }
505 | origin.y = pointsToTop
506 | label.frame.origin = self.convert(origin, to: self)
507 | pointsToTop -= spacing
508 | }
509 | }
510 |
511 | /// Recenter all the views to the right.
512 | private func goRight() {
513 | /// Move content by the same amount so it appears to stay still.
514 | var pointsToLeft: CGFloat = self.documentSize.width
515 | var changedMainOffset: Bool = false
516 | for (label, _) in self.visibleLabels.reversed() {
517 | pointsToLeft -= label.frame.width
518 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
519 | if !changedMainOffset {
520 | self.documentOffset.x += pointsToLeft - origin.x
521 | changedMainOffset = true
522 | }
523 | origin.x = pointsToLeft
524 | label.frame.origin = self.convert(origin, to: self)
525 | pointsToLeft -= spacing
526 | }
527 | }
528 |
529 | public override func layout() {
530 | super.layout()
531 | self.sameTimeLayout += 1
532 | /// Get the before and after indexes.
533 | let beforeIndex = {
534 | if let firstchangeIndex = self.visibleLabels.first {
535 | return self.changeIndexDecreaseAction(firstchangeIndex.1)
536 | } else {
537 | /// No view in visibleLabels, need to create one with the current changeIndex
538 | return self.changeIndexDecreaseAction(self.changeIndex)
539 | }
540 | }()
541 | let afterIndex = {
542 | if let lastchangeIndex = self.visibleLabels.last {
543 | return self.changeIndexIncreaseAction(lastchangeIndex.1)
544 | } else {
545 | /// No view in visibleLabels, need to create one with the current changeIndex
546 | return self.changeIndexIncreaseAction(self.changeIndex)
547 | }
548 | }()
549 | /// Checks whether the content to display takes less than the height/width of the screen (in that case it will reduce the size of the frame to avoid all the recentering/resizing operations).
550 | let isLittle =
551 | (orientation == .horizontal && (self.visibleLabels.last?.0.frame.origin.x ?? 0) + (self.visibleLabels.last?.0.frame.width ?? 0) - (self.visibleLabels.first?.0.frame.origin.x ?? 0) < self.frame.size.width + 1)
552 | ||
553 | (orientation == .vertical && (self.visibleLabels.last?.0.frame.origin.y ?? 0) + (self.visibleLabels.last?.0.frame.height ?? 0) - (self.visibleLabels.first?.0.frame.origin.y ?? 0) < self.frame.size.height + 1)
554 |
555 | if beforeIndex == nil && afterIndex == nil, isLittle {
556 | switch orientation {
557 | case .horizontal:
558 | self.documentSize = CGSizeMake(self.frame.size.width + 1, self.frame.size.height) // Add one to make it scrollable.
559 | self.goLeft()
560 | case .vertical:
561 | self.documentSize = CGSizeMake(self.frame.size.width, self.frame.size.height + 1) // Add one to make it scrollable.
562 | self.goUp()
563 | }
564 | } else {
565 | /// Increase the size of the ScrollView orientation for the view to be scrollable.
566 | switch orientation {
567 | case .horizontal:
568 | self.documentSize = CGSizeMake(self.frame.size.width * self.contentMultiplier, self.frame.size.height)
569 | case .vertical:
570 | self.documentSize = CGSizeMake(self.frame.size.width, self.frame.size.height * self.contentMultiplier)
571 | }
572 | self.recenterIfNecessary(beforeIndexUndefined: beforeIndex == nil, afterIndexUndefined: afterIndex == nil)
573 | }
574 |
575 | switch orientation {
576 | case .horizontal:
577 | let visibleBounds: CGRect = self.convert(self.contentView.bounds, to: self)
578 | let minimumVisibleX: CGFloat = CGRectGetMinX(visibleBounds)
579 | let maximumVisibleX: CGFloat = CGRectGetMaxX(visibleBounds)
580 |
581 | self.redrawViewsX(minimumVisibleX: minimumVisibleX, toMaxX: maximumVisibleX, beforeIndex: beforeIndex, afterIndex: afterIndex, isLittle: isLittle)
582 | case .vertical:
583 | let visibleBounds: CGRect = self.convert(self.contentView.bounds, to: self)
584 | let minimumVisibleY: CGFloat = CGRectGetMinY(visibleBounds)
585 | let maximumVisibleY: CGFloat = CGRectGetMaxY(visibleBounds)
586 |
587 | self.redrawViewsY(minimumVisibleY: minimumVisibleY, toMaxY: maximumVisibleY, beforeIndex: beforeIndex, afterIndex: afterIndex, isLittle: isLittle)
588 | }
589 |
590 | self.sameTimeLayout -= 1
591 | }
592 |
593 | /// Creates a new view and add it to the ScrollView, returns nil if there was an error during the view's creation.
594 | private func insertView() -> NSView {
595 | let view = content(changeIndex)
596 | view.frame = self.contentFrame(changeIndex)
597 | //view.wantsLayer = false
598 | //view.layer?.backgroundColor = NSColor.clear.cgColor
599 |
600 | self.documentView?.addSubview(view)
601 | return view
602 | }
603 |
604 | /// Creates a new view and add it to the end of the ScrollView, returns nil if there is no more content to be displayed.
605 | private func createAndAppendNewViewToEnd() -> NSView? {
606 | if let lastchangeIndex = visibleLabels.last {
607 | guard let newChangeIndex = self.changeIndexIncreaseAction(lastchangeIndex.1) else { return nil }
608 | changeIndex = newChangeIndex
609 | }
610 |
611 | let newView = self.insertView()
612 | self.visibleLabels.append((newView, changeIndex))
613 | return newView
614 | }
615 |
616 | /// Creates and append a new view to the right, returns nil if there is no more content to be displayed.
617 | private func placeNewViewToRight(rightEdge: CGFloat) -> CGFloat? {
618 | guard let newView = createAndAppendNewViewToEnd() else { return nil }
619 |
620 | var frame: CGRect = newView.frame
621 | frame.origin.x = rightEdge
622 | // frame.origin.y = 0 // avoid having unaligned elements
623 |
624 | newView.frame = frame
625 |
626 | return CGRectGetMaxX(frame)
627 | }
628 |
629 | /// Creates and append a new view to the bottom, returns nil if there is no more content to be displayed.
630 | private func placeNewViewToBottom(bottomEdge: CGFloat) -> CGFloat? {
631 | guard let newView = createAndAppendNewViewToEnd() else { return nil }
632 |
633 | var frame: CGRect = newView.frame
634 | // frame.origin.x = 0 // avoid having unaligned elements
635 | frame.origin.y = bottomEdge
636 |
637 | newView.frame = frame
638 |
639 | return CGRectGetMaxY(frame)
640 | }
641 |
642 | /// Creates a new view and add it to the beginning of the ScrollView, returns nil if there is no more content to be displayed.
643 | private func createAndAppendNewViewToBeginning() -> NSView? {
644 | if let firstchangeIndex = visibleLabels.first {
645 | guard let newChangeIndex = self.changeIndexDecreaseAction(firstchangeIndex.1) else { return nil }
646 | changeIndex = newChangeIndex
647 | }
648 |
649 | let newView = self.insertView()
650 | self.visibleLabels.insert((newView, changeIndex), at: 0)
651 | return newView
652 | }
653 |
654 | /// Creates and append a new view to the left, returns nil if there is no more content to be displayed.
655 | private func placeNewViewToLeft(leftEdge: CGFloat) -> CGFloat? {
656 | guard let newView = createAndAppendNewViewToBeginning() else { return nil }
657 |
658 | var frame: CGRect = newView.frame
659 | frame.origin.x = leftEdge - frame.size.width
660 | // frame.origin.y = 0 // avoid having unaligned elements
661 |
662 | newView.frame = frame
663 |
664 | return CGRectGetMinX(frame)
665 | }
666 |
667 | /// Creates and append a new view to the top, returns nil if there is no more content to be displayed.
668 | private func placeNewViewToTop(topEdge: CGFloat) -> CGFloat? {
669 | guard let newView = createAndAppendNewViewToBeginning() else { return nil }
670 |
671 | var frame: CGRect = newView.frame
672 | // frame.origin.x = 0 // avoid having unaligned elements
673 | frame.origin.y = topEdge - frame.size.height
674 |
675 | newView.frame = frame
676 |
677 | return CGRectGetMinY(frame)
678 | }
679 |
680 | /// Add views to the blank screen and removes the ones who aren't displayed anymore.
681 | private func redrawViewsX(minimumVisibleX: CGFloat, toMaxX maximumVisibleX: CGFloat, beforeIndex: ChangeIndex?, afterIndex: ChangeIndex?, isLittle: Bool) {
682 |
683 | /// Checks whether there is any visible view in the ScrollView, if not then it will try to create one.
684 | if self.visibleLabels.isEmpty {
685 | if self.placeNewViewToLeft(leftEdge: minimumVisibleX) == nil {
686 | self.goLeft()
687 | return
688 | }
689 | /// Start with the first element and not the second (as the method shifts the second element to the first position).
690 | self.visibleLabels.first?.0.frame.origin.x += self.visibleLabels.first?.0.frame.width ?? 0 + spacing
691 | }
692 |
693 | /// If beforeIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
694 | if beforeIndex != nil, let firstLabel = self.visibleLabels.first?.0 {
695 | /// Add labels that are missing on left side.
696 | var leftEdge: CGFloat = CGRectGetMinX(firstLabel.frame)
697 | while (leftEdge > minimumVisibleX) {
698 | if let newLeftEdge = self.placeNewViewToLeft(leftEdge: leftEdge) {
699 | leftEdge = newLeftEdge
700 | } else {
701 | self.goLeft()
702 | return
703 | }
704 | }
705 | }
706 |
707 | /// If afterIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
708 | if afterIndex != nil, let lastLabel = self.visibleLabels.last?.0 {
709 | /// Add labels that are missing on right side
710 | var rightEdge: CGFloat = CGRectGetMaxX(lastLabel.frame)
711 | while (rightEdge < maximumVisibleX) {
712 | if let newRightEdge = self.placeNewViewToRight(rightEdge: rightEdge) {
713 | rightEdge = newRightEdge
714 | } else {
715 | if !isLittle {
716 | self.goRight()
717 | }
718 | return
719 | }
720 | }
721 | }
722 |
723 | /// Remove labels that have fallen off right edge (not visible anymore).
724 | if var lastLabel = self.visibleLabels.last?.0 {
725 | while (CGRectGetMinX(lastLabel.frame) > maximumVisibleX) {
726 | lastLabel.removeFromSuperview()
727 | self.visibleLabels.removeLast()
728 | guard let newFirstLabel = self.visibleLabels.last?.0 else { break }
729 | lastLabel = newFirstLabel
730 | }
731 | }
732 |
733 | /// Remove labels that have fallen off left edge (not visible anymore).
734 | if var firstLabel = self.visibleLabels.first?.0 {
735 | while (CGRectGetMaxX(firstLabel.frame) < minimumVisibleX) {
736 | firstLabel.removeFromSuperview()
737 | self.visibleLabels.removeFirst()
738 | guard let newFirstLabel = self.visibleLabels.first?.0 else { break }
739 | firstLabel = newFirstLabel
740 | }
741 | }
742 | }
743 |
744 | private func redrawViewsY(minimumVisibleY: CGFloat, toMaxY maximumVisibleY: CGFloat, beforeIndex: ChangeIndex?, afterIndex: ChangeIndex?, isLittle: Bool) {
745 |
746 | /// Checks whether there is any visible view in the ScrollView, if not then it will try to create one.
747 | if self.visibleLabels.isEmpty {
748 | if self.placeNewViewToTop(topEdge: minimumVisibleY) == nil {
749 | self.goUp()
750 | return
751 | }
752 | /// Start with the first element and not the second (as the method shifts the second element to the first position).
753 | self.visibleLabels.first?.0.frame.origin.y += self.visibleLabels.first?.0.frame.height ?? 0 + spacing
754 | }
755 |
756 | /// If beforeIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
757 | if beforeIndex != nil, let firstLabel = self.visibleLabels.first?.0 {
758 | /// Add labels that are missing on top side.
759 | var topEdge: CGFloat = CGRectGetMinY(firstLabel.frame)
760 | while (topEdge > minimumVisibleY) {
761 | if let newTopEdge = self.placeNewViewToTop(topEdge: topEdge) {
762 | topEdge = newTopEdge
763 | } else {
764 | self.goUp()
765 | break
766 | }
767 | }
768 | }
769 |
770 | /// If afterIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
771 | if afterIndex != nil, let lastLabel = self.visibleLabels.last?.0 {
772 | /// Add labels that are missing on bottom side.
773 | var bottomEdge: CGFloat = CGRectGetMaxY(lastLabel.frame)
774 | while (bottomEdge < maximumVisibleY) {
775 | if let newBottomEdge = self.placeNewViewToBottom(bottomEdge: bottomEdge) {
776 | bottomEdge = newBottomEdge
777 | } else {
778 | if !isLittle {
779 | self.goDown()
780 | }
781 | break
782 | }
783 | }
784 | }
785 |
786 | /// Remove labels that have fallen off bottom edge.
787 | if var lastLabel = self.visibleLabels.last?.0 {
788 | while (CGRectGetMinY(lastLabel.frame) > maximumVisibleY) {
789 | lastLabel.removeFromSuperview()
790 | self.visibleLabels.removeLast()
791 | guard let newLastLabel = self.visibleLabels.last?.0 else { break }
792 | lastLabel = newLastLabel
793 | }
794 | }
795 |
796 | /// Remove labels that have fallen off top edge.
797 | if var firstLabel = self.visibleLabels.first?.0 {
798 | while (CGRectGetMaxY(firstLabel.frame) < minimumVisibleY) {
799 | firstLabel.removeFromSuperview()
800 | self.visibleLabels.removeFirst()
801 | guard let newFirstLabel = self.visibleLabels.first?.0 else { break }
802 | firstLabel = newFirstLabel
803 | }
804 | }
805 | }
806 |
807 | /// Orientation possibilities of the NSInfiniteScrollView
808 | public enum Orientation {
809 | case horizontal
810 | case vertical
811 | }
812 | }
813 |
814 | // https://stackoverflow.com/a/14572970/16456439
815 | extension NSInfiniteScrollView {
816 | var documentSize: NSSize {
817 | set { documentView?.setFrameSize(newValue) }
818 | get { documentView?.frame.size ?? NSSize.zero }
819 | }
820 | var documentOffset: NSPoint {
821 | set { if sameTimeLayout < 2 { self.documentView?.scroll(newValue) } }
822 | get { documentVisibleRect.origin }
823 | }
824 | }
825 |
826 | extension NSView {
827 | var center: NSPoint {
828 | get { NSPoint(x: self.frame.midX, y: self.frame.midY) }
829 | set { frame.origin = NSPoint(x: newValue.x - (frame.size.width / 2), y: newValue.y - (frame.size.height / 2)) }
830 | }
831 | }
832 |
833 | #else
834 |
835 | /// UIKit component of the InfiniteScrollView.
836 | ///
837 | /// Generic types:
838 | /// - ChangeIndex: A type of data that will be given to draw the views and that will be increased and drecreased. It could be for example an Int, a Date or whatever you want.
839 | public class UIInfiniteScrollView: UIScrollView, UIScrollViewDelegate {
840 |
841 | /// Number that will be used to multiply to the view frame height/width so it can scroll.
842 | ///
843 | /// Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
844 | private var contentMultiplier: CGFloat
845 |
846 | /// Data that will be passed to draw the view and get its frame.
847 | private var changeIndex: ChangeIndex
848 |
849 | /// Function called to get the content to display for a particular ChangeIndex.
850 | private let content: (ChangeIndex) -> UIView
851 |
852 | /// The frame of the content to be displayed.
853 | private let contentFrame: (ChangeIndex) -> CGRect
854 |
855 | /// Function that get the ChangeIndex after another.
856 | ///
857 | /// Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
858 | ///
859 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
860 | ///
861 | /// ```swift
862 | /// let myArray: Array = [...]
863 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
864 | /// if changeIndex < myArray.count - 1 {
865 | /// return changeIndex + 1
866 | /// } else {
867 | /// return nil /// No more elements in the array.
868 | /// }
869 | /// }
870 | /// ```
871 | ///
872 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
873 | /// ```swift
874 | /// extension Date {
875 | /// func addingXDays(x: Int) -> Date {
876 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
877 | /// }
878 | /// }
879 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
880 | /// return currentDate.addingXDays(x: 30)
881 | /// }
882 | /// ```
883 | private let changeIndexIncreaseAction: (ChangeIndex) -> ChangeIndex?
884 |
885 | /// Function that get the ChangeIndex before another.
886 | ///
887 | /// Should return nil if there is no more content to display (end of the ScrollView at the top/left).
888 | ///
889 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
890 | ///
891 | /// ```swift
892 | /// let myArray: Array = [...]
893 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
894 | /// if changeIndex > 0 {
895 | /// return changeIndex - 1
896 | /// } else {
897 | /// return nil /// We reached the beginning of the array.
898 | /// }
899 | /// }
900 | /// ```
901 | ///
902 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
903 | /// ```swift
904 | /// extension Date {
905 | /// func addingXDays(x: Int) -> Date {
906 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
907 | /// }
908 | /// }
909 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
910 | /// return currentDate.addingXDays(x: -30)
911 | /// }
912 | /// ```
913 | private let changeIndexDecreaseAction: (ChangeIndex) -> ChangeIndex?
914 |
915 | /// Orientation of the ScrollView.
916 | private let orientation: Orientation
917 |
918 | /// Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything.
919 | ///
920 | /// Gives an action that must be used in order for refresh to end.
921 | public let refreshAction: ((@escaping () -> Void) -> ())?
922 |
923 | /// Space between the views.
924 | private let spacing: CGFloat
925 |
926 | /// Array containing the displayed views and their associated data.
927 | private var visibleLabels: [(UIView, ChangeIndex)]
928 |
929 | /// Creates an instance of UIInfiniteScrollView.
930 | /// - Parameters:
931 | /// - frame: Frame of the view.
932 | /// - content: Function called to get the content to display for a particular ChangeIndex.
933 | /// - contentFrame: The frame of the content to be displayed.
934 | /// - changeIndex: Data that will be passed to draw the view and get its frame, for the first view that will be displayed at init.
935 | /// - changeIndexIncreaseAction: Function that get the ChangeIndex after another. Should return nil if there is no more content to display (end of the ScrollView at the bottom/right).
936 | /// - changeIndexDecreaseAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the ScrollView at the top/left).
937 | /// - contentMultiplier: Number that will be used to multiply to the view frame height/width so it can scroll. Can be used to reduce high-speed scroll lag, set it higher if you need to increment the maximum scroll speed.
938 | /// - orientation: Orientation of the ScrollView.
939 | /// - refreshAction: Action to do when the user pull the InfiniteScrollView to the top to refresh the content, should be nil if there is no need to refresh anything. Gives an action that must be used in order for refresh to end.
940 | /// - spacing: Space between the views.
941 | public init(
942 | frame: CGRect,
943 | content: @escaping (ChangeIndex) -> UIView,
944 | contentFrame: @escaping (ChangeIndex) -> CGRect,
945 | changeIndex: ChangeIndex,
946 | changeIndexIncreaseAction: @escaping (ChangeIndex) -> ChangeIndex?,
947 | changeIndexDecreaseAction: @escaping (ChangeIndex) -> ChangeIndex?,
948 | contentMultiplier: CGFloat = 6,
949 | orientation: Orientation,
950 | refreshAction: ((@escaping () -> Void) -> ())?,
951 | spacing: CGFloat = 0
952 | ) {
953 | self.visibleLabels = []
954 | self.content = content
955 | self.contentFrame = contentFrame
956 | self.changeIndex = changeIndex
957 | self.changeIndexIncreaseAction = changeIndexIncreaseAction
958 | self.changeIndexDecreaseAction = changeIndexDecreaseAction
959 | self.contentMultiplier = contentMultiplier
960 | self.orientation = orientation
961 | self.refreshAction = refreshAction
962 | self.spacing = spacing
963 | super.init(frame: frame)
964 | /// Increase the size of the ScrollView orientation for the view to be scrollable.
965 | switch orientation {
966 | case .horizontal:
967 | self.contentSize = CGSizeMake(self.frame.size.width * self.contentMultiplier, self.frame.size.height)
968 | case .vertical:
969 | self.contentSize = CGSizeMake(self.frame.size.width, self.frame.size.height * self.contentMultiplier)
970 | }
971 |
972 | self.translatesAutoresizingMaskIntoConstraints = false
973 | self.showsHorizontalScrollIndicator = false
974 | self.showsVerticalScrollIndicator = false
975 | if self.refreshAction != nil {
976 | self.refreshControl = UIRefreshControl()
977 | self.refreshControl?.addTarget(self, action: #selector(refreshActionMethod), for: .valueChanged)
978 | }
979 | }
980 |
981 | @available(*, unavailable)
982 | required init?(coder: NSCoder) {
983 | fatalError("init(coder:) has not been implemented, please open a PR if you would like it to be implemented")
984 | }
985 |
986 | /// Execute the scroll action if it is defined.
987 | @objc private func refreshActionMethod() {
988 | if let refreshAction = self.refreshAction, let endAction = self.refreshControl?.endRefreshing {
989 | refreshAction(endAction)
990 | } else {
991 | DispatchQueue.main.async {
992 | self.refreshControl?.endRefreshing()
993 | }
994 | }
995 | }
996 |
997 | /// Recenter content periodically to achieve impression of infinite scrolling
998 | private func recenterIfNecessary(beforeIndexUndefined: Bool, afterIndexUndefined: Bool) {
999 | switch orientation {
1000 | case .horizontal:
1001 | let currentOffset: CGPoint = self.contentOffset
1002 | let contentWidth: CGFloat = self.contentSize.width
1003 | let centerOffsetX: CGFloat = (contentWidth - self.bounds.size.width) / 2
1004 | let distanceFromCenter: CGFloat = abs(currentOffset.x - centerOffsetX)
1005 |
1006 | if beforeIndexUndefined {
1007 | self.goLeft()
1008 | } else if afterIndexUndefined {
1009 | self.goRight()
1010 | } else {
1011 | if distanceFromCenter > (contentWidth / contentMultiplier) {
1012 | self.contentOffset = CGPointMake(centerOffsetX, currentOffset.y)
1013 |
1014 | /// Move content by the same amount so it appears to stay still.
1015 | for (label, _) in self.visibleLabels {
1016 | var center: CGPoint = self.convert(label.center, to: self)
1017 | center.x += (centerOffsetX - currentOffset.x)
1018 | label.center = self.convert(center, to: self)
1019 | }
1020 | }
1021 | }
1022 | case .vertical:
1023 | let currentOffset: CGPoint = self.contentOffset
1024 | let contentHeight: CGFloat = self.contentSize.height
1025 | let centerOffsetY: CGFloat = (contentHeight - self.bounds.size.height) / 2
1026 | let distanceFromCenter: CGFloat = abs(currentOffset.y - centerOffsetY)
1027 |
1028 | if beforeIndexUndefined {
1029 | self.goUp()
1030 | } else if afterIndexUndefined {
1031 | self.goDown()
1032 | } else {
1033 | if distanceFromCenter > (contentHeight / contentMultiplier) {
1034 | self.contentOffset = CGPointMake(currentOffset.x, centerOffsetY)
1035 |
1036 | /// Move content by the same amount so it appears to stay still.
1037 | for (label, _) in self.visibleLabels {
1038 | var center: CGPoint = self.convert(label.center, to: self)
1039 | center.y += (centerOffsetY - currentOffset.y)
1040 | label.center = self.convert(center, to: self)
1041 | }
1042 | }
1043 | }
1044 | }
1045 | }
1046 |
1047 | /// Recenter all the views to the top.
1048 | private func goUp() {
1049 | /// Move content by the same amount so it appears to stay still.
1050 | var pointsFromTop: CGFloat = 0
1051 | var changedMainOffset: Bool = false
1052 | for (label, _) in self.visibleLabels {
1053 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
1054 | if !changedMainOffset {
1055 | self.contentOffset.y -= origin.y
1056 | changedMainOffset = true
1057 | }
1058 | origin.y = pointsFromTop
1059 | label.frame.origin = self.convert(origin, to: self)
1060 | pointsFromTop += label.frame.height + spacing
1061 | }
1062 | }
1063 |
1064 | /// Recenter all the views to the left.
1065 | private func goLeft() {
1066 | /// Move content by the same amount so it appears to stay still.
1067 | var pointsFromLeft: CGFloat = 0
1068 | var changedMainOffset: Bool = false
1069 | for (label, _) in self.visibleLabels {
1070 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
1071 | origin.x = pointsFromLeft
1072 | if !changedMainOffset {
1073 | self.contentOffset.x -= origin.x
1074 | changedMainOffset = true
1075 | }
1076 | label.frame.origin = self.convert(origin, to: self)
1077 | pointsFromLeft += label.frame.width + spacing
1078 | }
1079 | }
1080 |
1081 | /// Recenter all the views to the bottom.
1082 | private func goDown() {
1083 | /// Move content by the same amount so it appears to stay still.
1084 | var pointsToTop: CGFloat = self.contentSize.height
1085 | var changedMainOffset: Bool = false
1086 | for (label, _) in self.visibleLabels.reversed() {
1087 | pointsToTop -= label.frame.height
1088 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
1089 | if !changedMainOffset {
1090 | self.contentOffset.y += pointsToTop - origin.y
1091 | changedMainOffset = true
1092 | }
1093 | origin.y = pointsToTop
1094 | label.frame.origin = self.convert(origin, to: self)
1095 | pointsToTop -= spacing
1096 | }
1097 | }
1098 |
1099 | /// Recenter all the views to the right.
1100 | private func goRight() {
1101 | /// Move content by the same amount so it appears to stay still.
1102 | var pointsToLeft: CGFloat = self.contentSize.width
1103 | var changedMainOffset: Bool = false
1104 | for (label, _) in self.visibleLabels.reversed() {
1105 | pointsToLeft -= label.frame.width
1106 | var origin: CGPoint = self.convert(label.frame.origin, to: self)
1107 | if !changedMainOffset {
1108 | self.contentOffset.x += pointsToLeft - origin.x
1109 | changedMainOffset = true
1110 | }
1111 | origin.x = pointsToLeft
1112 | label.frame.origin = self.convert(origin, to: self)
1113 | pointsToLeft -= spacing
1114 | }
1115 | }
1116 |
1117 |
1118 | public override func layoutSubviews() {
1119 | super.layoutSubviews()
1120 | /// Get the before and after indexes.
1121 | let beforeIndex = {
1122 | if let firstchangeIndex = self.visibleLabels.first {
1123 | return self.changeIndexDecreaseAction(firstchangeIndex.1)
1124 | } else {
1125 | /// No view in visibleLabels, need to create one with the current changeIndex
1126 | return self.changeIndexDecreaseAction(self.changeIndex)
1127 | }
1128 | }()
1129 | let afterIndex = {
1130 | if let lastchangeIndex = self.visibleLabels.last {
1131 | return self.changeIndexIncreaseAction(lastchangeIndex.1)
1132 | } else {
1133 | /// No view in visibleLabels, need to create one with the current changeIndex
1134 | return self.changeIndexIncreaseAction(self.changeIndex)
1135 | }
1136 | }()
1137 | /// Checks whether the content to display takes less than the height of the screen (in that case it will reduce the size of the frame to avoid all the recentering/resizing operations).
1138 | let isLittle =
1139 | (orientation == .horizontal && (self.visibleLabels.last?.0.frame.origin.x ?? 0) + (self.visibleLabels.last?.0.frame.width ?? 0) - (self.visibleLabels.first?.0.frame.origin.x ?? 0) < self.frame.size.width + 1)
1140 | ||
1141 | (orientation == .vertical && (self.visibleLabels.last?.0.frame.origin.y ?? 0) + (self.visibleLabels.last?.0.frame.height ?? 0) - (self.visibleLabels.first?.0.frame.origin.y ?? 0) < self.frame.size.height + 1)
1142 |
1143 | if beforeIndex == nil && afterIndex == nil, isLittle {
1144 | switch orientation {
1145 | case .horizontal:
1146 | self.contentSize = CGSizeMake(self.frame.size.width + 1, self.frame.size.height) // Add one to make it scrollable.
1147 | self.goLeft()
1148 | case .vertical:
1149 | self.contentSize = CGSizeMake(self.frame.size.width, self.frame.size.height + 1) // Add one to make it scrollable.
1150 | self.goUp()
1151 | }
1152 | } else {
1153 | /// Increase the size of the ScrollView orientation for the view to be scrollable.
1154 | switch orientation {
1155 | case .horizontal:
1156 | self.contentSize = CGSizeMake(self.frame.size.width * self.contentMultiplier, self.frame.size.height)
1157 | case .vertical:
1158 | self.contentSize = CGSizeMake(self.frame.size.width, self.frame.size.height * self.contentMultiplier)
1159 | }
1160 | self.recenterIfNecessary(beforeIndexUndefined: beforeIndex == nil, afterIndexUndefined: afterIndex == nil)
1161 | }
1162 |
1163 | switch orientation {
1164 | case .horizontal:
1165 | let visibleBounds: CGRect = self.convert(self.bounds, to: self)
1166 | let minimumVisibleX: CGFloat = CGRectGetMinX(visibleBounds)
1167 | let maximumVisibleX: CGFloat = CGRectGetMaxX(visibleBounds)
1168 |
1169 | self.redrawViewsX(minimumVisibleX: minimumVisibleX, toMaxX: maximumVisibleX, beforeIndex: beforeIndex, afterIndex: afterIndex, isLittle: isLittle)
1170 | case .vertical:
1171 | let visibleBounds: CGRect = self.convert(self.bounds, to: self)
1172 | let minimumVisibleY: CGFloat = CGRectGetMinY(visibleBounds)
1173 | let maximumVisibleY: CGFloat = CGRectGetMaxY(visibleBounds)
1174 |
1175 | self.redrawViewsY(minimumVisibleY: minimumVisibleY, toMaxY: maximumVisibleY, beforeIndex: beforeIndex, afterIndex: afterIndex, isLittle: isLittle)
1176 | }
1177 | }
1178 |
1179 | /// Creates a new view and add it to the ScrollView, returns nil if there was an error during the view's creation.
1180 | private func insertView() -> UIView {
1181 | let view = content(changeIndex)
1182 | view.frame = self.contentFrame(changeIndex)
1183 | view.backgroundColor = .clear
1184 | self.addSubview(view)
1185 | return view
1186 | }
1187 |
1188 | /// Creates a new view and add it to the end of the ScrollView, returns nil if there is no more content to be displayed.
1189 | private func createAndAppendNewViewToEnd() -> UIView? {
1190 | if let lastchangeIndex = visibleLabels.last {
1191 | guard let newChangeIndex = self.changeIndexIncreaseAction(lastchangeIndex.1) else { return nil }
1192 | changeIndex = newChangeIndex
1193 | }
1194 |
1195 | let newView = self.insertView()
1196 | self.visibleLabels.append((newView, changeIndex))
1197 | return newView
1198 | }
1199 |
1200 | /// Creates and append a new view to the right, returns nil if there is no more content to be displayed.
1201 | private func placeNewViewToRight(rightEdge: CGFloat) -> CGFloat? {
1202 | guard let newView = createAndAppendNewViewToEnd() else { return nil }
1203 |
1204 | var frame: CGRect = newView.frame
1205 | frame.origin.x = rightEdge
1206 | // frame.origin.y = 0 // avoid having unaligned elements
1207 |
1208 | newView.frame = frame
1209 |
1210 | return CGRectGetMaxX(frame)
1211 | }
1212 |
1213 | /// Creates and append a new view to the bottom, returns nil if there is no more content to be displayed.
1214 | private func placeNewViewToBottom(bottomEdge: CGFloat) -> CGFloat? {
1215 | guard let newView = createAndAppendNewViewToEnd() else { return nil }
1216 |
1217 | var frame: CGRect = newView.frame
1218 | // frame.origin.x = 0 // avoid having unaligned elements
1219 | frame.origin.y = bottomEdge
1220 |
1221 | newView.frame = frame
1222 |
1223 | return CGRectGetMaxY(frame)
1224 | }
1225 |
1226 | /// Creates a new view and add it to the beginning of the ScrollView, returns nil if there is no more content to be displayed.
1227 | private func createAndAppendNewViewToBeginning() -> UIView? {
1228 | if let firstchangeIndex = visibleLabels.first {
1229 | guard let newChangeIndex = self.changeIndexDecreaseAction(firstchangeIndex.1) else { return nil }
1230 | changeIndex = newChangeIndex
1231 | }
1232 |
1233 | let newView = self.insertView()
1234 | self.visibleLabels.insert((newView, changeIndex), at: 0)
1235 | return newView
1236 | }
1237 |
1238 | /// Creates and append a new view to the left, returns nil if there is no more content to be displayed.
1239 | private func placeNewViewToLeft(leftEdge: CGFloat) -> CGFloat? {
1240 | guard let newView = createAndAppendNewViewToBeginning() else { return nil }
1241 |
1242 | var frame: CGRect = newView.frame
1243 | frame.origin.x = leftEdge - frame.size.width
1244 | // frame.origin.y = 0 // avoid having unaligned elements
1245 |
1246 | newView.frame = frame
1247 |
1248 | return CGRectGetMinX(frame)
1249 | }
1250 |
1251 | /// Creates and append a new view to the top, returns nil if there is no more content to be displayed.
1252 | private func placeNewViewToTop(topEdge: CGFloat) -> CGFloat? {
1253 | guard let newView = createAndAppendNewViewToBeginning() else { return nil }
1254 |
1255 | var frame: CGRect = newView.frame
1256 | // frame.origin.x = 0 // avoid having unaligned elements
1257 | frame.origin.y = topEdge - frame.size.height
1258 |
1259 | newView.frame = frame
1260 |
1261 | return CGRectGetMinY(frame)
1262 | }
1263 |
1264 | /// Add views to the blank screen and removes the ones who aren't displayed anymore.
1265 | private func redrawViewsX(minimumVisibleX: CGFloat, toMaxX maximumVisibleX: CGFloat, beforeIndex: ChangeIndex?, afterIndex: ChangeIndex?, isLittle: Bool) {
1266 |
1267 | /// Checks whether there is any visible view in the ScrollView, if not then it will try to create one.
1268 | if self.visibleLabels.isEmpty {
1269 | if self.placeNewViewToLeft(leftEdge: minimumVisibleX) == nil {
1270 | self.goLeft()
1271 | return
1272 | }
1273 | /// Start with the first element and not the second (as the method shifts the second element to the first position).
1274 | self.visibleLabels.first?.0.frame.origin.x += self.visibleLabels.first?.0.frame.width ?? 0 + spacing
1275 | }
1276 |
1277 | /// If beforeIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
1278 | if beforeIndex != nil, let firstLabel = self.visibleLabels.first?.0 {
1279 | /// Add labels that are missing on left side.
1280 | var leftEdge: CGFloat = CGRectGetMinX(firstLabel.frame)
1281 | while (leftEdge > minimumVisibleX) {
1282 | if let newLeftEdge = self.placeNewViewToLeft(leftEdge: leftEdge) {
1283 | leftEdge = newLeftEdge
1284 | } else {
1285 | self.goLeft()
1286 | return
1287 | }
1288 | }
1289 | }
1290 |
1291 | /// If afterIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
1292 | if afterIndex != nil, let lastLabel = self.visibleLabels.last?.0 {
1293 | /// Add labels that are missing on right side
1294 | var rightEdge: CGFloat = CGRectGetMaxX(lastLabel.frame)
1295 | while (rightEdge < maximumVisibleX) {
1296 | if let newRightEdge = self.placeNewViewToRight(rightEdge: rightEdge) {
1297 | rightEdge = newRightEdge
1298 | } else {
1299 | if !isLittle {
1300 | self.goRight()
1301 | }
1302 | return
1303 | }
1304 | }
1305 | }
1306 |
1307 | /// Remove labels that have fallen off right edge (not visible anymore).
1308 | if var lastLabel = self.visibleLabels.last?.0 {
1309 | while (CGRectGetMinX(lastLabel.frame) > maximumVisibleX) {
1310 | lastLabel.removeFromSuperview()
1311 | self.visibleLabels.removeLast()
1312 | guard let newFirstLabel = self.visibleLabels.last?.0 else { break }
1313 | lastLabel = newFirstLabel
1314 | }
1315 | }
1316 |
1317 | /// Remove labels that have fallen off left edge (not visible anymore).
1318 | if var firstLabel = self.visibleLabels.first?.0 {
1319 | while (CGRectGetMaxX(firstLabel.frame) < minimumVisibleX) {
1320 | firstLabel.removeFromSuperview()
1321 | self.visibleLabels.removeFirst()
1322 | guard let newFirstLabel = self.visibleLabels.first?.0 else { break }
1323 | firstLabel = newFirstLabel
1324 | }
1325 | }
1326 | }
1327 |
1328 | private func redrawViewsY(minimumVisibleY: CGFloat, toMaxY maximumVisibleY: CGFloat, beforeIndex: ChangeIndex?, afterIndex: ChangeIndex?, isLittle: Bool) {
1329 |
1330 | /// Checks whether there is any visible view in the ScrollView, if not then it will try to create one.
1331 | if self.visibleLabels.isEmpty {
1332 | if self.placeNewViewToTop(topEdge: minimumVisibleY) == nil {
1333 | self.goUp()
1334 | return
1335 | }
1336 | /// Start with the first element and not the second (as the method shifts the second element to the first position).
1337 | self.visibleLabels.first?.0.frame.origin.y += self.visibleLabels.first?.0.frame.height ?? 0 + spacing
1338 | }
1339 |
1340 | /// If beforeIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
1341 | if beforeIndex != nil, let firstLabel = self.visibleLabels.first?.0 {
1342 | /// Add labels that are missing on top side.
1343 | var topEdge: CGFloat = CGRectGetMinY(firstLabel.frame)
1344 | while (topEdge > minimumVisibleY) {
1345 | if let newTopEdge = self.placeNewViewToTop(topEdge: topEdge) {
1346 | topEdge = newTopEdge
1347 | } else {
1348 | self.goUp()
1349 | break
1350 | }
1351 | }
1352 | }
1353 |
1354 | /// If afterIndex is nil it means that there is no more content to be displayed, otherwise it will draw and append it.
1355 | if afterIndex != nil, let lastLabel = self.visibleLabels.last?.0 {
1356 | /// Add labels that are missing on bottom side.
1357 | var bottomEdge: CGFloat = CGRectGetMaxY(lastLabel.frame)
1358 | while (bottomEdge < maximumVisibleY) {
1359 | if let newBottomEdge = self.placeNewViewToBottom(bottomEdge: bottomEdge) {
1360 | bottomEdge = newBottomEdge
1361 | } else {
1362 | if !isLittle {
1363 | self.goDown()
1364 | }
1365 | break
1366 | }
1367 | }
1368 | }
1369 |
1370 | /// Remove labels that have fallen off bottom edge.
1371 | if var lastLabel = self.visibleLabels.last?.0 {
1372 | while (CGRectGetMinY(lastLabel.frame) > maximumVisibleY) {
1373 | lastLabel.removeFromSuperview()
1374 | self.visibleLabels.removeLast()
1375 | guard let newLastLabel = self.visibleLabels.last?.0 else { break }
1376 | lastLabel = newLastLabel
1377 | }
1378 | }
1379 |
1380 | /// Remove labels that have fallen off top edge.
1381 | if var firstLabel = self.visibleLabels.first?.0 {
1382 | while (CGRectGetMaxY(firstLabel.frame) < minimumVisibleY) {
1383 | firstLabel.removeFromSuperview()
1384 | self.visibleLabels.removeFirst()
1385 | guard let newFirstLabel = self.visibleLabels.first?.0 else { break }
1386 | firstLabel = newFirstLabel
1387 | }
1388 | }
1389 | }
1390 |
1391 | /// Orientation possibilities of the UIInfiniteScrollView
1392 | public enum Orientation {
1393 | case horizontal
1394 | case vertical
1395 | }
1396 | }
1397 |
1398 | #endif
1399 |
--------------------------------------------------------------------------------
/Sources/InfiniteScrollViews/PagedInfiniteScrollView.swift:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2023 Antoine Bollengier
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 | */
24 | //
25 | // PagedInfiniteScrollView.swift
26 | //
27 | // Created by Antoine Bollengier (github.com/b5i) on 15.07.2023.
28 | // Copyright © 2023 Antoine Bollengier. All rights reserved.
29 | //
30 | // Inspired from https://gist.github.com/beader/08757070b8c8b1134ea8e53f347553d8
31 |
32 | #if os(macOS)
33 | import AppKit
34 | #else
35 | import UIKit
36 | #endif
37 |
38 | #if canImport(SwiftUI)
39 | import SwiftUI
40 |
41 | /// SwiftUI PagedInfiniteScrollView component.
42 | ///
43 | /// Generic types:
44 | /// - Content: a View.
45 | /// - ChangeIndex: A type of data that will be given to draw the views and that will be increased and drecreased. It could be for example an Int, a Date or whatever you want.
46 | @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
47 | public struct PagedInfiniteScrollView {
48 |
49 | /// Data that will be passed to draw the view and get its frame.
50 | public var changeIndex: Binding
51 |
52 | /// Function called to get the content to display for a particular ChangeIndex.
53 | public let content: (ChangeIndex) -> Content
54 |
55 | /// Function that get the ChangeIndex after another.
56 | ///
57 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right).
58 | ///
59 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
60 | ///
61 | /// ```swift
62 | /// let myArray: Array = [...]
63 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
64 | /// if changeIndex < myArray.count - 1 {
65 | /// return changeIndex + 1
66 | /// } else {
67 | /// return nil /// No more elements in the array.
68 | /// }
69 | /// }
70 | /// ```
71 | ///
72 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
73 | /// ```swift
74 | /// extension Date {
75 | /// func addingXDays(x: Int) -> Date {
76 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
77 | /// }
78 | /// }
79 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
80 | /// return currentDate.addingXDays(x: 30)
81 | /// }
82 | /// ```
83 | public let increaseIndexAction: (ChangeIndex) -> ChangeIndex?
84 |
85 | /// Function that get the ChangeIndex before another.
86 | ///
87 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the top/left).
88 | ///
89 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
90 | ///
91 | /// ```swift
92 | /// let myArray: Array = [...]
93 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
94 | /// if changeIndex > 0 {
95 | /// return changeIndex - 1
96 | /// } else {
97 | /// return nil /// We reached the beginning of the array.
98 | /// }
99 | /// }
100 | /// ```
101 | ///
102 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
103 | /// ```swift
104 | /// extension Date {
105 | /// func addingXDays(x: Int) -> Date {
106 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
107 | /// }
108 | /// }
109 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
110 | /// return currentDate.addingXDays(x: -30)
111 | /// }
112 | /// ```
113 | public let decreaseIndexAction: (ChangeIndex) -> ChangeIndex?
114 |
115 |
116 | #if os(macOS)
117 | /// Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation. If the boolean is false (no need to animate), the direction of the animation won't be used. In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
118 | public let shouldAnimateBetween: (_ oldIndex: ChangeIndex, _ newIndex: ChangeIndex) -> (Bool, NSPagedInfiniteScrollView.SlideSide)
119 | /// Function that should tell whether two ChangeIndex should be considered as equal (and so shouldn't change the controller if a transition is made between them).
120 | public let indexesEqual: (ChangeIndex, ChangeIndex) -> Bool
121 |
122 | /// The style for transitions between pages.
123 | public let transitionStyle: NSPageController.TransitionStyle
124 |
125 | /// Creates a new instance of PagedInfiniteScrollView.
126 | /// - Parameters:
127 | /// - changeIndex: Data that will be passed to draw the view and get its frame.
128 | /// - content: Function called to get the content to display for a particular ChangeIndex.
129 | /// - increaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the top/left). See definition in class to learn more.
130 | /// - decreaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right). See definition in class to learn more.
131 | /// - shouldAnimateBetween: Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation. If the boolean is false (no need to animate), the direction of the animation won't be used. In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
132 | /// - indexesEqual: Function that should tell whether two ChangeIndex should be considered as equal (and so shouldn't change the controller if a transition is made between them).
133 | /// - transitionStyle: The style for transitions between pages.
134 | public init(
135 | changeIndex: Binding,
136 | content: @escaping (ChangeIndex) -> Content,
137 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
138 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
139 | shouldAnimateBetween: @escaping (_ oldIndex: ChangeIndex, _ newIndex: ChangeIndex) -> (Bool, NSPagedInfiniteScrollView.SlideSide),
140 | indexesEqual: @escaping (ChangeIndex, ChangeIndex) -> Bool,
141 | transitionStyle: NSPageController.TransitionStyle = .horizontalStrip
142 | ) {
143 | self.changeIndex = changeIndex
144 | self.content = content
145 | self.increaseIndexAction = increaseIndexAction
146 | self.decreaseIndexAction = decreaseIndexAction
147 | self.shouldAnimateBetween = shouldAnimateBetween
148 | self.indexesEqual = indexesEqual
149 | self.transitionStyle = transitionStyle
150 | }
151 |
152 | public func makeNSViewController(context: Context) -> NSPagedInfiniteScrollView {
153 | /// Creates the main view and set it in the ``NSPageViewController``.
154 | let convertedClosure: (ChangeIndex) -> NSViewController = { changeIndex in
155 | return NSHostingController(rootView: content(changeIndex))
156 | }
157 | let changeIndexNotification: (ChangeIndex) -> () = { changeIndex in
158 | DispatchQueue.main.async {
159 | self.changeIndex.wrappedValue = changeIndex
160 | }
161 | }
162 |
163 | return NSPagedInfiniteScrollView(content: convertedClosure, changeIndex: changeIndex.wrappedValue, changeIndexNotification: changeIndexNotification, increaseIndexAction: increaseIndexAction, decreaseIndexAction: decreaseIndexAction, shouldAnimateBetween: shouldAnimateBetween, indexesEqual: indexesEqual, transitionStyle: transitionStyle)
164 | }
165 |
166 | public func updateNSViewController(_ nsViewController: NSPagedInfiniteScrollView, context: Context) {
167 | nsViewController.changeCurrentIndex(to: changeIndex.wrappedValue)
168 | }
169 | #else
170 |
171 | /// Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation.
172 | ///
173 | /// If the boolean is false (no need to animate), the direction of the animation won't be used.
174 | ///
175 | /// In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
176 | public let shouldAnimateBetween: (ChangeIndex, ChangeIndex) -> (Bool, UIPageViewController.NavigationDirection)
177 |
178 | /// The style for transitions between pages.
179 | public let transitionStyle: UIPageViewController.TransitionStyle
180 |
181 | /// The orientation of the page-by-page navigation.
182 | public let navigationOrientation: UIPageViewController.NavigationOrientation
183 |
184 | /// Creates a new instance of PagedInfiniteScrollView.
185 | /// - Parameters:
186 | /// - changeIndex: Data that will be passed to draw the view and get its frame.
187 | /// - content: Function called to get the content to display for a particular ChangeIndex.
188 | /// - increaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the top/left). See definition in class to learn more.
189 | /// - decreaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right). See definition in class to learn more.
190 | /// - shouldAnimateBetween: Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation. If the boolean is false (no need to animate), the direction of the animation won't be used. In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
191 | /// - transitionStyle: The style for transitions between pages.
192 | /// - navigationOrientation: The orientation of the page-by-page navigation.
193 | public init(
194 | changeIndex: Binding,
195 | content: @escaping (ChangeIndex) -> Content,
196 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
197 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
198 | shouldAnimateBetween: @escaping (ChangeIndex, ChangeIndex) -> (Bool, UIPageViewController.NavigationDirection),
199 | transitionStyle: UIPageViewController.TransitionStyle,
200 | navigationOrientation: UIPageViewController.NavigationOrientation
201 | ) {
202 | self.changeIndex = changeIndex
203 | self.content = content
204 | self.increaseIndexAction = increaseIndexAction
205 | self.decreaseIndexAction = decreaseIndexAction
206 | self.shouldAnimateBetween = shouldAnimateBetween
207 | self.transitionStyle = transitionStyle
208 | self.navigationOrientation = navigationOrientation
209 | }
210 |
211 | public func makeUIViewController(context: Context) -> UIPageViewController {
212 | /// Creates the main view and set it in the ``UIPageViewController``.
213 | let convertedClosure: (ChangeIndex) -> UIViewController = { changeIndex in
214 | return UIHostingController(rootView: content(changeIndex))
215 | }
216 | let changeIndexNotification: (ChangeIndex) -> () = { changeIndex in
217 | self.changeIndex.wrappedValue = changeIndex
218 | }
219 | let pageViewController = UIPagedInfiniteScrollView(content: convertedClosure, changeIndex: changeIndex.wrappedValue, changeIndexNotification: changeIndexNotification, increaseIndexAction: increaseIndexAction, decreaseIndexAction: decreaseIndexAction, transitionStyle: transitionStyle, navigationOrientation: navigationOrientation)
220 |
221 | let initialViewController = UIHostingController(rootView: content(changeIndex.wrappedValue))
222 | initialViewController.storedChangeIndex = changeIndex.wrappedValue
223 | pageViewController.setViewControllers([initialViewController], direction: .forward, animated: false, completion: nil)
224 |
225 | return pageViewController
226 | }
227 |
228 | public func updateUIViewController(_ uiViewController: UIPageViewController, context: Context) {
229 | /// Check if the view should update and if it should then it will be.
230 | guard let currentView = uiViewController.viewControllers?.first, let currentIndex = currentView.storedChangeIndex as? ChangeIndex else {
231 | return
232 | }
233 |
234 | let shouldAnimate: (Bool, UIPageViewController.NavigationDirection) = shouldAnimateBetween(changeIndex.wrappedValue, currentIndex)
235 |
236 | let initialViewController = UIHostingController(rootView: content(changeIndex.wrappedValue))
237 | initialViewController.storedChangeIndex = changeIndex.wrappedValue
238 | uiViewController.setViewControllers([initialViewController], direction: shouldAnimate.1, animated: shouldAnimate.0, completion: nil)
239 | }
240 |
241 | #endif
242 | }
243 |
244 | #if os(macOS)
245 | extension PagedInfiniteScrollView: NSViewControllerRepresentable {}
246 | #else
247 | extension PagedInfiniteScrollView: UIViewControllerRepresentable {}
248 | #endif
249 |
250 | #endif // canImport(SwiftUI)
251 |
252 | #if os(macOS)
253 |
254 | public class NSPagedInfiniteScrollView: NSPageController, NSPageControllerDelegate {
255 |
256 | /// Data that will be passed to draw the view and get its frame.
257 | private var changeIndex: ChangeIndex {
258 | didSet {
259 | if let changeIndexNotification = self.changeIndexNotification {
260 | changeIndexNotification(changeIndex)
261 | }
262 | }
263 | }
264 |
265 | /// Function called when changeIndex is modified inside the class.
266 | ///
267 | /// Useful when using Bindings between mutliples UIPagedInfiniteScrollView, see a usage in ``PagedInfiniteScrollView``.
268 | private var changeIndexNotification: ((ChangeIndex) -> ())?
269 |
270 | /// Function called to get the content to display for a particular ChangeIndex.
271 | private let content: (ChangeIndex) -> NSViewController
272 |
273 | /// Function that get the ChangeIndex after another.
274 | ///
275 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right).
276 | ///
277 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
278 | ///
279 | /// ```swift
280 | /// let myArray: Array = [...]
281 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
282 | /// if changeIndex < myArray.count - 1 {
283 | /// return changeIndex + 1
284 | /// } else {
285 | /// return nil /// No more elements in the array.
286 | /// }
287 | /// }
288 | /// ```
289 | ///
290 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
291 | /// ```swift
292 | /// extension Date {
293 | /// func addingXDays(x: Int) -> Date {
294 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
295 | /// }
296 | /// }
297 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
298 | /// return currentDate.addingXDays(x: 30)
299 | /// }
300 | /// ```
301 | private let increaseIndexAction: (ChangeIndex) -> ChangeIndex?
302 |
303 | /// Function that get the ChangeIndex before another.
304 | ///
305 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the top/left).
306 | ///
307 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
308 | ///
309 | /// ```swift
310 | /// let myArray: Array = [...]
311 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
312 | /// if changeIndex > 0 {
313 | /// return changeIndex - 1
314 | /// } else {
315 | /// return nil /// We reached the beginning of the array.
316 | /// }
317 | /// }
318 | /// ```
319 | ///
320 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
321 | /// ```swift
322 | /// extension Date {
323 | /// func addingXDays(x: Int) -> Date {
324 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
325 | /// }
326 | /// }
327 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
328 | /// return currentDate.addingXDays(x: -30)
329 | /// }
330 | /// ```
331 | private let decreaseIndexAction: (ChangeIndex) -> ChangeIndex?
332 |
333 | /// Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation. If the boolean is false (no need to animate), the direction of the animation won't be used. In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
334 | private let shouldAnimateBetween: (_ oldIndex: ChangeIndex, _ newIndex: ChangeIndex) -> (shouldAnimate: Bool, side: SlideSide)
335 |
336 | private let indexesEqual: (ChangeIndex, ChangeIndex) -> Bool
337 |
338 | private var indexForString: [String: ChangeIndex] = [:]
339 |
340 | /// Creates an instance of NSPagedInfiniteScrollView.
341 | /// - Parameters:
342 | /// - content: Function called to get the content to display for a particular ChangeIndex.
343 | /// - changeIndex: Data that will be passed to draw the view and get its frame.
344 | /// - changeIndexNotification: Function called when changeIndex is modified inside the class. Useful when using Bindings between mutliples NSPagedInfiniteScrollView, see a usage in ``PagedInfiniteScrollView``.
345 | /// - increaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the top/left). See definition in class to learn more.
346 | /// - decreaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right). See definition in class to learn more.
347 | /// - shouldAnimateBetween: Function that will return a boolean indicating if there's need to animate the change between two given ChangeIndex, it also returns the direction of the animation. If the boolean is false (no need to animate), the direction of the animation won't be used. In most of the cases you won't want to animate if the two values are equals because it would animate barely everytime during the app use.
348 | /// - indexesEqual: Function that should tell whether two ChangeIndex should be considered as equal (and so shouldn't change the controller if a transition is made between them).
349 | /// - transitionStyle: The style for transitions between pages.
350 | public init(
351 | content: @escaping (ChangeIndex) -> NSViewController,
352 | changeIndex: ChangeIndex,
353 | changeIndexNotification: ((ChangeIndex) -> ())? = nil,
354 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
355 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
356 | shouldAnimateBetween: @escaping (ChangeIndex, ChangeIndex) -> (Bool, SlideSide),
357 | indexesEqual: @escaping (ChangeIndex, ChangeIndex) -> Bool,
358 | transitionStyle: NSPageController.TransitionStyle = .horizontalStrip
359 | ) {
360 | self.content = content
361 | self.changeIndex = changeIndex
362 | self.changeIndexNotification = changeIndexNotification
363 | self.increaseIndexAction = increaseIndexAction
364 | self.decreaseIndexAction = decreaseIndexAction
365 | self.shouldAnimateBetween = shouldAnimateBetween
366 | self.indexesEqual = indexesEqual
367 | super.init(nibName: nil, bundle: nil)
368 | self.transitionStyle = transitionStyle
369 | }
370 |
371 | required init?(coder: NSCoder) {
372 | fatalError("init(coder:) has not been implemented")
373 | }
374 |
375 | public override func viewDidLoad() {
376 | super.viewDidLoad()
377 |
378 | // setDontCacheViewControllers:, as we always create new controllers, we don't want to store them.
379 | let selectorEncodedString = "c2V0RG9udENhY2hlVmlld0NvbnRyb2xsZXJzOg=="
380 | let selectorEncodedData = Data(base64Encoded: selectorEncodedString)!
381 | self.perform(Selector(String(decoding: selectorEncodedData, as: UTF8.self)), with: true)
382 |
383 | // Set up NSPageController
384 | self.delegate = self
385 | }
386 |
387 | public override func viewWillAppear() {
388 | super.viewWillAppear()
389 |
390 | self.selectedIndex = 0
391 |
392 | self.arrangedObjects.append(changeIndex)
393 |
394 | if let previousIndex = decreaseIndexAction(self.changeIndex) {
395 | self.arrangedObjects.insert(previousIndex, at: 0)
396 | self.selectedIndex = 1
397 | }
398 |
399 | if let nextIndex = increaseIndexAction(self.changeIndex) {
400 | self.arrangedObjects.append(nextIndex)
401 | }
402 |
403 | self.view.autoresizingMask = [.width, .height]
404 | }
405 |
406 | /// Boolean indicating whether an animation is currently running.
407 | private var isAnimating: Bool = false
408 |
409 | /// Programmaticaly change the current index (with animation).
410 | public func changeCurrentIndex(to newIndex: ChangeIndex) {
411 | if !self.indexesEqual(self.changeIndex, newIndex) {
412 | if let newIndexIndex = self.arrangedObjects.firstIndex(where: {
413 | if let index = ($0 as? ChangeIndex) {
414 | return self.indexesEqual(index, newIndex)
415 | } else {
416 | return false
417 | }
418 | }) {
419 | let potentialAnimation = self.shouldAnimateBetween(self.changeIndex, newIndex)
420 | if potentialAnimation.shouldAnimate {
421 | NSAnimationContext.runAnimationGroup { _ in
422 | self.animator().selectedIndex = newIndexIndex
423 | } completionHandler: {
424 | self.completeTransition()
425 | }
426 | } else {
427 | self.completeTransition()
428 | self.selectedIndex = newIndexIndex
429 | }
430 | return
431 | }
432 | let potentialAnimation = self.shouldAnimateBetween(self.changeIndex, newIndex)
433 |
434 | switch potentialAnimation.side {
435 | case .trailing:
436 | self.arrangedObjects.insert(newIndex, at: 0)
437 | if potentialAnimation.shouldAnimate {
438 | //To animate a selectedIndex change:
439 | NSAnimationContext.runAnimationGroup { _ in
440 | self.animator().selectedIndex = 0
441 | } completionHandler: {
442 | self.completeTransition()
443 | }
444 | } else {
445 | self.completeTransition()
446 | self.selectedIndex = 0
447 | }
448 | case .leading:
449 | self.arrangedObjects.append(newIndex)
450 | if potentialAnimation.shouldAnimate {
451 | //To animate a selectedIndex change:
452 | NSAnimationContext.runAnimationGroup { _ in
453 | self.isAnimating = true
454 | // go to the last element so the animation is going to the leading side
455 | self.animator().selectedIndex = self.arrangedObjects.count - 1
456 | } completionHandler: {
457 | self.completeTransition()
458 | self.isAnimating = false
459 |
460 | if let nextIndex = self.increaseIndexAction(self.changeIndex) {
461 | self.arrangedObjects.append(nextIndex)
462 | }
463 | }
464 | } else {
465 | self.completeTransition()
466 | self.selectedIndex = self.arrangedObjects.count - 1
467 | }
468 | }
469 | }
470 | }
471 |
472 | public func pageController(_ pageController: NSPageController, identifierFor object: Any) -> NSPageController.ObjectIdentifier {
473 | guard let index = object as? ChangeIndex else { return "" }
474 |
475 | // Use a unique identifier for each page
476 | if let indexString = self.indexForString.first(where: {
477 | self.indexesEqual($0.value, index)
478 | })?.key {
479 | return indexString
480 | } else {
481 | let indexString = UUID().uuidString
482 |
483 | self.indexForString[indexString] = index
484 |
485 | return indexString
486 | }
487 | }
488 |
489 | public func pageController(_ pageController: NSPageController, viewControllerForIdentifier identifier: NSPageController.ObjectIdentifier) -> NSViewController {
490 | guard let index = self.indexForString[identifier] else { return NSViewController() }
491 |
492 | return self.content(index)
493 | }
494 |
495 | /// Logic:
496 | /// 1. Take the new index
497 | /// 2. Take a potential previous index, put it in the ``NSPagedInfiniteScrollView/arrangedObjects`` before the new index. If there isn't, put only the new index in the ``NSPagedInfiniteScrollView/arrangedObjects``.
498 | /// 3. Check if there's a nextIndex, if there is, put it at the end of ``NSPagedInfiniteScrollView/arrangedObjects``.
499 | public func pageController(_ pageController: NSPageController, didTransitionTo object: Any) {
500 |
501 | guard let newIndex = object as? ChangeIndex else { return }
502 |
503 | self.changeIndex = newIndex
504 |
505 | NSAnimationContext.beginGrouping()
506 |
507 | NSAnimationContext.current.allowsImplicitAnimation = false
508 |
509 | if self.selectedIndex == 0 {
510 | self.arrangedObjects = [newIndex]
511 |
512 | if let nextIndex = increaseIndexAction(self.changeIndex) {
513 | self.arrangedObjects.append(nextIndex)
514 | }
515 |
516 | if let previousIndex = decreaseIndexAction(self.changeIndex) {
517 | self.arrangedObjects.insert(previousIndex, at: 0)
518 | self.selectedIndex = 1
519 | }
520 | } else if self.selectedIndex > 1 && !self.isAnimating {
521 | // keep the second and third element
522 | if let previousIndex = decreaseIndexAction(self.changeIndex) {
523 | self.arrangedObjects = [previousIndex, self.changeIndex]
524 | self.selectedIndex = 1
525 | } else {
526 | self.arrangedObjects = [self.changeIndex]
527 | }
528 |
529 | if let nextIndex = increaseIndexAction(self.changeIndex) {
530 | self.arrangedObjects.append(nextIndex)
531 | }
532 | }
533 |
534 | NSAnimationContext.endGrouping()
535 |
536 | self.removeUnusedIndexes()
537 | }
538 |
539 | private func removeUnusedIndexes() {
540 | self.indexForString = self.indexForString.filter { element in
541 | self.arrangedObjects.contains {
542 | if let index = ($0 as? ChangeIndex) {
543 | return self.indexesEqual(index, element.value)
544 | } else {
545 | return false
546 | }
547 | }
548 | }
549 | }
550 |
551 | public enum SlideSide {
552 | case leading, trailing
553 | }
554 | }
555 | #else
556 | /// UIKit component of the UIPagedInfiniteScrollView.
557 | ///
558 | /// Generic types:
559 | /// - Content: a View.
560 | /// - ChangeIndex: A type of data that will be given to draw the views and that will be increased and drecreased. It could be for example an Int, a Date or whatever you want.
561 | public class UIPagedInfiniteScrollView: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
562 |
563 | /// Data that will be passed to draw the view and get its frame.
564 | private var changeIndex: ChangeIndex {
565 | didSet {
566 | if let changeIndexNotification = self.changeIndexNotification {
567 | changeIndexNotification(changeIndex)
568 | }
569 | }
570 | }
571 |
572 | /// Function called when changeIndex is modified inside the class.
573 | ///
574 | /// Useful when using Bindings between mutliples UIPagedInfiniteScrollView, see a usage in ``PagedInfiniteScrollView``.
575 | private var changeIndexNotification: ((ChangeIndex) -> ())?
576 |
577 | /// Function called to get the content to display for a particular ChangeIndex.
578 | private let content: (ChangeIndex) -> UIViewController
579 |
580 | /// Function that get the ChangeIndex after another.
581 | ///
582 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right).
583 | ///
584 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
585 | ///
586 | /// ```swift
587 | /// let myArray: Array = [...]
588 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
589 | /// if changeIndex < myArray.count - 1 {
590 | /// return changeIndex + 1
591 | /// } else {
592 | /// return nil /// No more elements in the array.
593 | /// }
594 | /// }
595 | /// ```
596 | ///
597 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
598 | /// ```swift
599 | /// extension Date {
600 | /// func addingXDays(x: Int) -> Date {
601 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
602 | /// }
603 | /// }
604 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
605 | /// return currentDate.addingXDays(x: 30)
606 | /// }
607 | /// ```
608 | private let increaseIndexAction: (ChangeIndex) -> ChangeIndex?
609 |
610 | /// Function that get the ChangeIndex before another.
611 | ///
612 | /// Should return nil if there is no more content to display (end of the PagedScrollView at the top/left).
613 | ///
614 | /// For example, if ChangeIndex was Int and it meant the index of an element in an Array:
615 | ///
616 | /// ```swift
617 | /// let myArray: Array = [...]
618 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
619 | /// if changeIndex > 0 {
620 | /// return changeIndex - 1
621 | /// } else {
622 | /// return nil /// We reached the beginning of the array.
623 | /// }
624 | /// }
625 | /// ```
626 | ///
627 | /// Another example would be if ChangeIndex was a Date and we wanted to display every month:
628 | /// ```swift
629 | /// extension Date {
630 | /// func addingXDays(x: Int) -> Date {
631 | /// Calendar.current.date(byAdding: .day, value: x, to: self) ?? self
632 | /// }
633 | /// }
634 | /// let increaseIndexAction: (ChangeIndex) -> ChangeIndex? = { changeIndex in
635 | /// return currentDate.addingXDays(x: -30)
636 | /// }
637 | /// ```
638 | private let decreaseIndexAction: (ChangeIndex) -> ChangeIndex?
639 |
640 | /// Creates an instance of UIPagedInfiniteScrollView.
641 | /// - Parameters:
642 | /// - content: Function called to get the content to display for a particular ChangeIndex.
643 | /// - changeIndex: Data that will be passed to draw the view and get its frame.
644 | /// - changeIndexNotification: Function called when changeIndex is modified inside the class. Useful when using Bindings between mutliples UIPagedInfiniteScrollView, see a usage in ``PagedInfiniteScrollView``.
645 | /// - increaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the top/left). See definition in class to learn more.
646 | /// - decreaseIndexAction: Function that get the ChangeIndex before another. Should return nil if there is no more content to display (end of the PagedScrollView at the bottom/right). See definition in class to learn more.
647 | /// - transitionStyle: The style for transitions between pages.
648 | /// - navigationOrientation: The orientation of the page-by-page navigation.
649 | ///
650 | /// When you initialize the first view of the PagedInfiniteScrollView, don't forget to set the storedChangeIndex on your UIViewController like this:
651 | /// ```swift
652 | /// let myFirstChangeIndex: ChangeIndex = ...
653 | /// let myViewController: UIViewController = ...
654 | /// myViewController.storedChangeIndex = myFirstChangeIndex
655 | /// ```
656 | /// also make sure that the value you'll store in storedChangeIndex is of the type of ChangeIndex, and not `Binding` for example, otherwise the PagedInfiniteScrollView just won't work.
657 | public init(
658 | content: @escaping (ChangeIndex) -> UIViewController,
659 | changeIndex: ChangeIndex,
660 | changeIndexNotification: ((ChangeIndex) -> ())? = nil,
661 | increaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
662 | decreaseIndexAction: @escaping (ChangeIndex) -> ChangeIndex?,
663 | transitionStyle: UIPageViewController.TransitionStyle,
664 | navigationOrientation: UIPageViewController.NavigationOrientation
665 | ) {
666 | let convertedClosure: (ChangeIndex) -> UIViewController = { changeIndex in
667 | let controller = content(changeIndex)
668 | controller.storedChangeIndex = changeIndex
669 | return controller
670 | }
671 | self.content = convertedClosure
672 | self.changeIndex = changeIndex
673 | self.changeIndexNotification = changeIndexNotification
674 | self.increaseIndexAction = increaseIndexAction
675 | self.decreaseIndexAction = decreaseIndexAction
676 | super.init(transitionStyle: transitionStyle, navigationOrientation: navigationOrientation)
677 | self.dataSource = self
678 | self.delegate = self
679 | }
680 |
681 | @available(*, unavailable)
682 | required init?(coder: NSCoder) {
683 | fatalError("init(coder:) has not been implemented, please open a PR if you would like it to be implemented")
684 | }
685 |
686 | public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
687 | guard let currentIndex = viewController.storedChangeIndex as? ChangeIndex else {
688 | return nil /// Stops scroll.
689 | }
690 |
691 | /// Check if there is more content to display, if yes then it returns it.
692 | if let decreasedIndex = decreaseIndexAction(currentIndex) {
693 | return content(decreasedIndex)
694 | }
695 | return nil
696 | }
697 |
698 | public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
699 | guard let currentIndex = viewController.storedChangeIndex as? ChangeIndex else {
700 | return nil /// Stops scroll.
701 | }
702 |
703 | /// Check if there is more content to display, if yes then it returns it.
704 | if let increasedIndex = increaseIndexAction(currentIndex) {
705 | return content(increasedIndex)
706 | }
707 | return nil
708 | }
709 |
710 | public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
711 | /// Check if the transition was successful and update the changeIndex of the class.
712 | if completed,
713 | let currentViewController = pageViewController.viewControllers?.first,
714 | let currentIndex = currentViewController.storedChangeIndex as? ChangeIndex {
715 | changeIndex = currentIndex
716 | }
717 | }
718 | }
719 |
720 | /// To store the changeIndex in the ViewController
721 | ///
722 | /// From: https://tetontech.wordpress.com/2015/11/12/adding-custom-class-properties-with-swift-extensions/
723 | public extension UIViewController {
724 | private struct ChangeIndex {
725 | static var changeIndex: Any? = nil
726 | }
727 |
728 | var storedChangeIndex: Any? {
729 | get {
730 | return objc_getAssociatedObject(self, &ChangeIndex.changeIndex) as Any?
731 | }
732 | set {
733 | if let unwrappedValue = newValue {
734 | objc_setAssociatedObject(self, &ChangeIndex.changeIndex, unwrappedValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
735 | }
736 | }
737 | }
738 | }
739 | #endif
740 |
--------------------------------------------------------------------------------
/Tests/InfiniteScrollViewsTests/InfiniteScrollViewsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import InfiniteScrollViews
3 |
4 | final class InfiniteScrollViewsTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------