- Published on
> Building a Pull-to-Load-More ScrollView in SwiftUI
- Authors

- Name
- Mick MacCallum
- @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.
// Continue_Learning
How to make a SwiftUI List scroll automatically?
Learn how to use ScrollViewReader and ScrollViewProxy to scroll a SwiftUI list to a specific item automatically.
Supporting Dark Mode in a SwiftUI App
Learn how to properly support dark mode in SwiftUI using semantic colors, adaptive color assets, and color scheme detection.
Using SF Symbols in SwiftUI
Learn how to use SF Symbols in your SwiftUI apps, including sizing, coloring, animations, and finding the right symbol for your needs.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.