Implementing pagination in iOS UITableView/UICollectionView using Swift 5 iOS 07.07.2020

UITableView

Imagine, we already have an UITableView filled with data, and we want to add the Load More/Infinite Scrolling feature.

First, we create an UITableViewCell with a Xib file and add an UIActivityIndicatorView on the center of the cell.

Control+left click and drag the UIActivityIndicatorView into your .swift file and give the name activityIndicator and add loadingCell as Identifier.

class LoadingCell: UITableViewCell {
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!

    override func awakeFromNib() {
        super.awakeFromNib()
        self.activityIndicator.hidesWhenStopped = true
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
}

Now, let’s go into our LoadMoreViewController where we have the UITableView, and register the Loading Cell in the viewDidLoad().

class LoadMoreViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    var items: [String] = []
    var cellId = "itemCell"
    var cellLoadingId = "loadingCell"
    var isLoading = false

    let tbl: UITableView = {
        let tv = UITableView()
        tv.backgroundColor = UIColor.white
        tv.translatesAutoresizingMaskIntoConstraints = false
        return tv
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTable()
    }

    func setupTable() {
        tbl.delegate = self
        tbl.dataSource = self
        tbl.register(UITableViewCell.self, forCellReuseIdentifier: cellId)
        let loadingCellNib = UINib(nibName: "LoadingCell", bundle: nil)
        tbl.register(loadingCellNib, forCellReuseIdentifier: cellLoadingId)

        items.append(contentsOf: (1...20).map { index in "Item \(index)"})

        view.addSubview(tbl)

        NSLayoutConstraint.activate([
            tbl.topAnchor.constraint(equalTo: self.view.topAnchor),
            tbl.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
            tbl.rightAnchor.constraint(equalTo: self.view.rightAnchor),
            tbl.leftAnchor.constraint(equalTo: self.view.leftAnchor)
        ])
    }

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

        if (offsetY > contentHeight - scrollView.frame.height * 4) && !isLoading {
            loadMoreData()
        }
    }

    func loadMoreData() {
        if !self.isLoading {
            self.isLoading = true
            DispatchQueue.global().async {
                sleep(2)
                let start = self.items.count
                let end = start + 10

                if end < 40 {
                    self.items.append(contentsOf: (start...end).map { index in "Item \(index)"})
                    DispatchQueue.main.async {
                        self.tbl.reloadData()
                        self.isLoading = false
                    }
                } else {
                    DispatchQueue.main.async {
                        self.isLoading = false
                        self.tbl.reloadSections(IndexSet(integer: 1), with: .automatic)
                    }
                }
            }
        }
    }
}

extension LoadMoreViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if section == 0 {
            return items.count
        } else if section == 1 {
            // loading cell
            return 1
        } else {
            return 0
        }
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        return 2
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if indexPath.section == 0 {
            let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
            cell.textLabel?.text = items[indexPath.row]
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withIdentifier: cellLoadingId, for: indexPath) as! LoadingCell
            if self.isLoading {
                cell.activityIndicator.startAnimating()
            } else {
                cell.activityIndicator.stopAnimating()
            }
            return cell
        }
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        if indexPath.section == 0 {
            return 44
        } else {
            return 55 // loading cell
        }
    }   
}

We use the method scrollViewDidScroll, from the embedded UIScrollView in the UITableView, to detect when the user is close to the end of the list to call loadMoreData method.

UICollectionView

Steps to add Load More/Infinite Scrolling feature to UICollectionView.

  1. Create UICollectionViewCell and register in UICollectionView.
  2. Create UICollectionReusableView and register in UICollectionView.
  3. In the UICollectionView‘s delegate and datasource, add the referenceSizeForFooterInSection method to return the size for the Loading View when it’s time to show it.
  4. Set the reusable view in the UICollectionView footer.
class CollectionViewItemCell: UICollectionViewCell {}

class LoadingReusableView: UICollectionReusableView {
    let activityIndicator: UIActivityIndicatorView = {
        let av = UIActivityIndicatorView(style: .medium)
        av.hidesWhenStopped = true
        av.translatesAutoresizingMaskIntoConstraints = false
        return av
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setupUI()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        self.setupUI()
    }

    func setupUI() {
        self.addSubview(activityIndicator)
        activityIndicator.startAnimating()
        NSLayoutConstraint.activate([
            activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
            activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor)
        ])
    }
}

class LoadMoreViewController: UIViewController {

    var items: [UIColor] = []
    var cellId = "cellId"
    var cellLoadingId = "cellLoadingId"
    var isLoading = false

    var loadingView: LoadingReusableView?

    lazy var colorsCollection: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical

        let cl = UICollectionView(frame: .zero, collectionViewLayout: layout)
        cl.backgroundColor = .white
        cl.translatesAutoresizingMaskIntoConstraints = false
        cl.dataSource = self
        cl.delegate = self
        cl.register(CollectionViewItemCell.self, forCellWithReuseIdentifier: cellId)
        cl.register(LoadingReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: cellLoadingId)
        return cl
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        loadMoreData()
        setupCollection()
    }

    func setupCollection() {
        view.addSubview(colorsCollection)

        NSLayoutConstraint.activate([
            colorsCollection.topAnchor.constraint(equalTo: self.view.topAnchor),
            colorsCollection.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
            colorsCollection.rightAnchor.constraint(equalTo: self.view.rightAnchor),
            colorsCollection.leftAnchor.constraint(equalTo: self.view.leftAnchor)
        ])
    }

    func getRandomColor() -> UIColor {
        let red: CGFloat = CGFloat(drand48())
        let green: CGFloat = CGFloat(drand48())
        let blue: CGFloat = CGFloat(drand48())
        return UIColor(red: red, green: green, blue: blue, alpha: 1.0)
    }

    func loadMoreData() {
        if !self.isLoading {
            self.isLoading = true
            DispatchQueue.global().async {
                // Fake background loading task for 2 seconds
                sleep(2)
                let start = self.items.count
                let end = start + 30

                if end < 100 {
                    self.items.append(contentsOf: (start...end).map { index in self.getRandomColor() })
                    DispatchQueue.main.async {
                        self.isLoading = false
                        self.colorsCollection.reloadData()
                    }
                } else {
                    DispatchQueue.main.async {
                        self.isLoading = false
                        self.colorsCollection.reloadData()
                    }
                }
            }
        }
    }
}

extension LoadMoreViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! CollectionViewItemCell
        cell.backgroundColor = self.items[indexPath.row]
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 100, height: 100)
    }

    // size of Loading View
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        if self.isLoading {
            return CGSize.zero
        } else {
            return CGSize(width: collectionView.bounds.size.width, height: 55)
        }
    }

    // set the Loading View
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        if kind == UICollectionView.elementKindSectionFooter {
            let aFooterView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: cellLoadingId, for: indexPath) as! LoadingReusableView
            loadingView = aFooterView
            loadingView?.backgroundColor = UIColor.clear
            return aFooterView
        }
        return UICollectionReusableView()
    }

    // start and stop the activityIndicator
    func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) {
        if elementKind == UICollectionView.elementKindSectionFooter {
            if self.isLoading {
                self.loadingView?.activityIndicator.startAnimating()
            } else {
                self.loadingView?.activityIndicator.stopAnimating()
            }
        }
    }

    func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) {
        if elementKind == UICollectionView.elementKindSectionFooter {
            self.loadingView?.activityIndicator.stopAnimating()
        }
    }

    // prefetch
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        if indexPath.row == items.count - 10 && !self.isLoading {
            loadMoreData()
        }
    }
}