← Back to list

Building a Reusable, Generic Paginated ScrollView in SwiftUI

If you’ve shipped more than one list screen in SwiftUI, you’ve probably written the same boilerplate over and over: a ScrollView, a…

Dilshad N · 2026-06-21 11:39 · 2 claps · 3.9 min read
#swiftui #ios #scrollview
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Building a Reusable, Generic Paginated ScrollView in SwiftUI

If you’ve shipped more than one list screen in SwiftUI, you’ve probably written the same boilerplate over and over: a ScrollView, a LazyVStack, an .onAppear hack to detect when the user is near the bottom, a @State flag to avoid firing duplicate network calls, and a .refreshable modifier for pull-to-refresh. Multiply that by every screen in your app — feed, search results, notifications, comments — and you end up copy-pasting the same fragile logic everywhere.

So I built PaginatedScrollView: a single, generic, reusable SwiftUI component that handles pagination, pull-to-refresh, scroll position tracking, and loading states — for any data, any layout direction, and any cell type. Here’s why I built it, what it does, and how it works under the hood.

What it actually is

It’s a generic SwiftUI view that takes three things: your data (any RandomAccessCollection of Identifiable items), a closure for how to render each item, and an optional closure for what the loading spinner looks like at the bottom. That’s it. Pass in posts, products, messages — doesn’t matter. Same view, no subclassing.

It needs iOS 17 / macOS 14, since it leans on .scrollTargetLayout() and .scrollPosition(id:) under the hood.

The parameters

  • data — your collection of items
  • axis.vertical or .horizontal (defaults to vertical)
  • showsIndicators — show/hide the scroll bar
  • spacing — gap between items, defaults to 12
  • prefetchThreshold — how many items early to trigger loading more (default 3)
  • hasMore — set to false once there's nothing left to fetch
  • scrolledID — binding that tracks whichever item is currently in view
  • onRefresh — runs on pull-to-refresh; omit it to disable that gesture
  • onLoadMore — runs when you’re near the end of the list; omit it to disable pagination
  • content — builds the view for each item
  • loader — builds the bottom-of-list loading view (defaults to ProgressView)

A quick example:

struct Post: Identifiable {
    let id: UUID
    let title: String
}

@MainActor
final class FeedViewModel: ObservableObject {
    @Published var posts: [Post] = []
    @Published var hasMore = true
    private var page = 0

    func refresh() async {
        page = 0
        hasMore = true
        posts = await fetchPage(page)
    }

    func loadMore() async {
        page += 1
        let newPosts = await fetchPage(page)
        newPosts.isEmpty ? (hasMore = false) : posts.append(contentsOf: newPosts)
    }

    private func fetchPage(_ page: Int) async -> [Post] {
        try? await Task.sleep(for: .seconds(1))
        guard page < 5 else { return [] }
        return (0..<20).map { Post(id: UUID(), title: "Post #\(page * 20 + $0)") }
    }
}

struct FeedView: View {
    @StateObject private var viewModel = FeedViewModel()
    @State private var scrolledID: UUID?

