Dynamic Lists With SwiftUI Using Swift Concurrency for Asynchronous Data Loading
A reusable approach to creating dynamic lists in SwiftUI
Join the DZone community and get the full member experience.
Join For FreeIn a previous blog post, I described an implementation of lists in SwiftUI where items are loaded dynamically as the user scrolls and additional data for each item is fetched asynchronously. I also wanted to provide a reusable infrastructure for lists where you could concentrate only on the data fetching logic and the presentation of a single list item, but didn’t quite reach that goal.
In this blog post, I try to improve the list infrastructure and the separation of concerns between the data model and UI. I also want to test the Swift Concurrency features (async/await etc) for the asynchronous parts, and see how well it plays with SwiftUI.
A quite common use case for many lists is that we want to handle two levels of asynchronous data fetching. The first level fetches the next batch of list items and some basic data for each item. Then, for each list item, you do a second asynchronous operation to fetch additional data for that item.
An example of this pattern may be a list of photos, where you first fetch metadata for a batch of photos, and then fetch the actual image for each item using an URL from metadata. Another example may be a list of workouts (first level) and heart rate samples for each workout (second level).
The requirements from the previous blog post are still valid, with some additional requirements covered here.
List Requirements
The requirements for my list are as follows.
- The list should grow dynamically and batch-wise as the user scrolls
- The data on each row is fetched from a (possibly) slow data source - must be possible to be performed asynchronously in the background
- Some row data should be animated in two different situations: a) when data has been fetched and b) whenever a row becomes visible
- Possibility to reset the list and reload data
- Smooth scrolling throughout
- NEW: Support for two levels of asynchronous data loading
- NEW: The data layer should be based on Swift Concurrency features
- NEW: The data layer should be unaware of UI stuff (such as making updates on the main thread etc)
Let’s see how well we can meet these requirements.
1. List Infrastructure
We start out with the list infrastructure which contains the basic building blocks that can be used for a wide range of lists that fall roughly within the use case described above. This means that you should generally not need to change anything within the infrastructure, just use it to assemble your list as shown in the example in the next section.
The infrastructure is divided into three parts.
- Protocols for the Data Model
- Generic Wrapper Classes – View Models
- Presentation Components
1.1 Protocols for the Data Model
The protocol ListItemModel
represents the data model for each item in the list. The concrete type should contain stored properties for the item’s data. Implement fetchAdditionalData
to asynchronously fetch additional data.
Note: You do not need any id (or conforming to Identifiable) for the data item, since this is automatically handled by the wrapper layer shown below.
/// A type that represents the data model for each item in the list.
///
/// The concrete type should contain stored properties for the item's data.
/// Implement `fetchAdditionalData` to asynchronously fetch additional data.
protocol ListItemModel {
/// Fetch additional data for the item.
mutating func fetchAdditionalData() async
}
The protocol ListModel
is the data source for the list, responsible for fetching batches of items. Implement fetchNextItems
to asynchronously fetch the next batch of data. There is no need to store the items, this is handled by ListViewModel
.
/// The data model for the list, responsible for fetching batches of items.
///
/// Implement `fetchNextItems` to asynchronously fetch the next batch of data.
/// There is no need to store the items, this is handled by `ListViewModel`
protocol ListModel {
associatedtype Item: ListItemModel
/// Initialize a new list model
init()
/// Asynchronously fetch the next batch of data.
mutating func fetchNextItems(count: Int) async -> [Item]
/// Reset to start fetching batches from the beginning.
///
/// Called when the list is refreshed.
mutating func reset()
}
1.2 Generic Wrapper Classes – View Models
These classes act as intermediaries between the data model and the views. They do need to be aware of UI and make sure the UI updates are performed on the main thread. This is the reason for the @MainActor
annotation on the fetchAdditionalData
and fetchMoreItemsIfNeeded
functions. In fetchMoreItemsIfNeeded
, a new task is spawned for each list item to fetch additional data for that item. This is because we want these operations to go on in parallel. If we didn’t create a new task for each item, the calls to fetchAdditionalData
would happen one after the other (serial execution). Even if we still wouldn’t block the main thread, it would slow down the UI significantly (you can try it if want).
/// Used as a wrapper for a list item in the dynamic list.
/// It makes sure items are updated once additional data has been fetched.
final class ListItemViewModel<ItemType: ListItemModel>: Identifiable, ObservableObject {
/// The wrapped item
@Published var item: ItemType
/// The index of the item in the list, starting from 0.
var id: Int
/// Has the fetch of additional data completed?
var dataFetchComplete = false
fileprivate init(item: ItemType, index: Int) {
self.item = item
self.id = index
}
@MainActor
fileprivate func fetchAdditionalData() async {
guard !dataFetchComplete else { return }
await item.fetchAdditionalData()
dataFetchComplete = true
}
}
/// Acts as the view model for the dynamic list.
/// Handles fetching (and storing) the next batch of items as needed.
final class ListViewModel<ListModelType: ListModel>: ObservableObject {
/// Initialize the list view model.
/// - Parameters:
/// - listModel: The source that performs the actual data fetching.
/// - itemBatchCount: Number of items to fetch in each batch. It is recommended to be greater than number of rows displayed.
/// - prefetchMargin: How far in advance should the next batch be fetched? Greater number means more eager.
/// Should be less than `itemBatchCount`
init(
listModel: ListModelType = ListModelType(), itemBatchCount: Int = 3, prefetchMargin: Int = 1
) {
self.listModel = listModel
self.itemBatchSize = itemBatchCount
self.prefetchMargin = prefetchMargin
}
@Published fileprivate var list: [ListItemViewModel<ListModelType.Item>] = []
private var listModel: ListModelType
private let itemBatchSize: Int
private let prefetchMargin: Int
private var fetchingInProgress: Bool = false
private(set) var listID: UUID = UUID()
/// Extend the list if we are close to the end, based on the specified index
@MainActor
fileprivate func fetchMoreItemsIfNeeded(currentIndex: Int) async {
guard currentIndex >= list.count - prefetchMargin,
!fetchingInProgress
else { return }
fetchingInProgress = true
let newItems = await listModel.fetchNextItems(count: itemBatchSize)
let newListItems = newItems.enumerated().map { (index, item) in
ListItemViewModel<ListModelType.Item>(item: item, index: list.count + index)
}
for listItem in newListItems {
list.append(listItem)
Task {
await listItem.fetchAdditionalData()
}
}
fetchingInProgress = false
}
/// Reset to start fetching batches from the beginning.
///
/// Called when the list is refreshed.
func reset() {
guard !fetchingInProgress else { return }
list = []
listID = UUID()
listModel.reset()
}
}
1.3 Presentation Components
The last part of the list infrastructure contains components that deal with the presentation of the list items and the list itself. It consists of the protocol DynamicListItemView
that the list item view should adopt, and the generic struct DynamicList
which is the actual list view. Here we use .task
which works more or less as .onAppear
but will take us to an asynchronous environment so that we don’t need to create any tasks ourselves to be able to call async
functions.
/// A type that is responsible for presenting the content of each item in a dynamic list.
///
/// The data for the item is provided through the wrapper `itemViewModel`.
protocol DynamicListItemView: View {
associatedtype ItemType: ListItemModel
/// Should be declared as @ObservedObject var itemViewModel in concrete type
var itemViewModel: ListItemViewModel<ItemType> { get }
init(itemViewModel: ListItemViewModel<ItemType>)
}
/// The view for the dynamic list.
/// Generic parameters:
/// `ItemView` is the type that presents each list item.
/// `ListModelType` is the model list model used to fetch list data.
struct DynamicList<ItemView: DynamicListItemView, ListModelType: ListModel>: View
where ListModelType.Item == ItemView.ItemType {
@ObservedObject var listViewModel: ListViewModel<ListModelType>
var body: some View {
return List(listViewModel.list) { itemViewModel in
ItemView(itemViewModel: itemViewModel)
.task {
await self.listViewModel.fetchMoreItemsIfNeeded(currentIndex: itemViewModel.id)
}
}
.refreshable {
listViewModel.reset()
}
.task {
await self.listViewModel.fetchMoreItemsIfNeeded(currentIndex: 0)
}
.id(self.listViewModel.listID)
}
}
Well, that’s it for the infrastructure. But how do you use this to implement a dynamic list? Read on, and I will take you through an example.
2. Example – a Picture Viewer
In this example, we will use the list infrastructure from the previous section to implement a simple picture viewer. There are two distinct parts: the data layer, responsible for reading picture data off a web service, and the presentation layer containing the SwiftUI views of the user interface. This video shows what it looks like.
2.1 Data Layer
We will use the excellent test service Lorem Picsum – “The Lorem Ipsum for photos” – for this example. We need to define a struct – PictureData
– that represents a single picture response from the service. It adopts the Codable
protocol so that it can be easily decoded from the JSON response.
struct PictureData: Codable {
let id: String
let author: String
let width: Int
let height: Int
let url: String
let download_url: String
}
Next, we need to define the components that perform the actual fetching of the data, they are based on the protocols ListModel
and ListItemModel
respectively.
We start out with PictureListModel
which need to provide an implementation of the fetchNextItems
and the reset
functions. Note that it needs to maintain a state to handle paging, in this case, this is stored in the lastPageFetched
property. We use the new async
API of URLSession
to fetch the data, which makes things quite tidy and neat.
struct PictureListModel: ListModel {
var lastPageFetched = -1
init() {
}
mutating func fetchNextItems(count: Int) async -> [PictureItemModel] {
guard
let url = URL(
string: "https://picsum.photos/v2/list?page=\(lastPageFetched + 1)&limit=\(count)")
else { return [] }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
let items = try decoder.decode([PictureData].self, from: data)
lastPageFetched += 1
print("Fetched page \(lastPageFetched)")
return items.map { PictureItemModel(pictureData: $0) }
} catch {
print("No pictures found")
print(error.localizedDescription)
return []
}
}
mutating func reset() {
lastPageFetched = -1
}
}
The final component of the data layer is PictureItemModel
which need to implement the async
function fetchAdditionalData
.
Note: Since Lorem Picsum is a very fast service, we have added a small random artificial delay when fetching each picture to make the asynchronous fetching more visible in the user interface.
struct PictureItemModel: ListItemModel {
var pictureData: PictureData?
var image: Image?
mutating func fetchAdditionalData() async {
guard let thePictureData = pictureData else { return }
guard let imageUrl = URL(string: "https://picsum.photos/id/\(thePictureData.id)/200/150")
else { return }
do {
let (imageData, _) = try await URLSession.shared.data(from: imageUrl)
guard let uiImage = UIImage(data: imageData) else { return }
try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 * Float.random(in: 0.5...1.5)))
image = Image(uiImage: uiImage)
} catch {
print(error.localizedDescription)
}
}
}
That’s it for the data layer. Note that the components here are totally isolated from and unaware of the user interface, no main thread concerns, no observable objects, etc. We simply make the implementation as async
functions powered by Swift Concurrency.
2.2 Presentation Layer
The meat of the presentation layer is the view that presents a single list item. This view – PictureListItemView
adopts the DynamicListItemView
protocol and must contain an @ObservedObject
of the type ListItemViewModel
. The rest of the view is just standard SwiftUI and it does only need to concern itself with the presentation and animation of a single list item.
struct PictureListItemView: DynamicListItemView {
@ObservedObject var itemViewModel: ListItemViewModel<PictureItemModel>
init(itemViewModel: ListItemViewModel<PictureItemModel>) {
self.itemViewModel = itemViewModel
}
@State var opacity: Double = 0
var body: some View {
VStack(alignment: .center) {
if let thePictureData = itemViewModel.item.pictureData {
Text("Author: \(thePictureData.author)")
.font(.system(.caption))
if itemViewModel.dataFetchComplete,
let theImage = itemViewModel.item.image
{
theImage
.resizable()
.scaledToFill()
.opacity(opacity)
.animation(.easeInOut(duration: 1), value: opacity)
.frame(maxWidth: .infinity, maxHeight: 190)
.clipped()
.onAppear {
opacity = 1
}
.padding((1 - opacity) * 80)
} else {
Spacer()
ProgressView()
Spacer()
}
}
}
.frame(maxWidth: .infinity, idealHeight: 220)
.onAppear {
if itemViewModel.dataFetchComplete {
opacity = 1
}
}
.onDisappear {
opacity = 0
}
}
}
The only thing that remains is to declare the list view model – pictureListViewModel
– and define the body of the top-level ContentView
where we use DynamicList
to present the list. Note that you can use standard list modifiers to specify how the list should be presented – in the example we use .listStyle(.plain)
let pictureListViewModel = ListViewModel<PictureListModel>(itemBatchCount: 10, prefetchMargin: 1)
struct ContentView: View {
var body: some View {
DynamicList<PictureListItemView, PictureListModel>(listViewModel: pictureListViewModel)
.listStyle(.plain)
}
}
3. Conclusion
Looking back at the requirements at the beginning of this blog post, I think we have managed to fulfill them pretty well. Swift Concurrency makes handling the asynchronous calls much easier than before, and it seems to work very well together with SwiftUI. And I believe the list infrastructure will reduce the amount of code needed to create other lists with similar behaviour.
Of course, the list infrastructure doesn’t cover all the needs for any kind of list. And there is certainly room for improvements and additional features, e.g., support for searching, filtering, insert/delete, etc. But we’ll save that for a future blog post.
The complete source code and XCode project is available here: AsyncListSwiftUI
Published at DZone with permission of Anders Forssell, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments