BS
BleepingSwift
Published on

> Building a Pull-to-Load-More ScrollView in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

Infinite scroll—loading more content as the user reaches the bottom—is a common pattern in social feeds, product listings, and search results. SwiftUI doesn't provide this out of the box, but you can build it by detecting when items near the end of your list appear on screen.

The Basic Approach

The simplest technique is to trigger a load when the last item (or an item near the end) appears. Use the onAppear modifier on your list items:

struct ContentListView: View {
    @State private var items: [Item] = []
    @State private var isLoading = false
    @State private var page = 1

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items) { item in
                    ItemRow(item: item)
                        .onAppear {
                            if item == items.last {
                                loadMore()
                            }
                        }
                }

                if isLoading {
                    ProgressView()
                        .padding()
                }
            }
        }
        .task {
            await loadInitialContent()
        }
    }

    func loadMore() {
        guard !isLoading else { return }
        isLoading = true
        page += 1

        Task {
            let newItems = await fetchItems(page: page)
            items.append(contentsOf: newItems)
            isLoading = false
        }
    }
}

When the last item appears on screen, loadMore() fires. The isLoading guard prevents duplicate requests while a fetch is in progress.

Loading Before Reaching the End

Waiting until the absolute last item creates a noticeable pause. For smoother UX, trigger the load a few items before the end:

ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    ItemRow(item: item)
        .onAppear {
            let thresholdIndex = items.count - 5
            if index >= thresholdIndex {
                loadMore()
            }
        }
}

Now loading starts when the user is 5 items from the bottom, giving the network request a head start.

Extracting a Reusable Modifier

For cleaner code, create a view modifier that encapsulates the threshold logic:

struct LoadMoreModifier: ViewModifier {
    let itemIndex: Int
    let itemCount: Int
    let threshold: Int
    let action: () -> Void

    func body(content: Content) -> some View {
        content.onAppear {
            if itemIndex >= itemCount - threshold {
                action()
            }
        }
    }
}

extension View {
    func onLoadMore(
        itemIndex: Int,
        itemCount: Int,
        threshold: Int = 5,
        action: @escaping () -> Void
    ) -> some View {
        modifier(LoadMoreModifier(
            itemIndex: itemIndex,
            itemCount: itemCount,
            threshold: threshold,
            action: action
        ))
    }
}

// Usage
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    ItemRow(item: item)
        .onLoadMore(itemIndex: index, itemCount: items.count) {
            loadMore()
        }
}

Handling End of Content

Real APIs eventually run out of data. Track when you've loaded everything to stop making unnecessary requests:

struct ContentListView: View {
    @State private var items: [Item] = []
    @State private var isLoading = false
    @State private var hasMoreContent = true
    @State private var page = 1

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                    ItemRow(item: item)
                        .onAppear {
                            if index >= items.count - 5 && hasMoreContent {
                                loadMore()
                            }
                        }
                }

                if isLoading {
                    ProgressView()
                        .padding()
                } else if !hasMoreContent && !items.isEmpty {
                    Text("You've reached the end")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .padding()
                }
            }
        }
    }

    func loadMore() {
        guard !isLoading && hasMoreContent else { return }
        isLoading = true
        page += 1

        Task {
            let newItems = await fetchItems(page: page)
            if newItems.isEmpty {
                hasMoreContent = false
            } else {
                items.append(contentsOf: newItems)
            }
            isLoading = false
        }
    }
}

Using ScrollView with scrollPosition (iOS 17+)

On iOS 17 and later, you can use scrollPosition to monitor scroll offset more directly:

struct ModernInfiniteScroll: View {
    @State private var items: [Item] = []
    @State private var scrollPosition: String?

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items) { item in
                    ItemRow(item: item)
                        .id(item.id)
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $scrollPosition)
        .onChange(of: scrollPosition) { _, newValue in
            if let id = newValue, isNearEnd(id: id) {
                loadMore()
            }
        }
    }

    func isNearEnd(id: String) -> Bool {
        guard let index = items.firstIndex(where: { $0.id == id }) else {
            return false
        }
        return index >= items.count - 5
    }
}

This approach tracks exactly which item is currently visible, giving you precise control over when loading triggers.

Combining with Pull-to-Refresh

Pull-to-load-more works well alongside pull-to-refresh. Use refreshable for the top of the list and onAppear for the bottom:

ScrollView {
    LazyVStack {
        ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
            ItemRow(item: item)
                .onAppear {
                    if index >= items.count - 5 {
                        loadMore()
                    }
                }
        }
    }
}
.refreshable {
    await refreshContent()
}

The refreshable modifier adds the standard pull-down-to-refresh behavior, while your onAppear logic handles pagination at the bottom.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.