Skip to content

Commit 82fc7e3

Browse files
authored
Add async Swift Pagination (#382)
* Add async pagination * Add Sendable Tests * Make the Combine paginator generic * Clean up API naming * Allow SequenceProvider to wire errors to the UI * Remove Combine Support (for now) * Reduce `PaginationSequence` init visibility * Remove Combine from Example App * Use paginated users in example app * Lintfix
1 parent 747e860 commit 82fc7e3

File tree

11 files changed

+291
-59
lines changed

11 files changed

+291
-59
lines changed

Package.resolved

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

native/swift/Example/Example.xcodeproj/project.pbxproj

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 242132C72CE69CE80021D8E8 /* WordPressAPI */; };
1011
242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D648D2C3602C1007CA96C /* ListViewData.swift */; };
1112
242D64922C360687007CA96C /* RootListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64912C360687007CA96C /* RootListView.swift */; };
1213
242D64942C3608C6007CA96C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64932C3608C6007CA96C /* ListView.swift */; };
@@ -18,6 +19,7 @@
1819
2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF922B621E9B0014A01D /* ListViewModel.swift */; };
1920
24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */; };
2021
24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C3352BAA874C00162AD1 /* LoginManager.swift */; };
22+
24E77D032CE44DD900F6998C /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 24E77D022CE44DD900F6998C /* WordPressAPI */; };
2123
/* End PBXBuildFile section */
2224

2325
/* Begin PBXFileReference section */
@@ -41,6 +43,8 @@
4143
buildActionMask = 2147483647;
4244
files = (
4345
2479BF912B621CCA0014A01D /* WordPressAPI in Frameworks */,
46+
242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */,
47+
24E77D032CE44DD900F6998C /* WordPressAPI in Frameworks */,
4448
);
4549
runOnlyForDeploymentPostprocessing = 0;
4650
};
@@ -123,6 +127,8 @@
123127
name = Example;
124128
packageProductDependencies = (
125129
2479BF902B621CCA0014A01D /* WordPressAPI */,
130+
24E77D022CE44DD900F6998C /* WordPressAPI */,
131+
242132C72CE69CE80021D8E8 /* WordPressAPI */,
126132
);
127133
productName = Example;
128134
productReference = 2479BF7D2B621CB60014A01D /* Example.app */;
@@ -153,7 +159,7 @@
153159
);
154160
mainGroup = 2479BF742B621CB60014A01D;
155161
packageReferences = (
156-
2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */,
162+
242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */,
157163
);
158164
productRefGroup = 2479BF7E2B621CB60014A01D /* Products */;
159165
projectDirPath = "";
@@ -409,17 +415,25 @@
409415
/* End XCConfigurationList section */
410416

411417
/* Begin XCLocalSwiftPackageReference section */
412-
2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */ = {
418+
242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */ = {
413419
isa = XCLocalSwiftPackageReference;
414-
relativePath = ../../..;
420+
relativePath = "../../../../wordpress-rs";
415421
};
416422
/* End XCLocalSwiftPackageReference section */
417423

418424
/* Begin XCSwiftPackageProductDependency section */
425+
242132C72CE69CE80021D8E8 /* WordPressAPI */ = {
426+
isa = XCSwiftPackageProductDependency;
427+
productName = WordPressAPI;
428+
};
419429
2479BF902B621CCA0014A01D /* WordPressAPI */ = {
420430
isa = XCSwiftPackageProductDependency;
421431
productName = WordPressAPI;
422432
};
433+
24E77D022CE44DD900F6998C /* WordPressAPI */ = {
434+
isa = XCSwiftPackageProductDependency;
435+
productName = WordPressAPI;
436+
};
423437
/* End XCSwiftPackageProductDependency section */
424438
};
425439
rootObject = 2479BF752B621CB60014A01D /* Project object */;

native/swift/Example/Example/ExampleApp.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import SwiftUI
22
import WordPressAPI
3+
import Combine
4+
5+
private let userListParams = UserListParams(perPage: 5)
6+
private let postListParams = PostListParams(perPage: 5)
37

