UITableView
iOS Development
Infinite Scrolling
Load More
Swift Programming

UITableView load more when scrolling to bottom like Facebook application

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Infinite scrolling lets users browse large datasets without tapping a "next page" button. As the user scrolls near the bottom of a UITableView, the app fetches the next page of results and appends them to the list. This pattern is used in apps like Facebook, Twitter, and Instagram to create a seamless content feed experience.

Detecting the Scroll Position

The core of infinite scrolling is detecting when the user has scrolled close to the bottom of the table view. Since UITableView inherits from UIScrollView, you can use the scrollViewDidScroll delegate method to monitor the current scroll offset.

swift
1class FeedViewController: UIViewController, UITableViewDelegate,
2    UITableViewDataSource, UIScrollViewDelegate {
3
4    var items: [String] = []
5    var isLoading = false
6    var currentPage = 1
7    var hasMoreData = true
8
9    @IBOutlet weak var tableView: UITableView!
10
11    override func viewDidLoad() {
12        super.viewDidLoad()
13        tableView.delegate = self
14        tableView.dataSource = self
15        loadData(page: currentPage)
16    }
17
18    func scrollViewDidScroll(_ scrollView: UIScrollView) {
19        let offsetY = scrollView.contentOffset.y
20        let contentHeight = scrollView.contentSize.height
21        let frameHeight = scrollView.frame.size.height
22
23        if offsetY > contentHeight - frameHeight - 100 {
24            loadMoreData()
25        }
26    }
27}

The threshold of 100 points triggers the load before the user reaches the absolute bottom, creating a smoother experience. Adjust this value based on row height and how far ahead you want to prefetch.

Implementing Pagination Logic

The loadMoreData method checks guard conditions before making a network request. This prevents duplicate fetches and unnecessary calls when all data has been loaded.

swift
1func loadMoreData() {
2    guard !isLoading, hasMoreData else { return }
3
4    isLoading = true
5    currentPage += 1
6    loadData(page: currentPage)
7}
8
9func loadData(page: Int) {
10    let url = URL(string: "https://api.example.com/feed?page=\(page)&limit=20")!
11
12    URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
13        guard let self = self,
14              let data = data,
15              error == nil else {
16            DispatchQueue.main.async { self?.isLoading = false }
17            return
18        }
19
20        do {
21            let newItems = try JSONDecoder().decode([String].self, from: data)
22
23            if newItems.count < 20 {
24                self.hasMoreData = false
25            }
26
27            DispatchQueue.main.async {
28                self.items.append(contentsOf: newItems)
29                self.tableView.reloadData()
30                self.isLoading = false
31            }
32        } catch {
33            DispatchQueue.main.async { self.isLoading = false }
34        }
35    }.resume()
36}

The hasMoreData flag is set to false when the server returns fewer items than the requested page size, indicating no more pages exist. The isLoading flag prevents scrollViewDidScroll from triggering multiple simultaneous requests.

A spinner in the table footer tells the user that more content is loading. This provides visual feedback and prevents confusion when the user reaches the end of the current content.

swift
1func createLoadingFooter() -> UIView {
2    let footerView = UIView(frame: CGRect(x: 0, y: 0,
3                                           width: tableView.frame.width,
4                                           height: 50))
5    let spinner = UIActivityIndicatorView(style: .medium)
6    spinner.center = footerView.center
7    spinner.startAnimating()
8    footerView.addSubview(spinner)
9    return footerView
10}
11
12func loadMoreData() {
13    guard !isLoading, hasMoreData else { return }
14
15    isLoading = true
16    tableView.tableFooterView = createLoadingFooter()
17    currentPage += 1
18    loadData(page: currentPage)
19}

Remove the footer view once loading completes by setting tableView.tableFooterView = nil inside the data task completion handler.

Inserting Rows Without Full Reload

Calling reloadData() after every page load causes the table to flash and resets the scroll position in some cases. Using insertRows(at:with:) provides a smoother animation and preserves the current scroll state.

