Infinite Scroll on iOS with Swift

Swift - Infinite Scrolling for UIScrollView with an embedded UIStackView

Here is one way to do this...

Since the scroll view has paging enabled, we are able to have only 3 views "pages" in the scroll view at a time.

Assuming 4 total "pages"...

  • Start with pages 4, 1, 2
  • set scroll content offset x so the center page is visible
  • if we scroll to the next page, shift the views to 1, 2, 3 and again set scroll content offset x so the center page is visible
  • if we scroll to the previous page, shift the views to 3, 4, 1 and again set scroll content offset x so the center page is visible

This approach loads all "page" view controllers as child view controllers. If there we end up with, say, 20 "pages" - particularly if they are "heavy" (lots of subviews, code, etc) - we would want to load controllers only when we need to show them, and unload them when they're removed from the 3 "scroll slots."


struct MyPage Defines a page as a view controller and an index page number:

struct MyPage {
var vc: UIViewController!
var pageNumber: Int!
}

PagedScrollViewController class:

class PagedScrollViewController: UIViewController, UIScrollViewDelegate {

let scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
v.showsHorizontalScrollIndicator = false
// set clipsToBounds to false
// if we want to see the way the views are being cycled
v.clipsToBounds = true
return v
}()

let pageControl: UIPageControl = {
let v = UIPageControl()
return v
}()

let stack: UIStackView = {
let v = UIStackView()
v.axis = .horizontal
v.distribution = .fillEqually
return v
}()

var pages: [MyPage] = []

override func viewDidLoad() {
super.viewDidLoad()

scrollView.translatesAutoresizingMaskIntoConstraints = false
pageControl.translatesAutoresizingMaskIntoConstraints = false
stack.translatesAutoresizingMaskIntoConstraints = false

scrollView.addSubview(stack)
view.addSubview(scrollView)
view.addSubview(pageControl)

let g = view.safeAreaLayoutGuide
let svCLG = scrollView.contentLayoutGuide
let svFLG = scrollView.frameLayoutGuide

NSLayoutConstraint.activate([

// cover most of screen (with a little padding on each side)
//scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
//scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
//scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
//scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),

// small scroll view at top of screen
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
scrollView.heightAnchor.constraint(equalToConstant: 200.0),

stack.topAnchor.constraint(equalTo: svCLG.topAnchor, constant: 0.0),
stack.leadingAnchor.constraint(equalTo: svCLG.leadingAnchor, constant: 0.0),
stack.trailingAnchor.constraint(equalTo: svCLG.trailingAnchor, constant: 0.0),
stack.bottomAnchor.constraint(equalTo: svCLG.bottomAnchor, constant: 0.0),

stack.heightAnchor.constraint(equalTo: svFLG.heightAnchor, constant: 0.0),

pageControl.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 8.0),
pageControl.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
pageControl.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),

])

/*
// if we're loading "page" view controllers from Storyboard
var i = 0
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFirst") as? PSFirstViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psSecond") as? PSSecondViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psThird") as? PSThirdViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}
if let vc = storyboard?.instantiateViewController(withIdentifier: "psFourth") as? PSFourthViewController {
pages.append(MyPage(vc: vc, pageNumber: i))
i += 1
}

pages.forEach { pg in
self.addChild(pg.vc)
pg.vc.didMove(toParent: self)
}
*/

// for this example, we will
// create 4 "page" view controllers, with background colors
// (dark red, dark green, dark blue, brown)
let colors: [UIColor] = [
UIColor(red: 0.75, green: 0.00, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.75, blue: 0.00, alpha: 1.0),
UIColor(red: 0.00, green: 0.00, blue: 0.75, alpha: 1.0),
UIColor(red: 0.75, green: 0.50, blue: 0.00, alpha: 1.0),
]
for (c, i) in zip(colors, Array(0..<colors.count)) {
let vc = BasePageController()
vc.view.backgroundColor = c
vc.centerLabel.text = "\(i + 1)"
self.addChild(vc)
vc.didMove(toParent: self)
pages.append(MyPage(vc: vc, pageNumber: i))
}

// move last page to position Zero
pages.insert(pages.removeLast(), at: 0)
// add 3 pages to stack view in scroll view
pages[0...2].forEach { pg in
stack.addArrangedSubview(pg.vc.view)
pg.vc.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
}

scrollView.delegate = self

pageControl.numberOfPages = pages.count

pageControl.addTarget(self, action: #selector(self.pgControlChange(_:)), for: .valueChanged)

}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
scrollView.contentOffset.x = scrollView.frame.size.width
}

