DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Because the DevOps movement has redefined engineering responsibilities, SREs now have to become stewards of observability strategy.

Apache Cassandra combines the benefits of major NoSQL databases to support data management needs not covered by traditional RDBMS vendors.

The software you build is only as secure as the code that powers it. Learn how malicious code creeps into your software supply chain.

Generative AI has transformed nearly every industry. How can you leverage GenAI to improve your productivity and efficiency?

Related

  • Building a Real-Time Change Data Capture Pipeline With Debezium, Kafka, and PostgreSQL
  • Supervised Fine-Tuning (SFT) on VLMs: From Pre-trained Checkpoints To Tuned Models
  • Enhancing Business Decision-Making Through Advanced Data Visualization Techniques
  • Exploring Intercooler.js: Simplify AJAX With HTML Attributes

Trending

  • DZone's Article Submission Guidelines
  • Enforcing Architecture With ArchUnit in Java
  • Chat With Your Knowledge Base: A Hands-On Java and LangChain4j Guide
  • MCP Servers: The Technical Debt That Is Coming
  1. DZone
  2. Data Engineering
  3. Data
  4. Dynamic Lists With Asynchronous Data Loading in SwiftUI

Dynamic Lists With Asynchronous Data Loading in SwiftUI

By 
Anders Forssell user avatar
Anders Forssell
·
Apr. 22, 20 · Tutorial
Likes (1)
Comment
Save
Tweet
Share
11.4K Views

Join the DZone community and get the full member experience.

Join For Free

SwiftUI is an exciting new way to create UI on Apple platforms. But, it is still a young technology, and it can sometimes be hard to find information needed to support the implementation of more advanced use cases.

In porting an app to SwiftUI, I’ve been struggling a bit with the List view. List is a very powerful container view that makes it incredibly easy to create table style components.

But if you want to up your game by adding more features, such as dynamically loading more rows as the the user scrolls, or fetching data asynchronously, it gets a little trickier.  That’s when you would like to know more about how List works its magic behind the scenes — but information is scarce about things like cell reuse, redrawing, handling animations, etc.

After some experimentation (and trial and error), I managed to come up with a list that met some more advanced requirements. I also created a few helper components that I would like to share — in case someone else is looking for similar solutions.

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.

The Solution

I will now walk you through the essential parts of the solution. The complete code is shown at the end of this article and can also be downloaded here: https://github.com/callistaenterprise/SwiftUIListExample.

We start with some of the plumbing, which consists of a few protocols and generic components that may be used as a foundation for any list of this type.

Data Model

First off is the data model, and we begin with the ListDataItem protocol, which should be adopted by the component representing row data. It must have an Int index that is used to determine when more data needs to be loaded, more on this later.

It also contains a function — fetchData — that retrieves data for the row in a second, possibly slower step.

Swift
xxxxxxxxxx
1
11
 
1
/// The data items of the list. Must contain index (row number) as a stored property
2
protocol ListDataItem {
3
    var index: Int { get set }
4
    init(index: Int)
5
6
    /// Fetch additional data of the item, possibly asynchronously
7
    func fetchData()
8
9
    /// Has the data been fetched?
10
    var dataIsFetched: Bool { get }
11
}


Next out is the ListDataProvider, a generic class that maintains the actual list of data items, stored in a published property that is used directly by the List view.

The method fetchMoreItemsIfNeeded is called by the view when a new row is presented. It will check if more items need to be fetched and will also initiate the loading of additional data for each row.

The properties itemBatchCount and prefetchMargin can be used to fine-tune the list behavior.

Swift
xxxxxxxxxx
1
35
 
1
/// Generic data provider for the list
2
class ListDataProvider<Item: ListDataItem>: ObservableObject {
3
    /// - Parameters:
4
    ///   - itemBatchCount: Number of items to fetch in each batch. It is recommended to be greater than number of rows displayed.
5
    ///   - prefetchMargin: How far in advance should the next batch be fetched? Greater number means more eager.
6
    ///                     Sholuld be less than temBatchSize.
7
    init(itemBatchCount: Int = 20, prefetchMargin: Int = 3) {
8
        itemBatchSize = itemBatchCount
9
        self.prefetchMargin = prefetchMargin
10
        reset()
11
    }
12
13
    private let itemBatchSize: Int
14
    private let prefetchMargin: Int
15
16
    private(set) var listID: UUID = UUID()
17
18
    func reset() {
19
        list = []
20
        listID = UUID()
21
        fetchMoreItemsIfNeeded(currentIndex: -1)
22
    }
23
24
    @Published var list: [Item] = []
25
26
    /// Extend the list if we are close to the end, based on the specified index
27
    func fetchMoreItemsIfNeeded(currentIndex: Int) {
28
        guard currentIndex >= list.count - prefetchMargin else { return }
29
        let startIndex = list.count
30
        for currentIndex in startIndex ..< max(startIndex + itemBatchSize, currentIndex) {
31
            list.append(Item(index: currentIndex))
32
            list[currentIndex].fetchData()
33
        }
34
    }
35
}


