3 | # InfinityScrollView
4 | 
5 | 
6 | 
7 |
8 | - [Requirements](#requirements)
9 | - [Installation](#installation)
10 | - [Usage](#usage)
11 | - [License](#license)
12 |
13 | `InfinityScrollView` is a Swift library that allows you to add endless horizontal scroll to different items. `InfinityScrollView` has various configuration options:
14 | - fast deceleration rate
15 | - snap to center item
16 | - configurable snap deceleration animations
17 |
18 |
19 | Infinity scroll example:
20 |
21 | 
22 |
23 |
24 | Infinity scroll example with different items sizes:
25 |
26 | 
27 |
28 |
29 | Infinity scroll example with snap to center item:
30 |
31 | 
32 |
33 |
34 | Single item behaviour:
35 |
36 | 
37 |
38 |
39 | ## Requirements
40 |
41 | - iOS 11.0+
42 | - Xcode 11.0+
43 | - Swift 5.0+
44 |
45 | ## Installation
46 |
47 | ### CocoaPods
48 |
49 | To integrate Infinity Scroll View into your Xcode project with CocoaPods, specify it in your `Podfile`:
50 |
51 | ```ruby
52 | pod 'Shakuro.InfinityScrollView'
53 | ```
54 |
55 | Then, run the following command:
56 |
57 | ```bash
58 | $ pod install
59 | ```
60 |
61 | ### Manually
62 |
63 | If you prefer not to use CocoaPods, you can integrate Shakuro.InfinityScrollView simply by copying it to your project.
64 |
65 | ## Usage
66 |
67 | Just create `InfinityScrollView` programmatically or in the storyboard. Take into account that `InfinityScrollView` must have the data source and the delegate objects. The data source needs to adopt the `InfinityScrollViewDataSource` protocol and the delegate has to adopt the `InfinityScrollViewDelegate` protocol. The data source provides the views that `InfinityScrollView` will display. The delegate allows you to respond to scrolling events.
68 |
69 | Take a look at the [InfinityScrollView_Example](https://github.com/shakurocom/InfinityScrollView/tree/main/InfinityScrollView_Example) (you need to perform `pod install` before before using it).
70 |
71 | ## License
72 |
73 | Shakuro.InfinityScrollView is released under the MIT license. [See LICENSE](https://github.com/shakurocom/InfinityScrollView/blob/main/LICENSE.md) for details.
74 |
75 | ## Give it a try and reach us
76 |
77 | Explore our expertise in Native Mobile Development and iOS Development.
78 |
79 | If you need professional assistance with your mobile or web project, feel free to contact our team
80 |
81 |
82 |
--------------------------------------------------------------------------------
/Resources/infinity_scroll.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shakurocom/InfinityScrollView/4820d1554107ad72b054c35b1d70b594d3026173/Resources/infinity_scroll.gif
--------------------------------------------------------------------------------
/Resources/infinity_scroll_with_different_sizes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shakurocom/InfinityScrollView/4820d1554107ad72b054c35b1d70b594d3026173/Resources/infinity_scroll_with_different_sizes.gif
--------------------------------------------------------------------------------
/Resources/infinity_scroll_with_snap_to_center.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shakurocom/InfinityScrollView/4820d1554107ad72b054c35b1d70b594d3026173/Resources/infinity_scroll_with_snap_to_center.gif
--------------------------------------------------------------------------------
/Resources/single_item_behaviour.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shakurocom/InfinityScrollView/4820d1554107ad72b054c35b1d70b594d3026173/Resources/single_item_behaviour.gif
--------------------------------------------------------------------------------
/Resources/title_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shakurocom/InfinityScrollView/4820d1554107ad72b054c35b1d70b594d3026173/Resources/title_image.png
--------------------------------------------------------------------------------
/Shakuro.InfinityScrollView.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'Shakuro.InfinityScrollView'
3 | s.version = '1.0.4'
4 | s.summary = 'Shakuro Infinity Scroll View'
5 | s.homepage = 'https://github.com/shakurocom/InfinityScrollView'
6 | s.license = { :type => "MIT", :file => "LICENSE.md" }
7 | s.authors = {'apopov1988' => 'apopov@shakuro.com', 'wwwpix' => 'spopov@shakuro.com', 'slaschuk' => 'slaschuk@shakuro.com'}
8 | s.source = { :git => 'https://github.com/shakurocom/InfinityScrollView.git', :tag => s.version }
9 | s.source_files = 'Source/*', 'Source/**/*'
10 | s.swift_version = ['5.1', '5.2', '5.3', '5.4', '5.5', '5.6']
11 | s.ios.deployment_target = '11.0'
12 |
13 | s.dependency 'Shakuro.CommonTypes', '~> 1.1'
14 |
15 | end
16 |
--------------------------------------------------------------------------------
/Source/InfinityScrollView+Models.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2020 Shakuro (https://shakuro.com/)
3 | // Sergey Laschuk
4 | //
5 |
6 | import CoreGraphics
7 | import Foundation
8 | import UIKit
9 |
10 | extension InfinityScrollView {
11 |
12 | public enum SwipeDirection {
13 |
14 | /// User dragged here and there but end drag on the same item he started.
15 | case none
16 |
17 | /// User ended dragging on item to the left of the starting item
18 | case left
19 |
20 | /// User ended dragging on item to the right of the starting item
21 | case right
22 |
23 | }
24 |
25 | public enum SnapAnimation {
26 |
27 | /// No animation - hard jump to projected offset
28 | case none
29 |
30 | /// Default animation of UIScrollView
31 | case scrollView
32 |
33 | /// Animation curve with given name (ex.: easeIn)
34 | case curve(duration: CFTimeInterval, name: CAMediaTimingFunctionName)
35 |
36 | /// Dumped spring. See `CASpringAnimation` for parameters description.
37 | /// Initial velocity obtained from drag.
38 | /// - warning: Very bouncy spring on energetic (high velocity) drag can lead to user see not yet tiled-out area.
39 | case spring(mass: CGFloat, stiffness: CGFloat, damping: CGFloat)
40 |
41 | /// example parameters for spring animation
42 | public static let defaultSpring = SnapAnimation.spring(mass: 1, stiffness: 40, damping: 8)
43 |
44 | }
45 |
46 | public enum SingleItemBehavior {
47 |
48 | /// Single item is tiled (as if there is more than one item)
49 | case tile
50 |
51 | /// No tiling.
52 | /// Scroll area set to the width of item.
53 | /// UIScrollview's bounce will be enabled for user drag.
54 | case bounce
55 |
56 | /// No tiling.
57 | /// Scroll area set to the width of item.
58 | /// UIScrollview's bounce will be disabled for user drag.
59 | case noBounce
60 |
61 | }
62 |
63 | internal struct NearestVisibleCenterItemData {
64 | internal let anchorOffsetX: CGFloat
65 | internal let tileCenterX: CGFloat
66 | internal let itemIndex: Int
67 | internal let tileIndex: Int
68 | }
69 |
70 | internal struct AnimationData {
71 | internal let projectedContentOffsetX: CGFloat
72 | internal let nearestItemData: NearestVisibleCenterItemData
73 | internal let initialVelocityX: CGFloat
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/Source/InfinityScrollView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright (c) 2020 Shakuro (https://shakuro.com/)
3 | // Sergey Popov, Sergey Laschuk
4 | //
5 |
6 | import Foundation
7 | import Shakuro_CommonTypes
8 | import UIKit
9 |
10 | public protocol InfinityScrollViewDataSource: AnyObject {
11 |
12 | /// - parameter infinityScrollView: caller.
13 | /// - returns: number of elementes in infinity scroll.
14 | /// Negative value will throw assert (debug) or be rounded to zero (release).
15 | func infinityScrollViewNumberOfItems(_ infinityScrollView: InfinityScrollView) -> Int
16 |
17 | /// - parameter infinityScrollView: caller.
18 | /// - parameter index: index of item.
19 | /// - returns: width of item at index.
20 | /// Behaviour for zero/negative values is not defined (please refrain)
21 | /// Height will be equal to the height of InfinityScrollView.
22 | func infinityScrollView(_ infinityScrollView: InfinityScrollView, widthForItemAtIndex index: Int) -> CGFloat
23 |
24 | /// - parameter infinityScrollView: caller.
25 | /// - parameter index: index of item.
26 | /// - parameter size: size, that will be set to view.
27 | /// - returns: setted up view for displaying item
28 | ///
29 | /// `.intrinsicContentSize` is not supported.
30 | /// Height will be always equal to height of InfinityScrollView itself.
31 | /// Consider creating etalon view and calculating dynamic width inside `infinityScrollView(:,widthForItemAtIndex:)`.
32 | ///
33 | /// You can perform some additional configuration of returned view, depending on the provided size.
34 | func infinityScrollView(_ infinityScrollView: InfinityScrollView, viewForItemAtIndex index: Int, size: CGSize) -> UIView
35 |
36 | }
37 |
38 | public protocol InfinityScrollViewDelegate: AnyObject {
39 |
40 | /// Will not be reported if there are no items in InfinityScrollView
41 | func infinityScrollView(_ infinityScrollView: InfinityScrollView,
42 | willEndSwipeOnItemAtIndex itemIndex: Int,
43 | swipeDirection: InfinityScrollView.SwipeDirection)
44 |
45 | /// Will be reported even if deceleration is disabled.
46 | func infinityScrollView(_ infinityScrollView: InfinityScrollView, didEndDeceleratingOnItemAtIndex itemIndex: Int, wasAborted: Bool)
47 |
48 | /// User tapped specific item.
49 | /// Tap to stop deceleration animation do not count.
50 | func infinityScrollView(_ infinityScrollView: InfinityScrollView, didSelectItemAtIndex itemIndex: Int)
51 |
52 | }
53 |
54 | /// CollectView-like control. Handles infinity collection of items.
55 | /// Can be scrolled left and right.
56 | /// Looped: after last items goes first one.
57 | /// If only single item - user will see endless amounts of this item.
58 | public class InfinityScrollView: UIView {
59 |
60 | private enum Constant {
61 | static let snapAnimationKey: String = "InfinityScrollView.snapAnimation"
62 | static let snapAnimationNameValue: String = "InfinityScrollView.snapAnimation.name"
63 | static let snapAnimationNameKey: String = "InfinityScrollView.snapAnimation.key"
64 | }
65 |
66 | public weak var dataSource: InfinityScrollViewDataSource?
67 | public weak var delegate: InfinityScrollViewDelegate?
68 |
69 | /// If `true` content will be decelerated with velocity of drag (after drag is ended)
70 | ///
71 | /// Default value is `true`.
72 | public var isDecelerationEnabled: Bool = true
73 |
74 | /// Deceleration rate.
75 | /// Use constants from `UIScrollView.DecelerationRate` as guide for possible values.
76 | ///
77 | /// Default value is `UIScrollView.DecelerationRate.normal`.
78 | public var decelerationRate: UIScrollView.DecelerationRate {
79 | get {
80 | return internalScrollView.decelerationRate
81 | }
82 | set {
83 | internalScrollView.decelerationRate = newValue
84 | }
85 | }
86 |
87 | /// If `true` content will be snapped to nearest item center at drag end. Respects deceleration.
88 | /// Applies animation if allowed.
89 | /// Applies deceleration if allowed.
90 | /// Not recommended if item's width is greater than control's width.
91 | ///
92 | /// Default value is `false`.
93 | public var isSnapEnabled: Bool = false
94 |
95 | /// Animation for snap after dragging ended
96 | ///
97 | /// Default value is `SnapAnimation.scrollView`
98 | public var snapAnimation: SnapAnimation = .scrollView
99 |
100 | /// Behaviour for data source of only one item.
101 | ///
102 | /// Default value is `SingleItemBehavior.bounce`.
103 | public private(set) var singleItemBehavior: SingleItemBehavior = .bounce
104 |
105 | private var internalScrollView: UIScrollView!
106 | private var contentContainerView: UIView!
107 | private var touchDownRecognizer: SingleTouchDownGestureRecognizer!
108 | private var tapRecognizer: UITapGestureRecognizer!
109 |
110 | private var cachedItemWidths: [CGFloat] = []
111 | private var cachedNumberOfItems: Int = 0
112 | private var cachedItemsTotalWidth: CGFloat = 0.0
113 |
114 | /// Offset origin.x of view of first item of first iteration from content's centerX.
115 | ///
116 | /// -(cachedItems[0].width)/2
117 | private var cachedZeroItemOffset: CGFloat = 0.0
118 |
119 | /// How much extra space is filled with item's views beyond visible area (internalScrollView.bounds) to the left & right
120 | ///
121 | /// = min(bounds.width, 500)
122 | private var visibleAreaOverhangX: CGFloat = 500.0 // twice frame's width
123 |
124 | /// Views for items that are added to content view.
125 | /// Index is a tiled (zero-based & pass-through) index: 11 for 9 total items means 3rd item in second iteration.
126 | private var visibleTileViews: [Int: UIView] = [:]
127 |
128 | /// Minimum of content offset change required to trigger recenter of scrollable content.
129 | ///
130 | /// visibleAreaOverhangX / 2
131 | private var recenterThrottleDistance: CGFloat = 0.0
132 |
133 | private var allowRecenter: Int = 1 // allowed if > 0
134 |
135 | /// The whole width of scrollable area. Should be big enough for user not to "bounce" against it.
136 | /// Offset will be resetted within it as often as possible, so that user constantly see only ceter-ish section of it.
137 | ///
138 | /// 5000.0
139 | private var scrollableContentWidth: CGFloat = 5000.0
140 |
141 | /// Layout will be skipped, if lastLayoutBoundsSize == bounds.size
142 | private var lastLayoutBoundsSize: CGSize = .zero
143 |
144 | /// Minimum amount content offset must be changed to trigger views tiling.
145 | ///
146 | /// visibleAreaOverhangX / 5
147 | private var tileThrottleDistance: CGFloat = 0.0
148 |
149 | /// Last time tiling was performed - content offset was here.
150 | /// Used for throttling of tiling.
151 | private var tileDoneForContentOffsetX: CGFloat = .infinity
152 |
153 | /// allowed if > 0
154 | private var allowTiling: Int = 1
155 |
156 | /// Additional offset accumulated due to recentering.
157 | private var recenteredZeroItemOffset: CGFloat = 0
158 |
159 | /// Will be filled/updated in `scrollViewWillEndDragging(:withVelocity:targetContentOffset:)`.
160 | /// Used for reporting to delegate and animating deceleration.
161 | private var decelerationAnimationData: AnimationData?
162 |
163 | /// Animation used for animating deceleration (snap or no snap).
164 | /// `.none` & `.scrollView` settings do not use this property.
165 | private var decelerateAnimation: CAAnimation?
166 |
167 | /// Index of the tile under the center
168 | private var dragStartedTileIndex: Int?
169 |
170 | // MARK: - Initialization
171 |
172 | override init(frame: CGRect) {
173 | super.init(frame: frame)
174 | commonInit()
175 | }
176 |
177 | required init?(coder aDecoder: NSCoder) {
178 | super.init(coder: aDecoder)
179 | commonInit()
180 | }
181 |
182 | // MARK: - Events
183 |
184 | public override func layoutSubviews() {
185 | super.layoutSubviews()
186 |
187 | guard lastLayoutBoundsSize != bounds.size else {
188 | return
189 | }
190 |
191 | updateScroll()
192 |
193 | // width-dependent layout
194 | if lastLayoutBoundsSize.width != bounds.width {
195 | visibleAreaOverhangX = max(500.0, bounds.width)
196 | tileThrottleDistance = visibleAreaOverhangX / 5.0
197 | recenterThrottleDistance = visibleAreaOverhangX / 2.0
198 | recenterIfNeeded(allowThrottle: false, allowShiftTileViews: false)
199 | tileItemViewsIfNeeded(allowThrottle: false, targetContentOffsetX: nil)
200 | setupSingleItemIfNeeded()
201 | }
202 |
203 | // height-dependent layout
204 | if lastLayoutBoundsSize.height != bounds.height {
205 | updateVisibleTileViews(height: bounds.height)
206 | }
207 |
208 | lastLayoutBoundsSize = bounds.size
209 | }
210 |
211 | // MARK: - Public
212 |
213 | public func viewForItem(at index: Int) -> UIView? {
214 | return visibleTileViews[index]
215 | }
216 |
217 | public func accessibilityScrollForward() {
218 | guard visibleTileViews.count > 1,
219 | let currentIndex = indexOfItemAtVisibleCenter()
220 | else {
221 | return
222 | }
223 | let nextIndex = currentIndex + 1 == visibleTileViews.count ? 0 : currentIndex + 1
224 | if let frame = visibleTileViews[nextIndex]?.frame {
225 | internalScrollView.setContentOffset(CGPoint(x: frame.midX - (internalScrollView.frame.width / 2), y: 0), animated: true)
226 | }
227 | }
228 |
229 | public func accessibilityScrollBackward() {
230 | guard visibleTileViews.count > 1,
231 | let currentIndex = indexOfItemAtVisibleCenter()
232 | else {
233 | return
234 | }
235 | let previousIndex = currentIndex - 1 == -1 ? visibleTileViews.count - 1 : currentIndex - 1
236 | if let frame = visibleTileViews[previousIndex]?.frame {
237 | internalScrollView.setContentOffset(CGPoint(x: frame.midX - (internalScrollView.frame.width / 2), y: 0), animated: true)
238 | }
239 | }
240 |
241 | public func reloadData() {
242 | recreateCacheFromDataSource()
243 | updateScroll()
244 | recenterIfNeeded(allowThrottle: false, allowShiftTileViews: false)
245 | tileItemViewsIfNeeded(allowThrottle: false, targetContentOffsetX: nil)
246 | setupSingleItemIfNeeded()
247 | }
248 |
249 | /// The whole width of scrollable area. Should be big enough for user not to "bounce" against it.
250 | /// Offset will be resetted within it as often as possible, so that user constantly see only ceter-ish section of it.
251 | ///
252 | /// Recentering is disabled during animations.
253 | /// Consider increasing this value if user swipes vilently and device screen is wide.
254 | /// Suggestion: 10 x Screen.width
255 | /// It is better to call `reloadData()` if new value is set.
256 | ///
257 | /// Default value is 5000.
258 | public func setScrollableContentWidth(_ newValue: CGFloat) {
259 | scrollableContentWidth = newValue
260 | setNeedsLayout()
261 | }
262 |
263 | /// See singleItemBehavior for description.
264 | /// Reloading of data is required after changing this setting.
265 | public func setSingleItemBehavior(_ newValue: SingleItemBehavior) {
266 | singleItemBehavior = newValue
267 | internalScrollView.bounces = newValue == .bounce
268 | internalScrollView.alwaysBounceHorizontal = newValue == .bounce
269 | setNeedsLayout()
270 | }
271 |
272 | public override func setNeedsLayout() {
273 | lastLayoutBoundsSize = .zero
274 | super.setNeedsLayout()
275 | }
276 |
277 | /// Index of item, which view is intersecting central line of drawing area.
278 | ///
279 | /// - returns: `nil` if there are no items
280 | public func indexOfItemAtVisibleCenter() -> Int? {
281 | return nearestVisibleCenterItem(targetOffsetX: internalScrollView.contentOffset.x)?.itemIndex
282 | }
283 |
284 | }
285 |
286 | // MARK: - UIScrollViewDelegate
287 |
288 | extension InfinityScrollView: UIScrollViewDelegate {
289 |
290 | public func scrollViewDidScroll(_ scrollView: UIScrollView) {
291 | guard scrollView === internalScrollView else {
292 | return
293 | }
294 | tileItemViewsIfNeeded(allowThrottle: true, targetContentOffsetX: nil)
295 | }
296 |
297 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
298 | guard scrollView === internalScrollView else {
299 | return
300 | }
301 | let visibleItem = nearestVisibleCenterItem(targetOffsetX: scrollView.contentOffset.x)
302 | dragStartedTileIndex = visibleItem?.tileIndex
303 | if decelerationAnimationData != nil {
304 | if isDecelerationEnabled {
305 | if (cachedNumberOfItems > 1) || (singleItemBehavior == .tile) || isSnapEnabled {
306 | switch snapAnimation {
307 | case .none:
308 | // already reported
309 | break
310 | case .scrollView:
311 | // deceleration animation from UIScrollView is used
312 | // will report in `scrollViewDidEndDecelerating()`
313 | if let itemIndex = visibleItem?.itemIndex {
314 | delegate?.infinityScrollView(self, didEndDeceleratingOnItemAtIndex: itemIndex, wasAborted: true)
315 | }
316 | case .curve, .spring:
317 | // will be reported in `animationDidStop(...)`
318 | break
319 | }
320 | } else {
321 | // special case for single item: non-scrollView animation do not work
322 | // : deceleration animation from UIScrollView is used
323 | // : will report in `scrollViewDidEndDecelerating()`
324 | if let itemIndex = visibleItem?.itemIndex {
325 | delegate?.infinityScrollView(self, didEndDeceleratingOnItemAtIndex: itemIndex, wasAborted: true)
326 | }
327 | }
328 | } else {
329 | // already reported
330 | }
331 | } else {
332 | // already reported
333 | }
334 | stopSnapAnimation()
335 | decelerationAnimationData = nil // beginning of next drag cycle
336 | recenterIfNeeded(allowThrottle: true, allowShiftTileViews: true)
337 | }
338 |
339 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
340 | guard scrollView === internalScrollView else {
341 | return
342 | }
343 | if let decelerationData = decelerationAnimationData {
344 | if isDecelerationEnabled {
345 | if (cachedNumberOfItems > 1) || (singleItemBehavior == .tile) || isSnapEnabled {
346 | switch snapAnimation {
347 | case .none:
348 | // no animation
349 | internalScrollView.bounds.origin.x = decelerationData.projectedContentOffsetX
350 | // report
351 | delegate?.infinityScrollView(self,
352 | didEndDeceleratingOnItemAtIndex: decelerationData.nearestItemData.itemIndex,
353 | wasAborted: false)
354 | case .scrollView:
355 | // deceleration animation from UIScrollView is used
356 | // will report in `scrollViewDidEndDecelerating(...)` or `scrollViewWillBeginDragging(...)`
357 | break
358 | case .curve, .spring:
359 | startSnapAnimation(initialVelocityX: decelerationData.initialVelocityX,
360 | targetContentOffset: CGPoint(x: decelerationData.projectedContentOffsetX, y: 0))
361 | }
362 | } else {
363 | // fallback to 'scrollView' behaviour - other animations make no sense and do not work
364 | // : deceleration animation from UIScrollView is used
365 | // : will report in `scrollViewDidEndDecelerating(...)` or `scrollViewWillBeginDragging(...)`
366 | }
367 | } else {
368 | delegate?.infinityScrollView(self, didEndDeceleratingOnItemAtIndex: decelerationData.nearestItemData.itemIndex, wasAborted: false)
369 | }
370 | } else {
371 | // there are no items - nothing to work with
372 | }
373 | }
374 |
375 | public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
376 | guard scrollView === internalScrollView else {
377 | return
378 | }
379 | if decelerationAnimationData != nil {
380 | if isDecelerationEnabled {
381 | if (cachedNumberOfItems > 1) || (singleItemBehavior == .tile) || isSnapEnabled {
382 | switch snapAnimation {
383 | case .none:
384 | // already reported
385 | break
386 | case .scrollView:
387 | if let itemIndex = nearestVisibleCenterItem(targetOffsetX: scrollView.contentOffset.x)?.itemIndex {
388 | delegate?.infinityScrollView(self, didEndDeceleratingOnItemAtIndex: itemIndex, wasAborted: false)
389 | }
390 | decelerationAnimationData = nil
391 | case .curve, .spring:
392 | // will be reported in `animationDidStop(...)`
393 | break
394 | }
395 | } else {
396 | // special case for single item behaviour: non-scrollView animations do not work
397 | if let itemIndex = nearestVisibleCenterItem(targetOffsetX: scrollView.contentOffset.x)?.itemIndex {
398 | // in case of bounce: scrollViewDidEndDecelerating will be called before scrollViewWillBeginDragging
399 | // so, if scrollView.isTracking -> deceleration (bounce) was aborted
400 | delegate?.infinityScrollView(self, didEndDeceleratingOnItemAtIndex: itemIndex, wasAborted: scrollView.isTracking)
401 | }
402 | decelerationAnimationData = nil
403 | }
404 | } else {
405 | // already reported
406 | }
407 | } else {
408 | // already reported
409 | }
410 | }
411 |
412 | public func scrollViewWillEndDragging(_ scrollView: UIScrollView,
413 | withVelocity velocity: CGPoint,
414 | targetContentOffset: UnsafeMutablePointer) {
415 | guard scrollView === internalScrollView else {
416 | return
417 | }
418 | var projectedContentOffsetX = scrollView.contentOffset.x
419 | if isDecelerationEnabled {
420 | projectedContentOffsetX = DecelerationHelper.project(value: scrollView.contentOffset.x,
421 | initialVelocity: velocity.x,
422 | decelerationRate: scrollView.decelerationRate.rawValue)
423 | tileItemViewsIfNeeded(allowThrottle: false, targetContentOffsetX: projectedContentOffsetX)
424 | }
425 |
426 | if let nearestItemData = nearestVisibleCenterItem(targetOffsetX: projectedContentOffsetX) {
427 | if let startIndex = dragStartedTileIndex {
428 | let direction = swipeDirection(startIndex: startIndex, endIndex: nearestItemData.tileIndex)
429 | delegate?.infinityScrollView(self, willEndSwipeOnItemAtIndex: nearestItemData.itemIndex, swipeDirection: direction)
430 | }
431 | if isSnapEnabled {
432 | projectedContentOffsetX = nearestItemData.anchorOffsetX
433 | }
434 | decelerationAnimationData = AnimationData(projectedContentOffsetX: projectedContentOffsetX,
435 | nearestItemData: nearestItemData,
436 | initialVelocityX: velocity.x)
437 | if (cachedNumberOfItems > 1) || (singleItemBehavior == .tile) || isSnapEnabled {
438 | switch snapAnimation {
439 | case .none:
440 | targetContentOffset.pointee = scrollView.contentOffset // stop system animation
441 | case .scrollView:
442 | targetContentOffset.pointee = CGPoint(x: projectedContentOffsetX, y: 0)
443 | case .curve, .spring:
444 | targetContentOffset.pointee = scrollView.contentOffset // stop system animation
445 | allowTiling -= 1
446 | allowRecenter -= 1
447 | }
448 | } else {
449 | // without snap non-scrollview animations make no sense for non-tiled single item -> fallback to scrollView
450 | targetContentOffset.pointee = CGPoint(x: projectedContentOffsetX, y: 0)
451 | }
452 | }
453 | }
454 |
455 | }
456 |
457 | // MARK: - CAAnimationDelegate
458 |
459 | extension InfinityScrollView: CAAnimationDelegate {
460 |
461 | public func animationDidStop(_ anim: CAAnimation, finished finishedFlag: Bool) {
462 | if let animationName = anim.value(forKey: Constant.snapAnimationNameKey) as? String,
463 | animationName == Constant.snapAnimationNameValue {
464 | allowRecenter += 1
465 | allowTiling += 1
466 | decelerateAnimation?.delegate = nil
467 | decelerateAnimation = nil
468 | if isDecelerationEnabled {
469 | switch snapAnimation {
470 | case .none:
471 | // already reported
472 | break
473 | case .scrollView:
474 | // will be reported in `scrollViewDidEndDecelerating(...)` or `scrollViewDidEndDragging(...)`
475 | break
476 | case .curve, .spring:
477 | if let presentationLayer = internalScrollView.layer.presentation(),
478 | let nearestItem = nearestVisibleCenterItem(targetOffsetX: presentationLayer.bounds.origin.x) {
479 | delegate?.infinityScrollView(self,
480 | didEndDeceleratingOnItemAtIndex: nearestItem.itemIndex,
481 | wasAborted: !finishedFlag)
482 | } else {
483 | // nothing to report about
484 | }
485 | }
486 | } else {
487 | // already reported
488 | }
489 | }
490 | }
491 |
492 | }
493 |
494 | // MARK: - UIGestureRecognizerDelegate
495 |
496 | extension InfinityScrollView: UIGestureRecognizerDelegate {
497 |
498 | public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
499 | switch gestureRecognizer {
500 | case touchDownRecognizer:
501 | return internalScrollView.layer.animation(forKey: Constant.snapAnimationKey) != nil
502 | case tapRecognizer:
503 | return (internalScrollView.layer.animation(forKey: Constant.snapAnimationKey) == nil) && !internalScrollView.isDecelerating
504 | default:
505 | return super.gestureRecognizerShouldBegin(gestureRecognizer)
506 | }
507 | }
508 |
509 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
510 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
511 | switch gestureRecognizer {
512 | case touchDownRecognizer,
513 | tapRecognizer:
514 | return true
515 | default:
516 | return false
517 | }
518 | }
519 |
520 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
521 | shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
522 | if (gestureRecognizer === tapRecognizer) && (otherGestureRecognizer === touchDownRecognizer) {
523 | return true
524 | } else {
525 | return false
526 | }
527 | }
528 |
529 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
530 | shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
531 | if (gestureRecognizer === touchDownRecognizer) && (otherGestureRecognizer === touchDownRecognizer) {
532 | return true
533 | } else {
534 | return false
535 | }
536 | }
537 |
538 | }
539 |
540 | // MARK: - Private
541 |
542 | private extension InfinityScrollView {
543 |
544 | private func commonInit() {
545 | let scrollView = UIScrollView(frame: bounds)
546 | scrollView.showsVerticalScrollIndicator = false
547 | scrollView.showsHorizontalScrollIndicator = false
548 | scrollView.decelerationRate = UIScrollView.DecelerationRate.normal
549 | scrollView.scrollsToTop = false
550 | scrollView.delegate = self
551 | scrollView.backgroundColor = UIColor.clear
552 | scrollView.contentSize = CGSize(width: scrollableContentWidth, height: bounds.height)
553 | scrollView.translatesAutoresizingMaskIntoConstraints = true // manual layout by frame
554 | addSubview(scrollView)
555 | internalScrollView = scrollView
556 |
557 | let containerView = UIView(frame: CGRect(x: 0, y: 0, width: scrollableContentWidth, height: bounds.height))
558 | containerView.backgroundColor = UIColor.clear
559 | containerView.translatesAutoresizingMaskIntoConstraints = true // manual layout by frame
560 | scrollView.addSubview(containerView)
561 | contentContainerView = containerView
562 |
563 | let localTouchDownRecognizer = SingleTouchDownGestureRecognizer(target: self, action: #selector(handleSingleTouchDownGestureRecognized(_:)))
564 | localTouchDownRecognizer.delegate = self
565 | internalScrollView.addGestureRecognizer(localTouchDownRecognizer)
566 | touchDownRecognizer = localTouchDownRecognizer
567 |
568 | let localTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGestureRecognized(_:)))
569 | localTapRecognizer.delegate = self
570 | addGestureRecognizer(localTapRecognizer)
571 | tapRecognizer = localTapRecognizer
572 | }
573 |
574 | private func recreateCacheFromDataSource() {
575 | visibleTileViews.forEach({ $0.value.removeFromSuperview() })
576 | visibleTileViews.removeAll()
577 | tileDoneForContentOffsetX = .infinity
578 | cachedNumberOfItems = 0
579 | cachedItemWidths.removeAll()
580 | cachedItemsTotalWidth = 0
581 | cachedZeroItemOffset = 0
582 | recenteredZeroItemOffset = 0
583 | guard let strongDataSource = dataSource else {
584 | return
585 | }
586 | cachedNumberOfItems = strongDataSource.infinityScrollViewNumberOfItems(self)
587 | assert(cachedNumberOfItems >= 0, "number of items can't be negative")
588 | guard cachedNumberOfItems > 0 else {
589 | return
590 | }
591 | for itemIndex in 0.. 0,
608 | cachedNumberOfItems > 0,
609 | (cachedNumberOfItems > 1 || singleItemBehavior == .tile),
610 | let strongDataSource = dataSource else {
611 | return
612 | }
613 |
614 | let contentOffsetDistance = abs(tileDoneForContentOffsetX - internalScrollView.contentOffset.x)
615 | guard !allowThrottle || contentOffsetDistance > tileThrottleDistance else {
616 | return
617 | }
618 |
619 | let visibleBounds = internalScrollView.bounds
620 | let minVisibleX: CGFloat
621 | let maxVisibleX: CGFloat
622 | if let targetX = targetContentOffsetX {
623 | minVisibleX = min(visibleBounds.minX, targetX) - visibleAreaOverhangX
624 | maxVisibleX = max(visibleBounds.maxX, targetX) + visibleAreaOverhangX
625 | } else {
626 | minVisibleX = visibleBounds.minX - visibleAreaOverhangX
627 | maxVisibleX = visibleBounds.maxX + visibleAreaOverhangX
628 | }
629 |
630 | // remove existing tiles, that are no longer visible
631 | for (index, tileView) in visibleTileViews {
632 | if (tileView.frame.minX > maxVisibleX) || (tileView.frame.maxX < minVisibleX) {
633 | visibleTileViews.removeValue(forKey: index)
634 | tileView.removeFromSuperview()
635 | }
636 | }
637 |
638 | // prepare for adding new tiles
639 | let zeroItemStartX = scrollableContentWidth / 2.0 + cachedZeroItemOffset + recenteredZeroItemOffset
640 | let iterationMultiplier = floor((minVisibleX - zeroItemStartX) / cachedItemsTotalWidth)
641 | let tileStartX = zeroItemStartX + iterationMultiplier * cachedItemsTotalWidth
642 |
643 | // skip non-visible tiles (to the left of minVisibleX)
644 | var currentTileX = tileStartX
645 | var currentTileIndex: Int = 0 + Int(iterationMultiplier) * cachedNumberOfItems
646 | var currentItemWidth = cachedItemWidths[itemIndex(tileIndex: currentTileIndex)]
647 | var currentTileMaxX = currentTileX + currentItemWidth
648 | while currentTileMaxX < minVisibleX {
649 | currentTileIndex += 1
650 | currentItemWidth = cachedItemWidths[itemIndex(tileIndex: currentTileIndex)]
651 | currentTileX = currentTileMaxX
652 | currentTileMaxX += currentItemWidth
653 | }
654 |
655 | // add new tiles
656 | let itemHeight = visibleBounds.height
657 | while currentTileX < maxVisibleX {
658 | let currentItemIndex = itemIndex(tileIndex: currentTileIndex)
659 | currentItemWidth = cachedItemWidths[currentItemIndex]
660 | if visibleTileViews[currentTileIndex] == nil {
661 | let newTileFrame = CGRect(x: currentTileX, y: 0, width: currentItemWidth, height: itemHeight)
662 | let newTileView = strongDataSource.infinityScrollView(self, viewForItemAtIndex: currentItemIndex, size: newTileFrame.size)
663 | newTileView.frame = newTileFrame
664 | contentContainerView.addSubview(newTileView)
665 | visibleTileViews[currentTileIndex] = newTileView
666 | }
667 | currentTileX += currentItemWidth
668 | currentTileIndex += 1
669 | }
670 |
671 | tileDoneForContentOffsetX = internalScrollView.contentOffset.x
672 | }
673 |
674 | private func itemIndex(tileIndex: Int) -> Int {
675 | var result = tileIndex % cachedNumberOfItems
676 | if result < 0 {
677 | result += cachedNumberOfItems
678 | }
679 | return result
680 | }
681 |
682 | /// Move content so that center of content align with visual center of InfinityScrollView.
683 | /// - parameter allowThrottle: if true - recentering will be skipped, if since last time content offset was changed too little.
684 | /// - parameter allowShiftTileViews: if true - tile views will be shifted in the opposite of 'recenter' direction to keep them visually in place.
685 | private func recenterIfNeeded(allowThrottle: Bool, allowShiftTileViews: Bool) {
686 | guard allowRecenter > 0, (cachedNumberOfItems > 1 || singleItemBehavior == .tile) else {
687 | return
688 | }
689 |
690 | let currentOffsetX = internalScrollView.contentOffset.x
691 | let centerOffsetX = (internalScrollView.contentSize.width - internalScrollView.bounds.size.width) / 2.0
692 | let shiftX = centerOffsetX - currentOffsetX
693 | if !allowThrottle || abs(shiftX) > recenterThrottleDistance {
694 | allowTiling -= 1
695 | internalScrollView.contentOffset = CGPoint(x: centerOffsetX, y: 0)
696 | tileDoneForContentOffsetX += shiftX // sync throttling for tiling
697 | if allowShiftTileViews {
698 | for (_, tileView) in visibleTileViews {
699 | var tileFrame = tileView.frame
700 | tileFrame.origin.x += shiftX
701 | tileView.frame = tileFrame
702 | }
703 | recenteredZeroItemOffset += shiftX
704 | }
705 | allowTiling += 1
706 | }
707 | }
708 |
709 | private func updateVisibleTileViews(height newHeight: CGFloat) {
710 | visibleTileViews.forEach({ (_, tileView) in
711 | tileView.frame.size.height = newHeight
712 | })
713 | }
714 |
715 | /// Update scrollable area
716 | private func updateScroll() {
717 | internalScrollView.frame = bounds
718 | let contentWidth: CGFloat
719 | if cachedNumberOfItems == 1, singleItemBehavior != .tile {
720 | contentWidth = max(bounds.width, cachedItemsTotalWidth)
721 | } else {
722 | contentWidth = scrollableContentWidth
723 | }
724 | internalScrollView.contentSize = CGSize(width: contentWidth, height: bounds.height)
725 | contentContainerView.frame = CGRect(x: 0, y: 0, width: contentWidth, height: bounds.height)
726 | }
727 |
728 | /// Special case for single item in data source when tiling is not allowed
729 | private func setupSingleItemIfNeeded() {
730 | guard cachedNumberOfItems == 1, singleItemBehavior != .tile, let strongDataSource = dataSource else {
731 | return
732 | }
733 | // add single tile
734 | let contentSize = contentContainerView.bounds.size
735 | let itemIndex: Int = 0
736 | let itemWidth = cachedItemWidths[itemIndex]
737 | let tileView: UIView
738 | if let oldTileView = visibleTileViews[itemIndex] {
739 | tileView = oldTileView
740 | } else {
741 | let newTileFrame = CGRect(x: 0, y: 0, width: itemWidth, height: contentSize.height)
742 | let newTileView = strongDataSource.infinityScrollView(self, viewForItemAtIndex: itemIndex, size: newTileFrame.size)
743 | newTileView.frame = newTileFrame
744 | contentContainerView.addSubview(newTileView)
745 | visibleTileViews[itemIndex] = newTileView
746 | tileView = newTileView
747 | }
748 | tileView.frame = CGRect(x: (contentSize.width - itemWidth) / 2.0,
749 | y: 0,
750 | width: itemWidth,
751 | height: contentSize.height)
752 | }
753 |
754 | /// this function assumes, that views for items was already tiled in advance.
755 | ///
756 | /// - parameter targetOffsetX: offset for internal scroll view to look nearst item around.
757 | /// - returns:
758 | /// `nil` if there are no items.
759 | /// Descriptive data about nearest item/tile.
760 | /// We are aiming to place center of item to the center of visible area.
761 | private func nearestVisibleCenterItem(targetOffsetX: CGFloat) -> NearestVisibleCenterItemData? {
762 | let screenHalfWidth = bounds.width / 2.0
763 | let targetCenterX = targetOffsetX + screenHalfWidth
764 | var nearestTile: Dictionary.Element?
765 | var nearestDistance: CGFloat = .infinity
766 | for tile in visibleTileViews {
767 | let distance = abs(tile.value.frame.origin.x + (tile.value.frame.size.width / 2.0) - targetCenterX)
768 | if distance < nearestDistance {
769 | nearestTile = tile
770 | nearestDistance = distance
771 | }
772 | }
773 |
774 | guard let foundElement = nearestTile else {
775 | return nil
776 | }
777 |
778 | let tileCenterX = foundElement.value.frame.origin.x + foundElement.value.frame.size.width / 2.0
779 | return NearestVisibleCenterItemData(anchorOffsetX: tileCenterX - screenHalfWidth ,
780 | tileCenterX: tileCenterX,
781 | itemIndex: itemIndex(tileIndex: foundElement.key),
782 | tileIndex: foundElement.key)
783 | }
784 |
785 | private func startSnapAnimation(initialVelocityX: CGFloat, targetContentOffset: CGPoint) {
786 | stopSnapAnimation()
787 |
788 | let fromBounds = internalScrollView.bounds
789 | let toBounds = CGRect(x: targetContentOffset.x, y: targetContentOffset.y, width: fromBounds.width, height: fromBounds.height)
790 |
791 | switch snapAnimation {
792 | case .none,
793 | .scrollView:
794 | // should not be here - these two settings handled by `scrollViewWillEndDragging(:withVelocity:targetContentOffset:)`
795 | stopSnapAnimation()
796 |
797 | case .curve(let duration, let name):
798 | let animation = CABasicAnimation(keyPath: "bounds")
799 | animation.duration = duration
800 | animation.timingFunction = CAMediaTimingFunction(name: name)
801 | animation.fromValue = NSValue(cgRect: fromBounds)
802 | animation.toValue = NSValue(cgRect: toBounds)
803 | animation.isRemovedOnCompletion = true
804 | animation.delegate = self
805 | animation.setValue(Constant.snapAnimationNameValue, forKey: Constant.snapAnimationNameKey)
806 | decelerateAnimation = animation
807 | internalScrollView.layer.bounds = toBounds
808 | internalScrollView.layer.add(animation, forKey: Constant.snapAnimationKey)
809 |
810 | case .spring(let mass, let stiffness, let damping):
811 | let animation = CASpringAnimation(keyPath: "bounds")
812 | animation.mass = mass
813 | animation.stiffness = stiffness
814 | animation.damping = damping
815 | animation.initialVelocity = abs(initialVelocityX)
816 | animation.fromValue = NSValue(cgRect: fromBounds)
817 | animation.toValue = NSValue(cgRect: toBounds)
818 | animation.duration = animation.settlingDuration
819 | animation.isRemovedOnCompletion = true
820 | animation.delegate = self
821 | animation.setValue(Constant.snapAnimationNameValue, forKey: Constant.snapAnimationNameKey)
822 | decelerateAnimation = animation
823 | internalScrollView.layer.bounds = toBounds
824 | internalScrollView.layer.add(animation, forKey: Constant.snapAnimationKey)
825 | }
826 | }
827 |
828 | private func stopSnapAnimation() {
829 | guard internalScrollView.layer.animation(forKey: Constant.snapAnimationKey) != nil else {
830 | return
831 | }
832 | if let presentationLayer = internalScrollView.layer.presentation() {
833 | internalScrollView.layer.bounds = presentationLayer.bounds
834 | // NOTE: There is a slight but noticeable glitch:
835 | // - next frame is rendered as if animation is still going
836 | // - frame after next is rendered with "stopped" bounds
837 | }
838 | internalScrollView.layer.removeAnimation(forKey: Constant.snapAnimationKey)
839 | }
840 |
841 | private func swipeDirection(startIndex: Int, endIndex: Int) -> SwipeDirection {
842 | if startIndex == endIndex {
843 | return .none
844 | } else if startIndex > endIndex {
845 | return .left
846 | } else {
847 | return .right
848 | }
849 | }
850 |
851 | @objc private func handleSingleTouchDownGestureRecognized(_ gestureRecognizer: UIGestureRecognizer) {
852 | stopSnapAnimation()
853 | }
854 |
855 | @objc private func handleTapGestureRecognized(_ gestureRecognizer: UIGestureRecognizer) {
856 | let point = gestureRecognizer.location(in: internalScrollView)
857 | // not using `nearestVisibleCenterItem()` here: that function is heavier and adjusts for center of visible area
858 | var nearestTile: Dictionary.Element?
859 | var nearestDistance: CGFloat = .infinity
860 | for tile in visibleTileViews {
861 | let distance = abs(tile.value.frame.origin.x + (tile.value.frame.size.width / 2.0) - point.x)
862 | if distance < nearestDistance {
863 | nearestTile = tile
864 | nearestDistance = distance
865 | }
866 | }
867 | guard let foundElement = nearestTile else {
868 | return
869 | }
870 | let index = itemIndex(tileIndex: foundElement.key)
871 | delegate?.infinityScrollView(self, didSelectItemAtIndex: index)
872 | }
873 |
874 | }
875 |
--------------------------------------------------------------------------------
/Source/SingleTouchDownGestureRecognizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // All credits go to: https://stackoverflow.com/a/15629234
3 | //
4 |
5 | import Foundation
6 | import UIKit
7 |
8 | open class SingleTouchDownGestureRecognizer: UIGestureRecognizer {
9 |
10 | open override func touchesBegan(_ touches: Set, with event: UIEvent) {
11 | if state == .possible {
12 | state = .recognized
13 | }
14 | }
15 |
16 | open override func touchesMoved(_ touches: Set, with event: UIEvent) {
17 | state = .failed
18 | }
19 |
20 | open override func touchesEnded(_ touches: Set, with event: UIEvent) {
21 | state = .failed
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------