Implementing an infinite scrolling list with SwiftUI and Combine
Published on: June 29, 2020Tons of apps that we build feature lists. Sometimes we build lists of settings, lists of todo items, lists of our favorite pictures, lists of tweets, and many other things. Some of these lists could scroll almost endlessly. Think of a Twitter timeline, a Facebook feed or a list of posts on Reddit.
You might argue that knowing how to build a list that scrolls infinitely and fetches new content whenever a user reaches the end of the list is an essential skill of any iOS developer. That's why as one of my first posts that covers SwiftUI I wanted to explore building a list that can scroll forever.
And to be honest, I was surprised with how simple SwiftUI makes implementing this feature on iOS 14, even though we can't read the current scroll offset of a list like we can in UIKit. Instead of reading a scroll offset we can use a list item's onAppear
modifier to trigger a new page load.
Let's find out how.
Implementing the SwiftUI portion of an infinite scrolling list
Before I explain, let's look at some code:
struct EndlessList: View {
@StateObject var dataSource = ContentDataSource()
var body: some View {
List(dataSource.items) { item in
Text(item.label)
.onAppear {
dataSource.loadMoreContentIfNeeded(currentItem: item)
}
.padding(.all, 30)
}
}
}
This code uses
@StateObject
which is new in iOS 14. Read more about@StateObject
and how it compares to@ObservedObject
here.
I'll show you the data source in a moment, but let's talk about the couple of lines of code in this snippet first. Surely this can't be all we need to support infinite scrolling, right? Well... it turns out it is all we need.
In SwiftUI, onAppear
is called when a view is rendered by the system. This doesn't mean that the view will be rendered within the user's view, or that it ever makes it on screen so we're relying on List
's performance optimizations here and trust that it doesn't render all of its views at once.
A List
will only keep a certain number of views around while rendering so we can use onAppear
to hook into List
's rendering. Since we have access to the item that's being rendered, we can ask the data source to load more data if needed depending on the item that's being rendered. If this is one of the last items in the data source, we can kick off a page load and add more items to the data source.
Implementing the data source
Let's look at the data source for this example:
class ContentDataSource: ObservableObject {
@Published var items = [ListItem]()
@Published var isLoadingPage = false
private var currentPage = 1
private var canLoadMorePages = true
init() {
loadMoreContent()
}
func loadMoreContentIfNeeded(currentItem item: ListItem?) {
guard let item = item else {
loadMoreContent()
return
}
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
if items.firstIndex(where: { $0.id == item.id }) == thresholdIndex {
loadMoreContent()
}
}
private func loadMoreContent() {
guard !isLoadingPage && canLoadMorePages else {
return
}
isLoadingPage = true
let url = URL(string: "https://s3.eu-west-2.amazonaws.com/com.donnywals.misc/feed-\(currentPage).json")!
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: ListResponse.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.handleEvents(receiveOutput: { response in
self.canLoadMorePages = response.hasMorePages
self.isLoadingPage = false
self.currentPage += 1
})
.map({ response in
return self.items + response.items
})
.catch({ _ in Just(self.items) })
.assign(to: $items)
}
}
This code uses Combine's new
assign(to:)
function. Read more about it here.
There's a lot to unpack in that snippet but I think the most interesting bit is loadMoreContent
. The rest of the code kind of speaks for itself.
In loadMoreContent
I check whether I'm already loading a page, and whether there are more pages to load. I set isLoadingPage
to true, and I construct a URL
for a page which points to a feed file that I've uploaded to Amazon S3. This would normally be a URL
that points to the page that you want to load in your list. I create a dataTaskPublisher
so I can load the URL
and I use Combine's handleEvents
operator to apply side-effects to my data source when a response was loaded.
Next, I update the canLoadMorePages
boolean, set isLoadingPage
back to false
because the load is complete and increment the currentPage
. I prefixed handleEvents
with receive(on: DispatchQueue.main)
because I modify an @Published
property in the handleEvents
operator which might change my view and that must be done on the main thread. I don't do this in my map
that's applied after handleEvents
because map
is supposed to be pure and not apply side-effects.
In the map
I return a value that merges the current list of items with the newly loaded items. Lastly, I catch any erros that might have occured during the page load and replace them with a publisher that re-emits the current list of items. To update the items
property I use Combine's new assign(to:)
operator. This operator can pipe the output from a publisher directly into an @Published
property without needing to manually subscribe to it.
While it's a lot of code, I think this pipeline is relatively straightforward once you understand all of the operators that are used.
Since I made the ContentDataSource
's isLoadingPage
property @Published
, we can use it to add a loading indicator to the bottom of the list to show the user we're loading a new page in case the page isn't loaded by the time the user reaches the end of the list:
struct EndlessList: View {
@StateObject var dataSource = ContentDataSource()
var body: some View {
List {
ForEach(dataSource.items) { item in
Text(item.label)
.onAppear {
dataSource.loadMoreContentIfNeeded(currentItem: item)
}
.padding(.all, 30)
}
if dataSource.isLoadingPage {
ProgressView()
}
}
}
}
This if statement will conditionally show and hide a ProgressView
depending on whether we're fetching a new page.
We can modify this example to build in infinite scrolling list using a ScrollView
and ForEach
through a LazyVStack
on iOS 14.
Building an endless scrolling LazyVStack
On iOS 13 it's possible to build scrolling lists using ForEach
and VStack
. Unfortunately, these components don't work well with the technique for building an infinite list that I just demonstrated. A VStack
combined with ForEach
builds its entire view hierarchy at once rather than lazily like a List
does. This would mean that we'd immediately begin loading items from the server and continue to load more until all pages are loaded without any action from the user. This happens because onAppear
is called when a view is added to the view hierarchy rather than when the view actually becomes visible.
Luckily, iOS 14 introduces a LazyVStack
that builds its view hierarchy lazily, which means that new items are added to its layout as the user scrolls. This means that the onAppear
method for items created in ForEach
is called at a similar time as it is for items inside a List
, and that we can use it to build our infinite scrolling list without using a List
:
struct EndlessList: View {
@StateObject var dataSource = ContentDataSource()
var body: some View {
ScrollView {
LazyVStack {
ForEach(dataSource.items) { item in
Text(item.label)
.onAppear {
dataSource.loadMoreContentIfNeeded(currentItem: item)
}
.padding(.all, 30)
}
if dataSource.isLoadingPage {
ProgressView()
}
}
}
}
}
Pretty nifty, right?
In Summary
In this week's post I finally went all-in on SwiftUI. With iOS 14 I think it has reached a level of maturity that makes it attractive to learn, and since all SwiftUI code from iOS 13 also works on iOS 14 I think it's unlikely that Apple will make huge breaking changes to SwiftUI in the near future.
You saw how you can use SwiftUI to build an infinite scrolling list using the onAppear
modifier, and how you can back this up with a data source object that's implemented in Combine. I also showed you the new LazyVStack
that was added to SwiftUI in iOS 14 which allows you to apply the technique we used to build an inifite scrolling list to a VStack
.
If you have questions about this post, or if you have feedback to me I would love to hear from you on Twitter.