Views

Now, we come to the second part of the plumbing, which concerns the views. The structure is similar to that of the data model components. There is one protocol,  DynamicListRow, that the row view should adopt and a generic struct,  DynamicList, which is used as the actual list view.

DynamicList may need some further explanation. The generic parameter Row is a custom view that is used to visualize the row in any fashion you would like. The list gets it data  from  listProvider , a property of the type  ListDataProvider  that we defined above.

The onAppear modifier on the row view is there to ensure that more items are fetched as needed by calling fetchMoreItemsIfNeeded every time a row becomes visible.

There is also an id modifier on the list view itself. What is the purpose of that, you may ask? The listID property provides a unique id that remains the same until the list is reset, in which case a new unique id is generated. This ensures that the list is completely redrawn when it is reset. Leaving it out could sometimes cause the list to not fetch enough data when reset, especially for small batch sizes.

Swift
xxxxxxxxxx
1
21
 
1
/// The view for the list row
2
protocol DynamicListRow: View {
3
    associatedtype Item: ListDataItem
4
    var item: Item { get }
5
    init(item: Item)
6
}
7
8
/// The view for the dynamic list
9
struct DynamicList<Row: DynamicListRow>: View {
10
    @ObservedObject var listProvider: ListDataProvider<Row.Item>
11
    var body: some View {
12
        return
13
            List(0 ..< listProvider.list.count, id: \.self) { index in
14
                Row(item: self.listProvider.list[index])
15
                    .onAppear {
16
                        self.listProvider.fetchMoreItemsIfNeeded(currentIndex: index)
17
                }
18
            }
19
            .id(self.listProvider.listID)
20
    }
21
}


That’s all for the plumbing. Let’s move on to an example of how to use this to create our list.

List Example

Here is an example of how to implement a list using the components we’ve defined above.

To be able to demonstrate how it works with asynchronous data loading, we start with a very simple datastore, called SlowDataStore. It offers a static function that will give us a random amount between 0 and 1 as a publisher, which takes between 0.5 and 2.0 seconds to deliver the value implemented as a usleep on a background queue.

Swift
xxxxxxxxxx
1
 
1
struct SlowDataStore {
2
    static func getAmount(forIndex _: Int) -> AnyPublisher<Double, Never> {
3
        Just(Double.random(in: 0 ..< 1))
4
            .subscribe(on: DispatchQueue.global(qos: .background))
5
            .map { val in usleep(UInt32.random(in: 500_000 ..< 2_000_000)); return val }
6
            .eraseToAnyPublisher()
7
    }
8
}


Next, we create our custom data item in the class MyDataItem  which conforms to the ListDataItem  protocol. It is a quite straightforward implementation of the protocol requirements. Note that dataPublisher needs to be a stored property in order to keep the publisher from the SlowDataStore alive while waiting for the data to arrive.

Swift
xxxxxxxxxx
1
29
 
1
final class MyDataItem: ListDataItem, ObservableObject {
2
    init(index: Int) {
3
        self.index = index
4
    }
5
6
    var dataIsFetched: Bool {
7
        amount != nil
8
    }
9
10
    var index: Int = 0
11
12
    @Published var amount: Double?
13
14
    var label: String {
15
        "Line \(index)"
16
    }
17
18
    private var dataPublisher: AnyCancellable?
19
20
    func fetchData() {
21
        if !dataIsFetched {
22
            dataPublisher = SlowDataStore.getAmount(forIndex: index)
23
                .receive(on: DispatchQueue.main)
24
                .sink { amount in
25
                    self.amount = amount
26
            }
27
        }
28
    }
29
}


The final part of the list is our custom row view, which conforms to the DynamicListRow protocol. The row contains a horizontal stack with three elements:

  • A text view displaying the line number.
  • A text view displaying "Loading..." if data is not yet available, otherwise the amount is displayed.
  • A custom graph bar view that displays the amount graphically. The implementation of GraphBar  is given with the complete solution at the end.

The animation is triggered by the two modifiers on the horizontal stack. The first one,  onReceive, is used when data first arrives from the SlowDataStore. The second one,  onAppear, is used when the row appears and data is already available.

Note: The animation triggered by onReceive  and  onAppear  needs to be mutually exclusive, otherwise the animation will not work correctly. That’s why we test the property  dataIsFetched.