swift
1func loadData(page: Int) {
2    // ... network request ...
3
4    DispatchQueue.main.async {
5        let startIndex = self.items.count
6        self.items.append(contentsOf: newItems)
7        let endIndex = self.items.count
8
9        let indexPaths = (startIndex..<endIndex).map {
10            IndexPath(row: $0, section: 0)
11        }
12
13        self.tableView.performBatchUpdates({
14            self.tableView.insertRows(at: indexPaths, with: .automatic)
15        }, completion: { _ in
16            self.isLoading = false
17            self.tableView.tableFooterView = nil
18        })
19    }
20}

performBatchUpdates wraps the insertion in an animation block. The .automatic animation style lets UIKit choose the most appropriate transition.

Adding Pull-to-Refresh

Pull-to-refresh lets the user reload the feed from the beginning. This complements infinite scrolling by providing a way to see new content that was posted after the initial load.

swift
1override func viewDidLoad() {
2    super.viewDidLoad()
3
4    let refreshControl = UIRefreshControl()
5    refreshControl.addTarget(self, action: #selector(handleRefresh),
6                             for: .valueChanged)
7    tableView.refreshControl = refreshControl
8
9    loadData(page: currentPage)
10}
11
12@objc func handleRefresh() {
13    currentPage = 1
14    hasMoreData = true
15    items.removeAll()
16    tableView.reloadData()
17    loadData(page: currentPage)
18}

Inside the loadData completion handler, call tableView.refreshControl?.endRefreshing() to dismiss the spinner after the fresh data has loaded.

Offset-Based vs Cursor-Based Pagination

The examples above use page-based pagination. In production, cursor-based pagination is more reliable because it handles insertions and deletions between pages. Instead of a page number, the API returns a cursor token that points to the next batch.

swift
1var nextCursor: String? = nil
2
3func loadData(cursor: String?) {
4    var urlString = "https://api.example.com/feed?limit=20"
5    if let cursor = cursor {
6        urlString += "&cursor=\(cursor)"
7    }
8
9    let url = URL(string: urlString)!
10
11    URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
12        guard let self = self, let data = data else { return }
13
14        let response = try? JSONDecoder().decode(FeedResponse.self, from: data)
15
16        DispatchQueue.main.async {
17            if let newItems = response?.items {
18                self.items.append(contentsOf: newItems)
19            }
20            self.nextCursor = response?.nextCursor
21            self.hasMoreData = self.nextCursor != nil
22            self.tableView.reloadData()
23            self.isLoading = false
24        }
25    }.resume()
26}

With cursor pagination, duplicate items are avoided even when new content is added to the feed between page loads.

Common Pitfalls

  • Not guarding against duplicate requests: Without the isLoading flag, scrollViewDidScroll fires many times per scroll gesture, triggering dozens of simultaneous network requests for the same page.
  • Calling reloadData on a background thread: UIKit requires all UI updates on the main thread. Calling reloadData from a network callback without DispatchQueue.main.async causes crashes or visual glitches.
  • Forgetting to reset state on pull-to-refresh: If you do not reset currentPage, hasMoreData, and items when the user pulls to refresh, the next pagination fetch returns the wrong page.
  • Using a fixed threshold for all devices: A threshold of 100 points works on iPhones but may feel too early or too late on iPads with larger screens. Consider calculating the threshold as a percentage of the frame height.
  • Not handling empty or error states: When the network request fails, the loading indicator stays visible indefinitely. Always set isLoading = false and remove the footer in both success and error paths.

Summary

  • Detect scroll position in scrollViewDidScroll by comparing contentOffset.y against contentSize.height - frame.height minus a threshold.
  • Use isLoading and hasMoreData flags to prevent duplicate fetches and stop pagination when the server has no more data.
  • Display a UIActivityIndicatorView in the table footer view during loading for clear user feedback.
  • Use performBatchUpdates with insertRows(at:with:) instead of reloadData() for smooth row insertion without scroll jumps.
  • Combine infinite scrolling with UIRefreshControl for pull-to-refresh, and prefer cursor-based pagination over page numbers for production feeds.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.