// flag so we don't infinite loop on scrolling and setting page control current page
var pgControlScroll: Bool = false

@objc func pgControlChange(_ sender: UIPageControl) {
let w = scrollView.frame.size.width
guard w != 0 else { return }
// get the middle page
let pg = pages[1]
// unwrap current page number in scroll view
guard let cp = pg.pageNumber else { return }
// set the flag
pgControlScroll = true
// next page based on page control page number
let np = sender.currentPage
var r = CGRect.zero
if np > cp {
r = CGRect(x: w * 3.0 - 1.0, y: 0.0, width: 1.0, height: 1.0)
// next page is to the right
} else {
// next page is to the left
r = CGRect(x: 0.0, y: 0, width: 1, height: 1)
}
// need to manually animate the scroll, so we can update our page order when scroll finishes
UIView.animate(withDuration: 0.3, animations: {
self.scrollView.scrollRectToVisible(r, animated: false)
}, completion: { _ in
self.updatePages(self.scrollView)
})
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// turn off the flag
pgControlScroll = false
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
// get the "page" based on scroll offset x
let pgID = min(Int(round(x / w)), pages.count - 1)
let pg = pages[pgID]
guard let v = pg.vc.view else { return }
pageControl.backgroundColor = v.backgroundColor
// don't set the pageControl's pageNumber if we scrolled as a result of tapping the page control
if pgControlScroll { return }
pageControl.currentPage = pg.pageNumber
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updatePages(scrollView)
}
func updatePages(_ scrollView: UIScrollView) -> Void {
let w = scrollView.frame.size.width
guard w != 0 else { return }
let x = scrollView.contentOffset.x
if x == 0 {
// we've scrolled to the left
// move last page to position Zero
guard let pg = pages.last,
let v = pg.vc.view else { return }
// remove the last arranged subview
stack.arrangedSubviews.last?.removeFromSuperview()
// insert last "page" view as first arranged subview
stack.insertArrangedSubview(v, at: 0)
// set its width anchor
v.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
// move last page to first position in array
pages.insert(pages.removeLast(), at: 0)
} else if x == scrollView.frame.size.width * 2 {
// we've scrolled right
// move first page to last position in array
pages.append(pages.removeFirst())
// get the next page
let pg = pages[2]
guard let v = pg.vc.view else { return }
// remove first arranged subview
stack.arrangedSubviews.first?.removeFromSuperview()
// add next page view to stack
stack.addArrangedSubview(v)
// set its width anchor
v.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0).isActive = true
}
scrollView.contentOffset.x = scrollView.frame.size.width
}

}

BasePageController example "page" class:

class BasePageController: UIViewController {

let centerLabel: UILabel = {
let v = UILabel()
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.font = UIFont.systemFont(ofSize: 48.0, weight: .bold)
v.textAlignment = .center
return v
}()

override func viewDidLoad() {
super.viewDidLoad()

// add a label at each corner
for (i, s) in ["top-left", "top-right", "bot-left", "bot-right"].enumerated() {
let v = UILabel()
v.backgroundColor = UIColor(white: 0.8, alpha: 1.0)
v.translatesAutoresizingMaskIntoConstraints = false
v.text = s
view.addSubview(v)
let g = view.safeAreaLayoutGuide
switch i {
case 1:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
case 2:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
case 3:
v.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0).isActive = true
v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0).isActive = true
default:
v.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0).isActive = true
v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0).isActive = true
}
}
centerLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(centerLabel)
NSLayoutConstraint.activate([
centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}

}

Notes:

This is example code only and should not be considered "production ready."

If you have only 1 or 2 "pages" this will crash.

If you try this with a couple dozen "pages" you'll likely hit memory problems.


Edit based on comments...

Took a look at your project, and I see you're using a UICollectionView instead.

I think the issue is that you're mixing / matching your viewModel.pages - which has 4 elements, and your itemsWithBoundries - which has 6 elements. Trying to reconcile that is pretty messy.

So... I'm going to suggest a different, older approach.

For the collection view's numberOfItemsInSection, I'm going to return 10,000.

In cellForItemAt, I'll use indexPath.item % viewModel.pages.count (the remainder / modulo operator) to return a cell in the viewModel.pages[0...3] range.

Same idea in scrollViewDidScroll ... get the actual cell item index % number of pages to get Zero thru 3.

To achieve "infinite scrolling" in both directions, I'll start with scrolling the collection view to item 5,000 (code includes adjusting that if the number of pages is not equally divisible into 5,000). It's pretty unlikely a user would scroll 5,000 pages in either direction to reach the "end."

I edited your Test App with that approach and posted it to GitHub: https://github.com/DonMag/Test-App so you can see the changes I made.

