iosNudge

Infinite Scroll List

MediumSwiftUISwiftUIConcurrencyNetworkingArchitecture 45 min read

Build a list that loads items page by page. When the user gets close to the end, load the next page. Use an @Observable view model. A fake service is in starter.swift.

protocol ItemService: Sendable {
    func fetchItems(page: Int, pageSize: Int) async throws -> ItemPage
}

struct ItemPage: Sendable {
    let items: [Item]
    let hasMore: Bool   // false on the last page
}

// FakeItemService: 95 items, waits 0.8 s, fails 20% of the time.

Example flow

  1. The screen opens. A spinner fills the screen.
  2. Page 0 (items 1–20) arrives. The list shows them.
  3. The user scrolls. At item 16 the app starts loading page 1. A small spinner shows at the bottom.
  4. Page 3 fails. The bottom row shows the error and a "Retry" button. The old items stay.
  5. After item 95 the bottom row says "You have seen everything." No more requests.

Requirements

  • Load the first page when the screen opens. Load it only once, even if the view appears again.
  • Start loading the next page before the last row, for example 5 rows before.
  • Never run two page requests at the same time.
  • Stop asking for pages when hasMore is false.
  • Full-screen states when the list is empty:
    • loading: a spinner
    • error: a message and a "Try Again" button
    • empty: "No Items Yet"
  • Bottom-row states when the list has items:
    • loading: a small spinner
    • error: a short message and a "Retry" button
    • finished: "You have seen everything."
  • Pull to refresh reloads page 0 and replaces the list.

What a good answer handles

  • No retry loop. After an error, do not retry again and again while the user scrolls. Wait for the user to tap "Retry".
  • Duplicates. If the server returns an item twice, the list must not break.
  • Tall screens. On an iPad, the first page may not fill the screen. Loading must still continue.
  • Cancellation. If the screen goes away during a request, the state must not stay stuck on "loading".
  • Testability. The view model gets the service through its init.

Notes

  • Target iOS 17 or later. Use List, .task, .refreshable and ContentUnavailableView.
  • The starter has three previews: normal, empty and always failing.

Starter code

Copy this into a new Xcode project (or Swift Playgrounds) and build from here.

import SwiftUI

// MARK: - Model and service (done for you)

struct Item: Identifiable, Hashable, Sendable {
    let id: Int
    let title: String
}

struct ItemPage: Sendable {
    let items: [Item]
    let hasMore: Bool
}

protocol ItemService: Sendable {
    func fetchItems(page: Int, pageSize: Int) async throws -> ItemPage
}

/// Fake backend with `totalCount` items.
/// Each call waits 0.8 seconds and sometimes fails, so you can see every state.
struct FakeItemService: ItemService {
    var totalCount = 95
    var failureRate = 0.2

    func fetchItems(page: Int, pageSize: Int) async throws -> ItemPage {
        try await Task.sleep(for: .milliseconds(800))
        if Double.random(in: 0..<1) < failureRate {
            throw URLError(.notConnectedToInternet)
        }
        let start = page * pageSize
        let end = min(start + pageSize, totalCount)
        let items = start < end ? (start..<end).map { Item(id: $0, title: "Item \($0 + 1)") } : []
        return ItemPage(items: items, hasMore: end < totalCount)
    }
}

// MARK: - View model

@MainActor
@Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    // TODO: add a load state (idle, loading, failed, reached the end).

    private let service: any ItemService
    private let pageSize = 20

    init(service: any ItemService = FakeItemService()) {
        self.service = service
    }

    func loadNextPage() async {
        // TODO: load the next page. Never run two requests at once.
    }
}

// MARK: - View

struct ItemListView: View {
    @State private var model: ItemListViewModel

    init(service: any ItemService = FakeItemService()) {
        _model = State(initialValue: ItemListViewModel(service: service))
    }

    var body: some View {
        NavigationStack {
            List(model.items) { item in
                Text(item.title)
                // TODO: load more near the end of the list.
            }
            // TODO: loading, error and empty states. A footer row for the next page.
            .navigationTitle("Items")
        }
        .task { await model.loadNextPage() }
    }
}

#Preview("Normal") {
    ItemListView()
}

#Preview("Empty") {
    ItemListView(service: FakeItemService(totalCount: 0, failureRate: 0))
}

#Preview("Always fails") {
    ItemListView(service: FakeItemService(failureRate: 1))
}

Solution

Try to build it first. Then compare your design with ours.