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
- The screen opens. A spinner fills the screen.
- Page 0 (items 1–20) arrives. The list shows them.
- The user scrolls. At item 16 the app starts loading page 1. A small spinner shows at the bottom.
- Page 3 fails. The bottom row shows the error and a "Retry" button. The old items stay.
- 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
hasMoreisfalse. - 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,.refreshableandContentUnavailableView. - 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.