How to implement infinite scroll in SwiftUI
Attach onAppear to the last item in a
LazyVStack, then fire an async fetch inside
a .task modifier so Swift Concurrency handles
cancellation automatically as pages load.
// Minimal infinite scroll trigger
ForEach(items) { item in
RowView(item: item)
.onAppear {
if item == items.last {
Task { await viewModel.loadNextPage() }
}
}
}
if viewModel.isLoading {
ProgressView().padding()
}
Full implementation
The implementation uses an @Observable view model
(introduced in iOS 17) to hold paginated state, a ScrollView
with a LazyVStack for efficient row recycling, and
Swift Concurrency's structured tasks for safe, cancellable network calls. The sentinel row at the end
of the list is watched via onAppear, which triggers
the next page load without any timer or offset calculations.
import SwiftUI
// MARK: - Model
struct FeedItem: Identifiable, Equatable {
let id: Int
let title: String
let subtitle: String
}
// MARK: - ViewModel
@Observable
final class InfiniteScrollViewModel {
var items: [FeedItem] = []
var isLoading = false
var hasMore = true
private var currentPage = 0
private let pageSize = 20
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
defer { isLoading = false }
// Simulate network latency
try? await Task.sleep(for: .milliseconds(600))
let nextPage = currentPage + 1
let start = currentPage * pageSize
// Simulate a remote data source capping at 100 items
guard start < 100 else {
hasMore = false
return
}
let newItems = (start..
How it works
-
@Observableview model — iOS 17's Observation framework replaces@StateObject/@ObservableObject. The compiler generates change-tracking per-property, so only views that read a specific property re-render when it changes.isLoadinganditemseach trigger independent, minimal redraws. -
LazyVStackinsideScrollView— unlikeList, aLazyVStacknever recycles row identity, which makes appending new items at the bottom trivial. Rows are only created when they are about to be displayed, keeping memory flat even for large data sets. -
onAppearon the last item — the guarditem == viewModel.items.lastfires only once per page boundary. Wrapping the async call in an unstructuredTask { }bridges the synchronousonAppearclosure into Swift Concurrency safely. -
.taskon the outer view — the modifier onScrollView's parent loads page 1 on first render and automatically cancels the in-flight task if the view disappears (e.g., navigation pop), preventing state mutations on deallocated view models. -
Guard flags prevent duplicate fetches —
guard !isLoading, hasMore else { return }at the top ofloadNextPage()means rapid scroll bursts cannot queue multiple simultaneous fetches.hasMorealso terminates the trigger loop once the server signals the last page.
Variants
Prefetch threshold — load before the user hits the bottom
Instead of triggering on the very last item, trigger when the user is within a few items of the end. This hides latency on slower connections.
// In the ForEach body — trigger 5 rows before the end
.onAppear {
let threshold = 5
if let index = viewModel.items.firstIndex(of: item),
index >= viewModel.items.count - threshold {
Task { await viewModel.loadNextPage() }
}
}
Pull-to-refresh reset
Add .refreshable to the
ScrollView and reset the view model state before
re-fetching page 1. Because .refreshable awaits the async closure, the spinner dismisses
exactly when the first page arrives — no extra DispatchQueue.main juggling needed.
ScrollView {
// ... LazyVStack content
}
.refreshable {
viewModel.items = []
viewModel.currentPage = 0 // expose as internal(set) if needed
viewModel.hasMore = true
await viewModel.loadNextPage()
}
Common pitfalls
-
iOS 16 and
@Observable— the@Observablemacro requires iOS 17. On iOS 16 you must fall back to@StateObject+ObservableObjectwith@Publishedproperties. If your deployment target is iOS 16, guard the import with#if swift(>=5.9)or keep a separate legacy view model. -
Duplicate page loads on fast scroll — if
onAppearfires while a fetch is already in-flight (e.g., slow network, quick fling), skipping the guard will append duplicate items. Always checkisLoadingandhasMoreat the top of your fetch function; never rely solely on the call site. -
VoiceOver focus jumping — appending items to the bottom of a
LazyVStackcan cause VoiceOver to read newly inserted rows out of order. Set.accessibilityScrollActionon theScrollViewand announce new content withAccessibilityNotification.announcementafter a page loads so screen-reader users know more content has arrived.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement infinite scroll in SwiftUI for iOS 17+. Use onAppear to detect the last visible item and .task for async page fetching. Guard against duplicate loads with isLoading and hasMore flags. Make it accessible (VoiceOver labels on rows, announce new pages). Add a #Preview with realistic sample data (at least 20 pre-loaded items).
In the Soarias Build phase, paste this prompt into the Claude Code panel after scaffolding your data model — it slots directly into the Implementation step and generates a ready-to-wire view model alongside the SwiftUI view.
Related
FAQ
Does this work on iOS 16?
The onAppear + .task pattern works on iOS 15+.
However, @Observable is iOS 17-only. For iOS 16 targets, swap
@Observable final class for class … : ObservableObject,
mark each property with @Published, and declare the view model with
@StateObject in the view. Everything else — including LazyVStack
and the sentinel-row pattern — is compatible.
How do I handle errors and retry on a failed page load?
Add an error: Error? property to the view model. Catch thrown errors inside
loadNextPage() and assign them to self.error instead of propagating.
In the bottom sentinel area, show a "Retry" button when viewModel.error != nil
that calls Task { await viewModel.loadNextPage() } again. Remember to reset
error = nil at the top of the next attempt so the button disappears on success.
What is the UIKit equivalent?
In UIKit you'd implement scrollViewDidScroll(_:) on
UIScrollViewDelegate, compare contentOffset.y + frame.height
against contentSize.height minus a threshold, and call your fetch method when
the difference is small. You also need to manually manage a loading flag and append rows
via insertRows(at:with:). The SwiftUI approach with onAppear is
significantly less boilerplate and handles cancellation automatically.
Last reviewed: 2026-05-11 by the Soarias team.