├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fb5i%2FInfiniteScrollViews%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/b5i/InfiniteScrollViews) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fb5i%2FInfiniteScrollViews%2Fbadge%3Ftype%3Dswift-versions)](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 | --------------------------------------------------------------------------------