BS
BleepingSwift
Published on
4 min read

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

Share:

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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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.