How to do pagination in SwiftUI
I bet that when you started your first SwiftUI project and when you wanted to paginate some lists, you scratched your head a bit. While there are various techniques to achieve this, I’ll focus on the one I’ve used for years.
This time, I’ll use our Medium iOS application as an example, as we’ve often used this technique when rebuilding the app's various features using SwiftUI.
An infinite list of stories
I recently migrated the search user interface to SwiftUI, and as you can see above, you can scroll the results infinitely. Let’s explore how this is built. I’ll clean and simplify the code so it’s readable and only focus on the essentials.
We have a generic search results screen. Different tabs display different entities, but the code of the result screen should behave the same for all tabs. So, we have a generic container view that will display the data source depending on its state.
struct SearchResultListView<Presenter, Content: View>: View {
var datasource: SearchResultListDatasource<Presenter>
var itemsView: () -> Content
var body: some View {
switch viewModel.state {
case .loading: loadingView
case let .empty(message):
makeMessageView(message)
default:
contentView } }
private var loadingView: some View {
MediumProgressView() }
private func makeMessageView(_ message: String) -> some View {
Text(message) }
private var contentView: some View {
List { itemsView()
switch viewModel.state {
case .loadingNextPage:
MediumProgressView()
case .data, .nextPageData:
MediumProgressView() .onAppear {
Task { await datasource.fetchNextPage() }
}
default:
EmptyView() } } .scrollDismissesKeyboard(.immediately) .scrollContentBackground(.hidden) .listStyle(.plain) }
}
As you can see in the code above, this screen handles the error, loading, and next page states. The actual content is delegated to the view builder itemsView
this view is initialized with.
And for the pagination, the exciting part is what happens after the itemsView()
. If we have data or the next page has data, we know that the itemsView()
above will display some views. So, at the end of the list of views, we show a progress indicator and when the progress indicator appears then we execute a Task, and this task loads the data for the next page.
It works because List
(and LazyVStack
) load their vertical content lazily. So .onAppear
, it will only be triggered when the progress indicator appears in the viewport. Don’t try this ScrollView
alone; this will load your page indefinitely.
The second important part is that when this is triggered, we swap to another state, loadingNextPage
and so even if the user scrolls up and down, we won’t retrigger the task as the other progress view in the loadingNextPage
state doesn’t have an .onAppear
modifier.
Now, we could have used .task
instead of the .onAppear
modifier, but the problem with the .task
modifier is that if the view doesn’t stay on screen, the task will be canceled with the view lifecycle. While in most cases, and some pagination cases, this is what you want, this is not how I decided to build it for search. I want the next page to continue loading if the user switches to another tab or scrolls away.
Be mindful of those options when selecting how to do pagination for your features.
Here is how the actual list of stories is built when itemsView()
it is invoked in SearchResultListView
. It’s simply a ForEach
which build our post previews. Nothing related to pagination in there.
struct SearchResultPostsListView: View {
@ObservedObject var datasource: SearchResultListViewModel<PostPreview.Presenter>
var body: some View {
SearchResultListView(datasource: datasource) {
ForEach(datasource.presenters) { presenter in
Post.PostPreview {
PostPreviewViewModel(preview: presenter.data,
style: .medium,
eventVitals: .init(metricsData: presenter.metricsData),
layout: .list,
sourceProvider: presenter.sourceProvider,
onSelect: presenter.viewModel.onSelect) } } } }
}
That’s how I do pagination in most of my SwiftUI views, and it’s been working this way since day one of SwiftUI. Next time you ask yourself how to do this, simply return to this story.
Happy coding ?