Swift
xxxxxxxxxx
1
37
 
1
struct MyListRow: DynamicListRow {
2
    init(item: MyDataItem) {
3
        self.item = item
4
    }
5
6
    @ObservedObject var item: MyDataItem
7
    @State var animatedAmount: Double?
8
9
    let graphAnimation = Animation.interpolatingSpring(stiffness: 30, damping: 8)
10
11
    var body: some View {
12
        HStack {
13
            Text(self.item.label)
14
                .frame(width: 60, alignment: .leading)
15
                .font(.callout)
16
            Text(self.item.amount == nil ? "Loading..." :
17
                String(format: "Amount: %.1f", self.item.amount!))
18
                .frame(width: 100, alignment: .leading)
19
                .font(.callout)
20
            GraphBar(amount: self.item.amount, animatedAmount: self.$animatedAmount)
21
        }
22
        .onReceive(self.item.$amount) { amount in
23
            if !self.item.dataIsFetched {
24
                withAnimation(self.graphAnimation) {
25
                    self.animatedAmount = amount
26
                }
27
            }
28
        }
29
        .onAppear {
30
            if self.item.dataIsFetched {
31
                withAnimation(self.graphAnimation) {
32
                    self.animatedAmount = self.item.amount
33
                }
34
            }
35
        }
36
    }
37
}


We now just need to wrap it all up in a container view, as shown below.

Swift
xxxxxxxxxx
1
13
 
1
struct ContentView: View {
2
    var listProvider = ListDataProvider<MyDataItem>(itemBatchCount: 20, prefetchMargin: 3)
3
    var body: some View {
4
        VStack {
5
            DynamicList<MyListRow>(listProvider: listProvider)
6
7
            Button("Reset") {
8
                self.listProvider.reset()
9
            }
10
        }
11
    }
12
}
13


Conclusion

I hope this article contains some useful information that will help you build your own lists based on similar requirements. Please let me know what you think, and feel free to post any questions, comments, or suggestions.

The complete code is given below. You can also download sample XCode-project here: https://github.com/callistaenterprise/SwiftUIListExample.

Complete Source Code

Swift
xxxxxxxxxx
1
203
 
