```html SwiftUI: How to Implement Infinite Scroll (iOS 17+, 2026)

How to implement infinite scroll in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: onAppear / .task Updated: May 11, 2026
TL;DR

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

  1. @Observable view 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. isLoading and items each trigger independent, minimal redraws.
  2. LazyVStack inside ScrollView — unlike List, a LazyVStack never 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.
  3. onAppear on the last item — the guard item == viewModel.items.last fires only once per page boundary. Wrapping the async call in an unstructured Task { } bridges the synchronous onAppear closure into Swift Concurrency safely.
  4. .task on the outer view — the modifier on ScrollView'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.
  5. Guard flags prevent duplicate fetchesguard !isLoading, hasMore else { return } at the top of loadNextPage() means rapid scroll bursts cannot queue multiple simultaneous fetches. hasMore also 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 @Observable macro requires iOS 17. On iOS 16 you must fall back to @StateObject + ObservableObject with @Published properties. 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 onAppear fires while a fetch is already in-flight (e.g., slow network, quick fling), skipping the guard will append duplicate items. Always check isLoading and hasMore at the top of your fetch function; never rely solely on the call site.
  • VoiceOver focus jumping — appending items to the bottom of a LazyVStack can cause VoiceOver to read newly inserted rows out of order. Set .accessibilityScrollAction on the ScrollView and announce new content with AccessibilityNotification.announcement after 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.

```