Blogg

Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på Twitter

Callista medarbetare Anders Forssell

Dynamic Lists with SwiftUI Using Swift Concurrency for Asynchronous Data Loading

// Anders Forssell

In a previous blog post, I described an implementation of lists in SwiftUI where items are loaded dynamically as the 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.

  1. The list should grow dynamically and batch-wise as the user scrolls
  2. The data on each row is fetched from a (possibly) slow data source - must be possible to be performed asynchronously in the background
  3. Some row data should be animated in two different situations: a) when data has been fetched and b) whenever a row becomes visible
  4. Possibility to reset the list and reload data
  5. Smooth scrolling throughout
  6. NEW: Support for two levels of asynchronous data loading
  7. NEW: The data layer should be based on Swift Concurrency features
  8. 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.

  1. Protocols for the Data Model
  2. Generic Wrapper Classes – View Models
  3. 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 intermediary between the data model and the views. They do need to be aware of UI and making 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 parallell. 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 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.

Video

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 performs 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 in the beginning of this blog post, I think we have managed to fulfil 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 the all the needs for any kind of list. And there are 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

Tack för att du läser Callistas blogg.
Hjälp oss att nå ut med information genom att dela nyheter och artiklar i ditt nätverk.

Kommentarer