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…
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 —
.verticalor.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
falseonce 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