Infinite Scroll List in SwiftUI
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:
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:
- The component maps Data, a collection of
Hashable
, to Content, which is aView
. It is aBinding
, meaning that the list will refresh when underlying data does. - Shows if the list is currently loading more data.
- The actual function that will be called to load more data (if possible) and manage
isLoading
. - The data to view mapping function.
- The
init
is here solely to allow forcontent
to be marked as a@ViewBuilder
. - 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.
- If the list is loading, render a spinner at the bottom.
- 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)
}
}
}