1
//
2
//    DynamicListView.swift
3
//
4
//
5
6
import Combine
7
import SwiftUI
8
9
// MARK: - General Components
10
11
/// The data items of the list. Must contain index (row number) as a stored property.
12
protocol ListDataItem {
13
    var index: Int { get set }
14
    init(index: Int)
15
16
    /// Fetch additional data of the item, possibly asynchronously.
17
    func fetchData()
18
19
    /// Has the data been fetched?
20
    var dataIsFetched: Bool { get }
21
}
22
23
/// Generic data provider for the list.
24
class ListDataProvider<Item: ListDataItem>: ObservableObject {
25
    /// - Parameters:
26
    ///   - itemBatchCount: Number of items to fetch in each batch. It is recommended to be greater than number of rows displayed.
27
    ///   - prefetchMargin: How far in advance should the next batch be fetched? Greater number means more eager.
28
    ///                     Sholuld be less than temBatchSize
29
    init(itemBatchCount: Int = 20, prefetchMargin: Int = 3) {
30
        itemBatchSize = itemBatchCount
31
        self.prefetchMargin = prefetchMargin
32
        reset()
33
    }
34
35
    private let itemBatchSize: Int
36
    private let prefetchMargin: Int
37
38
    private(set) var listID: UUID = UUID()
39
40
    func reset() {
41
        list = []
42
        listID = UUID()
43
        fetchMoreItemsIfNeeded(currentIndex: -1)
44
    }
45
46
    @Published var list: [Item] = []
47
48
    /// Extend the list if we are close to the end, based on the specified index
49
    func fetchMoreItemsIfNeeded(currentIndex: Int) {
50
        guard currentIndex >= list.count - prefetchMargin else { return }
51
        let startIndex = list.count
52
        for currentIndex in startIndex ..< max(startIndex + itemBatchSize, currentIndex) {
53
            list.append(Item(index: currentIndex))
54
            list[currentIndex].fetchData()
55
        }
56
    }
57
}
58
59
/// The view for the list row
60
protocol DynamicListRow: View {
61
    associatedtype Item: ListDataItem
62
    var item: Item { get }
63
    init(item: Item)
64
}
65
66
/// The view for the dynamic list
67
struct DynamicList<Row: DynamicListRow>: View {
68
    @ObservedObject var listProvider: ListDataProvider<Row.Item>
69
    var body: some View {
70
        return
71
            List(0 ..< listProvider.list.count, id: \.self) { index in
72
                    Row(item: self.listProvider.list[index])
73
                        .onAppear {
74
                            self.listProvider.fetchMoreItemsIfNeeded(currentIndex: index)
75
                        }
76
                }
77
                .id(self.listProvider.listID)
78
    }
79
}
80
81
// MARK: - Dynamic List Example
82
83
struct SlowDataStore {
84
    static func getAmount(forIndex _: Int) -> AnyPublisher<Double, Never> {
85
        Just(Double.random(in: 0 ..< 1))
86
            .subscribe(on: DispatchQueue.global(qos: .background))
87
            .map { val in usleep(UInt32.random(in: 500_000 ..< 2_000_000)); return val }
88
            .eraseToAnyPublisher()
89
    }
90
}
91
92
final class MyDataItem: ListDataItem, ObservableObject {
93
    init(index: Int) {
94
        self.index = index
95
    }
96
97
    var dataIsFetched: Bool {
98
        amount != nil
99
    }
100
101
    var index: Int = 0
102
103
    @Published var amount: Double?
104
105
    var label: String {
106
        "Line \(index)"
107
    }
108
109
    private var dataPublisher: AnyCancellable?
110
111
    func fetchData() {
112
        if !dataIsFetched {
113
            dataPublisher = SlowDataStore.getAmount(forIndex: index)
114
                .receive(on: DispatchQueue.main)
115
                .sink { amount in
116
                    self.amount = amount
117
                }
118
        }
119
    }
120
}
121
122
struct MyListRow: DynamicListRow {
123
    init(item: MyDataItem) {
124
        self.item = item
125
    }
126
127
    @ObservedObject var item: MyDataItem
128
    @State var animatedAmount: Double?
129
130
    let graphAnimation = Animation.interpolatingSpring(stiffness: 30, damping: 8)
131
132
    var body: some View {
133
        HStack {
134
            Text(self.item.label)
135
                .frame(width: 60, alignment: .leading)
136
                .font(.callout)
137
            Text(self.item.amount == nil ? "Loading..." :
138
                String(format: "Amount: %.1f", self.item.amount!))
139
                .frame(width: 100, alignment: .leading)
140
                .font(.callout)
141
            GraphBar(amount: self.item.amount, animatedAmount: self.$animatedAmount)
142
        }
143
        .onReceive(self.item.$amount) { amount in
144
            if !self.item.dataIsFetched {
145
                withAnimation(self.graphAnimation) {
146
                    self.animatedAmount = amount
147
                }
148
            }
149
        }
150
        .onAppear {
151
            if self.item.dataIsFetched {
152
                withAnimation(self.graphAnimation) {
153
                    self.animatedAmount = self.item.amount
154
                }
155
            }
156
        }
157
    }
158
}
159
160
struct GraphBar: View {
161
    let amount: Double?
162
    @Binding var animatedAmount: Double?
163
164
    var color: Color {
165
        guard let theAmount = amount else { return Color.gray }
166
        switch theAmount {
167
        case 0.0 ..< 0.3: return Color.red
168
        case 0.3 ..< 0.7: return Color.yellow
169
        case 0.7 ... 1.0: return Color.green
170
        default: return Color.gray
171
        }
172
    }
173
174
    var body: some View {
175
        GeometryReader { geometry in
176
            ZStack {
177
                Capsule()
178
                    .frame(maxWidth: CGFloat(geometry.size.width * CGFloat(self.animatedAmount ?? 0)), maxHeight: 20)
179
                    .foregroundColor(self.color)
180
            }.frame(width: geometry.size.width, height: geometry.size.height, alignment: .leading)
181
        }
182
    }
183
}
184
185
struct ContentView: View {
186
    var listProvider = ListDataProvider<MyDataItem>(itemBatchCount: 20, prefetchMargin: 3)
187
    var body: some View {
188
        VStack {
189
            DynamicList<MyListRow>(listProvider: listProvider)
190
191
            Button("Reset") {
192
                self.listProvider.reset()
193
            }
194
        }
195
    }
196
}
197
198
struct DynamicList_Previews: PreviewProvider {
199
    static var previews: some View {
200
        ContentView()
201
            .environment(\.colorScheme, .dark)
202
    }
203
}


Data (computing)

Published at DZone with permission of Anders Forssell, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Building a Real-Time Change Data Capture Pipeline With Debezium, Kafka, and PostgreSQL
  • Supervised Fine-Tuning (SFT) on VLMs: From Pre-trained Checkpoints To Tuned Models
  • Enhancing Business Decision-Making Through Advanced Data Visualization Techniques
  • Exploring Intercooler.js: Simplify AJAX With HTML Attributes

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!