Reading time: 4 min

This recipe shows how to implement infinite scrolling list in SwiftUI. The result code is a single view, that can be used just like any other List. It renders data from a collection via a ViewBuilder and triggers loading when the list is scrolled to the bottom.

The result looks like this:

ezgif-7-f83ec7550e4e

This component is available as a Swift Package in this repo.

OK, here's the code for the InfiniteList view:

struct InfiniteList<Data, Content>: View
    where Data : RandomAccessCollection, Data.Element : Hashable, Content : View  { 
  @Binding var data: Data // 1
  @Binding var isLoading: Bool // 2
  let loadMore: () -> Void // 3
  let content: (Data.Element) -> Content // 4

  init(data: Binding<Data>,
       isLoading: Binding<Bool>,
       loadMore: @escaping () -> Void,
       @ViewBuilder content: @escaping (Data.Element) -> Content) { // 5
    _data = data
    _isLoading = isLoading
    self.loadMore = loadMore
    self.content = content
  }

  var body: some View {
    List {
       ForEach(data, id: \.self) { item in
         content(item)
           .onAppear {
              if item == data.last { // 6
                loadMore()
              }
           }
       }
       if isLoading { // 7
         ProgressView()
           .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
       }
    }.onAppear(perform: loadMore) // 8
  }
}

Here's a breakdown of what goes on in the snippet:

  1. The component maps Data, a collection of Hashable, to Content, which is a View. It is a Binding, meaning that the list will refresh when underlying data does.
  2. Shows if the list is currently loading more data.
  3. The actual function that will be called to load more data (if possible) and manage isLoading.
  4. The data to view mapping function.
  5. The init is here solely to allow for content to be marked as a @ViewBuilder.
  6. When the last row becomes visible, trigger loading more data. Note that this only happens if the last row was outside the screen and was scrolled to.
  7. If the list is loading, render a spinner at the bottom.
  8. Also trigger a refresh when the list first appears.

That's it! You can just drop this component into your code and wire it up to your data. Read along to see how that's done!

Sample infinite data source

Add this trivial model to represent our list data:

struct ListItem: Hashable {
  let text: String
}

Then, add this simple view model that holds the data and simulates paged loading:

class ListViewModel: ObservableObject {
  @Published var items = [ListItem]()
  @Published var isLoading = false
  private var page = 1
  private var subscriptions = Set<AnyCancellable>()

  func loadMore() {
    guard !isLoading else { return }

    isLoading = true
    (1...15).publisher
      .map { index in ListItem(text: "Page: \(page) item: \(index)") }
      .collect()
      .delay(for: .seconds(2), scheduler: RunLoop.main)
      .sink { [self] completion in
        isLoading = false
        page += 1
      } receiveValue: { [self] value in
        items += value
      }
      .store(in: &subscriptions)
  }
}

As you can see, it uses Combine to simulate delayed loading of 15 items, behaving just as it would if data were sourced from a server or read from a database.

Lastly, add this for a fully working sample:

struct InifiniteListTest: View {
  @ObservedObject var viewModel: ListViewModel

  var body: some View {
    InfiniteList(data: $viewModel.items,
                 isLoading: $viewModel.isLoading,
                 loadMore: viewModel.loadMore) { item in
      Text(item.text)
    }
  }
}

struct InfiniteListTest_Previews: PreviewProvider {
  static var previews: some View {
    InifiniteListTest(viewModel: ListViewModel())
  }
}
Adding pull-to-refresh to InfiniteList

Adding pull-to-refresh is simple enough if you use this component, just as component hosted on Github does. You can toggle between a refreshable and non-refreshable infinite list by setting the onRefresh parameter to a non-nil or a nil value, respectively. Here's the full code, including the view model:

import SwiftUI
import SwiftUIPullToRefresh

public struct InfiniteList<Data, Content, LoadingView>: View
where Data: RandomAccessCollection, Data.Element: Hashable, Content: View, LoadingView: View  {
  @Binding var data: Data
  @Binding var isLoading: Bool
  let loadingView: LoadingView
  let loadMore: () -> Void
  let onRefresh: OnRefresh?
  let content: (Data.Element) -> Content

  public init(data: Binding<Data>,
      isLoading: Binding<Bool>,
      loadingView: LoadingView,
      loadMore: @escaping () -> Void,
      onRefresh: OnRefresh? = nil,
      @ViewBuilder content: @escaping (Data.Element) -> Content) {
    _data = data
    _isLoading = isLoading
    self.loadingView = loadingView
    self.loadMore = loadMore
    self.onRefresh = onRefresh
    self.content = content
  }

  public var body: some View {
    if onRefresh != nil {
      RefreshableScrollView(onRefresh: onRefresh!) {
        scrollableContent
          .onAppear(perform: loadMore)
      }
    } else {
      List {
        listItems
      }.onAppear(perform: loadMore)
    }
  }

  private var scrollableContent: some View {
    Group {
      if #available(iOS 14.0, *) {
        LazyVStack(spacing: 10) {
          listItems
        }
      } else {
        VStack(spacing: 10) {
          listItems
        }
      }
    }
  }

  private var listItems: some View {
    Group {
      ForEach(data, id: \.self) { item in
        content(item)
          .onAppear {
            if item == data.last {
              loadMore()
            }
          }
        }
        if isLoading {
          loadingView
            .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center)
        }
    }
  }
}

class ListViewModel: ObservableObject {
  @Published var items = [ListItem]()
  @Published var isLoading = false
  private var page = 1
  private var subscriptions = Set<AnyCancellable>()

  func loadMore() {
    guard !isLoading else { return }

    isLoading = true
    (1...15).publisher
      .map { index in ListItem(text: "Page: \(page) item: \(index)") }
      .collect()
      .delay(for: .seconds(2), scheduler: RunLoop.main)
      .sink { [self] completion in
        isLoading = false
        page += 1
      } receiveValue: { [self] value in
        items += value
      }
      .store(in: &subscriptions)
  }

  func refresh(refreshComplete: RefreshComplete) {
    subscriptions.forEach { $0.cancel() }
    items.removeAll()
    isLoading = false
    page = 1
    loadMore()
    refreshComplete()
  }
}

struct InifiniteListTest: View {
  @ObservedObject var viewModel: ListViewModel

  var body: some View {
    InfiniteList(data: $viewModel.items,
                  isLoading: $viewModel.isLoading,
                  loadingView: ProgressView(),
                  loadMore: viewModel.loadMore,
                  onRefresh: viewModel.refresh(refreshComplete:)) { item in
        Text(item.text)
    }
  }
}

Next Post Previous Post