    var body: some View {
        PaginatedScrollView(
            data: viewModel.posts,
            hasMore: viewModel.hasMore,
            scrolledID: $scrolledID,
            onRefresh: { await viewModel.refresh() },
            onLoadMore: { await viewModel.loadMore() }
        ) { post in
            Text(post.title)
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .task { await viewModel.refresh() }
    }
}
import SwiftUI

@available(iOS 17.0, macOS 14.0, *)
struct PaginatedScrollView<Data, Content, Loader>: View
where Data: RandomAccessCollection,
      Data.Element: Identifiable,
      Content: View,
      Loader: View {

    // MARK: - Inputs

    private let data: Data
    private let axis: Axis.Set
    private let showsIndicators: Bool
    private let spacing: CGFloat
    private let prefetchThreshold: Int
    private let hasMore: Bool
    private let onRefresh: (() async -> Void)?
    private let onLoadMore: (() async -> Void)?
    @Binding private var scrolledID: Data.Element.ID?
    private let content: (Data.Element) -> Content
    private let loader: () -> Loader

    // MARK: - Internal state

    @State private var isPaginating = false

    // MARK: - Init

    init(
        data: Data,
        axis: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        spacing: CGFloat = 12,
        prefetchThreshold: Int = 3,
        hasMore: Bool = true,
        scrolledID: Binding<Data.Element.ID?>,
        onRefresh: (() async -> Void)? = nil,
        onLoadMore: (() async -> Void)? = nil,
        @ViewBuilder content: @escaping (Data.Element) -> Content,
        @ViewBuilder loader: @escaping () -> Loader
    ) {
        self.data = data
        self.axis = axis
        self.showsIndicators = showsIndicators
        self.spacing = spacing
        self.prefetchThreshold = max(0, prefetchThreshold)
        self.hasMore = hasMore
        self.onRefresh = onRefresh
        self.onLoadMore = onLoadMore
        self._scrolledID = scrolledID
        self.content = content
        self.loader = loader
    }

    // MARK: - Body

    var body: some View {
        if let onRefresh {
            scrollContent.refreshable { await onRefresh() }
        } else {
            scrollContent
        }
    }

    private var scrollContent: some View {
        ScrollView(axis, showsIndicators: showsIndicators) {
            stack {
                ForEach(data) { item in
                    content(item)
                        .onAppear { handleAppear(of: item) }
                }
                if isPaginating {
                    loader()
                        .frame(
                            maxWidth: axis == .vertical ? .infinity : nil,
                            maxHeight: axis == .horizontal ? .infinity : nil
                        )
                        .padding()
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition(id: $scrolledID)
    }

    @ViewBuilder
    private func stack<C: View>(@ViewBuilder content: () -> C) -> some View {
        if axis == .horizontal {
            LazyHStack(spacing: spacing, content: content)
        } else {
            LazyVStack(spacing: spacing, content: content)
        }
    }

    // MARK: - Pagination

    private func handleAppear(of item: Data.Element) {
        guard let onLoadMore,
              hasMore,
              !isPaginating,
              !data.isEmpty
        else { return }

        let triggerIndex = data.index(
            data.endIndex,
            offsetBy: -1 - prefetchThreshold,
            limitedBy: data.startIndex
        ) ?? data.startIndex

        if item.id == data[triggerIndex].id {
            Task {
                isPaginating = true
                await onLoadMore()
                isPaginating = false
            }
        }
    }
}

// MARK: - Convenience: default `ProgressView` loader

@available(iOS 17.0, macOS 14.0, *)
extension PaginatedScrollView where Loader == ProgressView<EmptyView, EmptyView> {
    init(
        data: Data,
        axis: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        spacing: CGFloat = 12,
        prefetchThreshold: Int = 3,
        hasMore: Bool = true,
        scrolledID: Binding<Data.Element.ID?>,
        onRefresh: (() async -> Void)? = nil,
        onLoadMore: (() async -> Void)? = nil,
        @ViewBuilder content: @escaping (Data.Element) -> Content
    ) {
        self.init(
            data: data,
            axis: axis,
            showsIndicators: showsIndicators,
            spacing: spacing,
            prefetchThreshold: prefetchThreshold,
            hasMore: hasMore,
            scrolledID: scrolledID,
            onRefresh: onRefresh,
            onLoadMore: onLoadMore,
            content: content,
            loader: { ProgressView() }
        )
    }
}

That’s it This isn’t groundbreaking, it’s just a small piece of plumbing I was rewriting too often. If you’ve got the same problem, copy it in and stop reinventing infinite scroll every time you start a new screen.

“The best code is the code you don’t have to write twice.”

Happy scrolling, and may your lists always load before your users notice. 🚀


메타데이터
post_id
23b8df3e4fcd
slug
building-a-reusable-generic-paginated-scrollview-in-swiftui-23b8df3e4fcd
url
https://medium.com/@dilshad.workspace/building-a-reusable-generic-paginated-scrollview-in-swiftui-23b8df3e4fcd
canonical_url
https://medium.com/@dilshad.workspace/building-a-reusable-generic-paginated-scrollview-in-swiftui-23b8df3e4fcd
author_url
https://medium.com/@dilshad.workspace
status
ok
fetched_at
2026-08-11 04:34:46