📜 ⬆️ ⬇️

Infinite UIScrollView

image

In many applications, you may encounter a scrolling that never shifts in the opposite direction at the end of the content. This technique has been standard for many years, on many platforms. On the other hand, there are many third-party libraries to get this effect. BUT you don't need any third-party library. This technique has very simple logic.

Page-by-page support for UIScrollView allows the user to view its contents page by page. UIScrollView turns on this effect by adjusting the scrollView offset when the user finishes dragging. When the user scrolls to the end of the pages (on the right), the scrollview limits the excess of its content by moving its offset in the opposite direction with a nice animation.

image
')
We want the scrollview to not limit the content offset when the user wants to exceed their number. Therefore, we need to add two more pages to the UIScrollView. The last page will be added to the zero index, and the first page will be added to the index (numberOfItems + 1). Then, if the user views the “numberOfItems” page, the scroll x content offset is set to 0. If the user views the index 0, then the scrollView x content offset will be set to “pageSize * numberOfItems”.

image

The first thing to do is create a new class inherited from UIView.

image

BannerView should be as below:

import UIKit class BannerView: UIView { override init(frame: CGRect) { super.init(frame: frame) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 

There is nothing unusual here. Now we need to add the scrollView and setUp code for BannerView:

 import UIKit class BannerView: UIView { private let scrollView:UIScrollView = { let sc = UIScrollView(frame: .zero) sc.translatesAutoresizingMaskIntoConstraints = false sc.isPagingEnabled = true return sc }() // BannerView DataSources (1) private var itemAtIndex:((_ bannerView:BannerView , _ index:Int)->(UIView))! private var numberOfItems:Int = 0 override init(frame: CGRect) { super.init(frame: frame) setUpUI() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setUpUI() { scrollView.frame = CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height) scrollView.delegate = self self.addSubview(scrollView) scrollView.showsHorizontalScrollIndicator = false } func reloadData(numberOfItems:Int , itemAtIndex:@escaping ((_ bannerView:BannerView , _ index:Int)->(UIView)) ) { self.itemAtIndex = itemAtIndex self.numberOfItems = numberOfItems reloadScrollView() } private func reloadScrollView() { guard self.numberOfItems > 0 else { return } if self.numberOfItems == 1 { let firstItem:UIView = self.itemAtIndex(self , 0) addViewToIndex(view: firstItem, index: 0) scrollView.isScrollEnabled = false return } let firstItem:UIView = self.itemAtIndex(self , 0) addViewToIndex(view: firstItem, index: numberOfItems+1) let lastItem:UIView = self.itemAtIndex(self , numberOfItems-1) addViewToIndex(view: lastItem, index: 0) for index in 0..<self.numberOfItems { let item:UIView = self.itemAtIndex(self , index) addViewToIndex(view: item, index: index+1) } scrollView.contentSize = CGSize(width: CGFloat(numberOfItems+2)*scrollView.frame.size.width, height: scrollView.frame.size.height) scrollView.contentOffset = CGPoint(x: self.scrollView.frame.size.width, y: self.scrollView.contentOffset.y) } private func addViewToIndex(view:UIView, index:Int) { view.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(view) view.frame = CGRect(x: CGFloat(index)*scrollView.frame.size.width, y: 0, width: scrollView.frame.size.width, height: scrollView.frame.size.height) } } 

I used framed instead of automatic layout for simplicity. In addition, I used closures instead of delegates. This helps to avoid dirt in the ViewController. With closures, you can simply use bannerView as follows:

 // ViewController.swift bannerView = BannerView(frame: CGRect(x: 0, y: 64, width: self.view.frame.size.width, height: 200)) self.view.addSubview(bannerView) bannerView.reloadData(numberOfItems: 5) { (bannerView, index) -> (UIView) in let view = UIView() view.backgroundColor = UIColor.red return view } 

To delegate a UIScrollView, I will use scrollViewDidEndDecelerating (_ scrollView: UIScrollView) instead of scrollViewDidScroll (_ scrollView: UIScrollView). Because we do not need to calculate the swap position every time the scrollView moves.

 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let currentPage:Int = Int(scrollView.contentOffset.x / scrollView.frame.size.width) if currentPage == 0 { self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width * CGFloat(numberOfItems), y: scrollView.contentOffset.y) } else if currentPage == numberOfItems { self.scrollView.contentOffset = CGPoint(x: 0, y: scrollView.contentOffset.y) } } 

And finally, our code will be like this for BannerView.swift:

 // BannerView.swift import UIKit class BannerView: UIView , UIScrollViewDelegate{ private let scrollView:UIScrollView = { let sc = UIScrollView(frame: .zero) sc.translatesAutoresizingMaskIntoConstraints = false sc.isPagingEnabled = true return sc }() private var itemAtIndex:((_ bannerView:BannerView , _ index:Int)->(UIView))! private var numberOfItems:Int = 0 override init(frame: CGRect) { super.init(frame: frame) setUpUI() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func reloadData(configuration:BannerViewConfiguration? , numberOfItems:Int , itemAtIndex:@escaping ((_ bannerView:BannerView , _ index:Int)->(UIView)) ) { self.itemAtIndex = itemAtIndex self.numberOfItems = numberOfItems reloadScrollView() } private func reloadScrollView() { guard self.numberOfItems > 0 else { return } if self.numberOfItems == 1 { let firstItem:UIView = self.itemAtIndex(self , 0) addViewToIndex(view: firstItem, index: 0) scrollView.isScrollEnabled = false return } let firstItem:UIView = self.itemAtIndex(self , 0) addViewToIndex(view: firstItem, index: numberOfItems+1) let lastItem:UIView = self.itemAtIndex(self , numberOfItems-1) addViewToIndex(view: lastItem, index: 0) for index in 0..<self.numberOfItems { let item:UIView = self.itemAtIndex(self , index) addViewToIndex(view: item, index: index+1) } scrollView.contentSize = CGSize(width: CGFloat(numberOfItems+2)*scrollView.frame.size.width, height: scrollView.frame.size.height) scrollView.contentOffset = CGPoint(x: self.scrollView.frame.size.width, y: self.scrollView.contentOffset.y) } private func addViewToIndex(view:UIView, index:Int) { view.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(view) view.frame = CGRect(x: CGFloat(index)*scrollView.frame.size.width, y: 0, width: scrollView.frame.size.width, height: scrollView.frame.size.height) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let currentPage:Int = Int(scrollView.contentOffset.x / scrollView.frame.size.width) if currentPage == 0 { self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width * CGFloat(numberOfItems), y: scrollView.contentOffset.y) } else if currentPage == numberOfItems { self.scrollView.contentOffset = CGPoint(x: 0, y: scrollView.contentOffset.y) } } private func setUpUI() { scrollView.frame = CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height) scrollView.delegate = self self.addSubview(scrollView) scrollView.showsHorizontalScrollIndicator = false } } 

image

Total


Thus, we made a reusable scrollview component with a little logic. By the way, with huge amounts of data it is better to use UICollectionView, because it has better performance and better memory management than UIScrollView. In addition, you can expand InfiniteScrollView using synchronization options or bi-directional scrolling. With a slight improvement, this will be a truly reusable tool for your applications.

→ Full source code can be found on github

Source: https://habr.com/ru/post/453782/


All Articles