48
@main
59
struct ExampleApp: App {
@@ -13,9 +17,9 @@ struct ExampleApp: App {
1317
.data
1418
.map { $0.asListViewData }
1519
}),
16-
RootListData(name: "Users", callback: {
17-
try await WordPressAPI.globalInstance.users.paginatedWithEditContext(params: UserListParams(perPage: 100))
18-
.map { $0.asListViewData }
20+
RootListData(name: "Users", sequence: {
21+
let sequence = try WordPressAPI.globalInstance.users.sequenceWithEditContext(params: userListParams)
22+
return ListViewSequence(underlyingSequence: sequence)
1923
}),
2024
RootListData(name: "Plugins", callback: {
2125
try await WordPressAPI.globalInstance.plugins.listWithEditContext(params: .init())
@@ -27,9 +31,9 @@ struct ExampleApp: App {
2731
value.asListViewData
2832
}
2933
}),
30-
RootListData(name: "Posts", callback: {
31-
try await WordPressAPI.globalInstance.posts.paginatedWithEditContext(params: PostListParams(perPage: 100))
32-
.map { $0.asListViewData }
34+
RootListData(name: "Posts", sequence: {
35+
let sequence = try WordPressAPI.globalInstance.posts.sequenceWithEditContext(params: postListParams)
36+
return ListViewSequence(underlyingSequence: sequence)
3337
}),
3438
RootListData(name: "Site Health Tests", callback: {
3539
let items: [any ListViewDataConvertable] = [

native/swift/Example/Example/ListViewData.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import Foundation
22
import WordPressAPI
3+
import WordPressAPIInternal
34

4-
struct ListViewData: Identifiable {
5+
struct ListViewData: Identifiable, Comparable, Hashable {
56
let id: String
67
let title: String
78
let subtitle: String
89
let fields: [String: String]
10+
11+
static func < (lhs: ListViewData, rhs: ListViewData) -> Bool {
12+
lhs.title < rhs.title
13+
}
914
}
1015

1116
protocol ListViewDataConvertable: Identifiable {
@@ -151,3 +156,15 @@ extension PostWithEditContext: ListViewDataConvertable {
151156
ListViewData(id: self.id, title: self.title.raw, subtitle: self.slug, fields: [:])
152157
}
153158
}
159+
160+
extension [PostWithEditContext] {
161+
func asListViewData() -> [ListViewData] {
162+
self.map { $0.asListViewData }
163+
}
164+
}
165+
166+
extension [ListViewDataConvertable] {
167+
func asListViewData() -> [ListViewData] {
168+
self.map { $0.asListViewData }
169+
}
170+
}

native/swift/Example/Example/ListViewModel.swift

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,83 @@ import Foundation
22
import SwiftUI
33
import WordPressAPI
44

5-
@Observable class ListViewModel {
5+
@MainActor
6+
protocol ListViewModel {
7+
8+
/// Guarantee only one object with each ID, but allow updating the object when new data comes in
9+
var listItems: [String: ListViewData] { get }
10+
11+
var shouldPresentAlert: Bool { get set }
12+
13+
var error: MyError? { get set }
14+
15+
func task() async
16+
}
17+
18+
@Observable class SequenceListViewModel: ListViewModel {
19+
var listItems: [String: ListViewData] = [String: ListViewData](minimumCapacity: 250)
20+
21+
typealias SequenceProvider = () throws -> ListViewSequence
22+
23+
private let sequenceProvider: SequenceProvider
24+
25+
init(sequenceProvider: @escaping SequenceProvider) {
26+
self.sequenceProvider = sequenceProvider
27+
}
28+
29+
var shouldPresentAlert: Bool = false
30+
31+
var error: MyError?
32+
33+
var sequence: ListViewSequence?
34+
35+
func task() async {
36+
do {
37+
for try await page in try self.sequenceProvider() {
38+
for item in page {
39+
self.listItems[item.id] = item
40+
}
41+
}
42+
} catch {
43+
self.error = .init(underlyingError: error)
44+
self.shouldPresentAlert = true
45+
}
46+
}
47+
48+
func reset() {
49+
50+
}
51+
}
52+
53+
@Observable class TaskListViewModel: ListViewModel {
654

755
typealias FetchDataTask = () async throws -> [ListViewData]
856

9-
var listItems: [ListViewData] = []
57+
var listItems: [String: ListViewData] = [:]
1058
private var dataCallback: FetchDataTask
11-
private var dataTask: Task<Void, any Error>?
1259
var isLoading: Bool = false
1360

1461
var error: MyError?
1562
var shouldPresentAlert = false
1663

17-
let loginManager: LoginManager
18-
19-
init(loginManager: LoginManager, dataCallback: @escaping FetchDataTask) {
20-
self.loginManager = loginManager
64+
init(dataCallback: @escaping FetchDataTask) {
2165
self.dataCallback = dataCallback
2266
}
2367

24-
func startFetching() {
25-
self.error = nil
68+
func task() async {
69+
self.isLoading = true
2670
self.shouldPresentAlert = false
2771

28-
self.dataTask = Task { @MainActor in
29-
self.isLoading = true
30-
self.shouldPresentAlert = false
31-
32-
do {
33-
self.listItems = try await dataCallback()
34-
} catch {
35-
self.error = MyError(underlyingError: error)
36-
self.shouldPresentAlert = true
72+
do {
73+
for item in try await dataCallback() {
74+
listItems[item.id] = item
3775
}
38-
39-
self.isLoading = false
76+
} catch {
77+
self.error = MyError(underlyingError: error)
78+
self.shouldPresentAlert = true
4079
}
41-
}
4280

43-
func stopFetching() {
44-
self.dataTask?.cancel()
81+
self.isLoading = false
4582
}
4683
}
4784

@@ -60,3 +97,34 @@ struct MyError: LocalizedError {
6097
underlyingError.localizedDescription
6198
}
6299
}
100+
101+
struct ListViewSequence: AsyncSequence {
102+
typealias Element = [ListViewData]
103+
104+
private let underlyingSequence: any AsyncSequence
105+
106+
init(underlyingSequence: any AsyncSequence) {
107+
self.underlyingSequence = underlyingSequence
108+
}
109+
110+
struct ListViewIterator: AsyncIteratorProtocol {
111+
var underlyingSequence: any AsyncIteratorProtocol
112+
113+
mutating func next() async throws -> Element? {
114+
guard let nextElement = try await underlyingSequence.next() else {
115+
return nil
116+
}
117+
118+
guard let listViewData = nextElement as? [any ListViewDataConvertable] else {
119+
debugPrint("Unable to convert data to `ListViewDataConvertable`")
120+
return nil
121+
}
122+
123+
return listViewData.asListViewData()
124+
}
125+
}
126+
127+
func makeAsyncIterator() -> ListViewIterator {
128+
ListViewIterator(underlyingSequence: underlyingSequence.makeAsyncIterator())
129+
}
130+
}

native/swift/Example/Example/UI/ListView.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct ListView: View {
66
var viewModel: ListViewModel
77

88
var body: some View {
9-
List(viewModel.listItems) { item in
9+
List(viewModel.listItems.values.sorted(), id: \.id) { item in
1010
VStack(alignment: .leading) {
1111
Text(item.title).font(.headline)
1212
Text(item.subtitle).font(.footnote)
@@ -29,14 +29,15 @@ struct ListView: View {
2929
}
3030
}
3131
)
32-
.onAppear(perform: viewModel.startFetching)
33-
.onDisappear(perform: viewModel.stopFetching)
32+
.task {
33+
await viewModel.task()
34+
}
3435
}
3536
}
3637

3738
#Preview {
3839

39-
let viewModel = ListViewModel(loginManager: LoginManager(), dataCallback: {
40+
let viewModel = TaskListViewModel(dataCallback: {
4041
[
4142
ListViewData(id: "1234", title: "Item 1", subtitle: "Subtitle", fields: [:])
4243
]

native/swift/Example/Example/UI/RootListView.swift

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22
import WordPressAPI
3+
import Combine
34

45
struct RootListView: View {
56

@@ -16,28 +17,50 @@ struct RootListViewItem: View {
1617
let item: RootListData
1718

1819
var body: some View {
19-
VStack(alignment: .leading, spacing: 4.0) {
20-
NavigationLink {
21-
ListView(
22-
viewModel: ListViewModel(
23-
loginManager: LoginManager(),
24-
dataCallback: self.item.callback
20+
switch item {
21+
case .callback(let name, let fetchDataTask):
22+
VStack(alignment: .leading, spacing: 4.0) {
23+
NavigationLink {
24+
ListView(
25+
viewModel: TaskListViewModel(dataCallback: fetchDataTask)
2526
)
26-
)
27-
} label: {
28-
Text(item.name)
27+
} label: {
28+
Text(name)
29+
}
30+
}
31+
32+
case .sequence(let name, let sequenceProvider):
33+
VStack(alignment: .leading, spacing: 4.0) {
34+
NavigationLink {
35+
ListView(
36+
viewModel: SequenceListViewModel(sequenceProvider: sequenceProvider)
37+
)
38+
} label: {
39+
Text(name)
40+
}
2941
}
3042
}
3143
}
3244
}
3345

34-
struct RootListData: Identifiable {
46+
enum RootListData: Identifiable {
3547

36-
let name: String
37-
let callback: ListViewModel.FetchDataTask
48+
case callback(String, TaskListViewModel.FetchDataTask)
49+
case sequence(String, SequenceListViewModel.SequenceProvider)
3850

3951
var id: String {
40-
self.name
52+
switch self {
53+
case .callback(let id, _): id
54+
case .sequence(let id, _): id
55+
}
56+
}
57+
58+
init(name: String, callback: @escaping TaskListViewModel.FetchDataTask) {
59+
self = .callback(name, callback)
60+
}
61+
62+
init(name: String, sequence: @escaping SequenceListViewModel.SequenceProvider) {
63+
self = .sequence(name, sequence)
4164
}
4265
}
4366

0 commit comments

Comments
 (0)