Infinite scroll tableView, swift

Add completion handler in loadMoreData() method & use a boolean check for call like below -

var isLoadingStarted = true

func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height

if offsetY > contentHeight - scrollView.frame.height {

if vievModel.!paginationFinished && isLoadingStarted {
isLoadingStarted = false
self.viewModel.loadMoreData {
self.tableView.reloadData()
//self.isLoadingStarted = true
}

}
}
}

//Handle loadMoreData method as below
static func loadMoreData(_ , completion:@escaping (() -> Void)) {
.
.
.
completion()

}

Use scroll view delegate to detect begin dragging and set flag isLoadingStarted there -

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.isLoadingStarted = true
}

Try it once.

swift infinite scroll up collectionView

The result you are describing is expected. Since you scrolled to top the content offset is at zero. And when you added new items the old ones were pushed to the bottom. It will not happen the other way around; when you scroll to bottom and add items beneath them the scroll position stays the same.

To be honest it is all about perspective. The position actually stays the same in both cases (or none) but depends on how you look at it. When you append items at bottom the position should stay the same looking from top (which is a default scenario). But when you add items at top the position stays the same from bottom.

So the solution in your case is that when you add items at the bottom you should change scroll from top:

let distanceFromTop = scrollView.contentOffset.y - 0.0;
// Reload data here
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: 0.0 + distanceFromTop)

which basically means "do nothing at all".

But when you are appending items at the top you need to compute it from bottom:

let distanceFromBottom = scrollView.contentSize.height - scrollView.contentOffset.y;
// Reload data here
scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentSize.height - distanceFromBottom)

You should note that you can do this sideways as well. Appending items on the right side would again be transparent. But adding them on the left should be like:

let distanceFromRight = scrollView.contentSize.width - scrollView.contentOffset.x;
// Reload data here
scrollView.contentOffset = CGPoint(x: scrollView.contentSize.width - distanceFromRight, y: scrollView.contentOffset.y)

I am using scrollView as this happens the same to every subclass of it. The same logic can be applied to table view or collection view.

Another note here is that estimated row heights or similar functionalities may cause anomalies when not done correctly. In such cases you may need to compute the offset a bit more smartly.

how to make a collection view to scroll horizontally having infinite scroll in both directions left, as well as right in iOS swift

You can do it using "scrollToItemAtIndexPath" method. Swipe direction you can get from scrollview delegate methods.

Scroll right to left

collectionView?.scrollToItemAtIndexPath(NSIndexPath(forItem: dataArray.count - 1, inSection: 0), atScrollPosition: .Right, animated: false)

Scroll left to right

collectionView?.scrollToItemAtIndexPath(NSIndexPath(forItem: 0, inSection: 0), atScrollPosition: .left, animated: false)

You can also apply a transformation on collection view to get similar.

swift infinite scrolling for loading previous chat history

I'd use scroll offset to decide when I should fire a new load request to have seamlessly scrolling experience. For example, you may want to fire a load more request whenever the distance from your current scroll position to edges reaches a distance equal k * SCREEN_HEIGHT. You should adjust the k to meet your need.

This is a very simple implemetation of the idea:

let fireRequestDistance = UIScreen.main.bounds.height
let numberOfItemsPerRequest = 50

class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!

var items: [Int] = Array(0...100)

var isLoadingNext: Bool = false
var isLoadingPrev: Bool = false

override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.reloadData()
tableView.scrollToRow(at: IndexPath(row: items.count / 2, section: 0), at: .middle, animated: false)
}
}

extension ViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = "\(items[indexPath.row])"
return cell
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !isLoadingNext, scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - fireRequestDistance {
isLoadingNext = true
print("Loading next")
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
let max = self.items.last!
let newItems = Array((max + 1)...(max + numberOfItemsPerRequest))
let indexPaths = (self.items.count..<(self.items.count + numberOfItemsPerRequest)).map { IndexPath(row: $0, section: 0) }
self.items += newItems
self.tableView.insertRows(at: indexPaths, with: .none)
self.isLoadingNext = false
}
}

if !isLoadingPrev, scrollView.contentOffset.y < fireRequestDistance {
isLoadingPrev = true
print("Loading prev")
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
let min = self.items.first!
let newItems = Array((min - numberOfItemsPerRequest)..<min)
let indexPaths = (0..<numberOfItemsPerRequest).map { IndexPath(row: $0, section: 0) }
self.items = newItems + self.items
self.tableView.insertRows(at: indexPaths, with: .none)
self.isLoadingPrev = false
}
}
}
}



Related Topics



Leave a reply



Submit