Implementing an infinite scrolling list with SwiftUI and Combine

Published on: June 29, 2020

Tons 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.

Categories

Combine SwiftUI

Subscribe to my newsletter