Skip to content

Commit

Permalink
Add async Swift Pagination (#382)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jkmassel authored Nov 14, 2024
1 parent 747e860 commit 82fc7e3
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 59 deletions.
2 changes: 1 addition & 1 deletion Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "0385aaff7e0d6e30186b9f42ad615ad3dbcb283b4464c0202bebb483e0b4058f",
"originHash" : "9aafa5656d7a6c49905ab42cb52d23fe8b7cefe1a04a14e537053255e511790a",
"pins" : [
{
"identity" : "collectionconcurrencykit",
Expand Down
20 changes: 17 additions & 3 deletions native/swift/Example/Example.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

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

/* Begin PBXFileReference section */
Expand All @@ -41,6 +43,8 @@
buildActionMask = 2147483647;
files = (
2479BF912B621CCA0014A01D /* WordPressAPI in Frameworks */,
242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */,
24E77D032CE44DD900F6998C /* WordPressAPI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -123,6 +127,8 @@
name = Example;
packageProductDependencies = (
2479BF902B621CCA0014A01D /* WordPressAPI */,
24E77D022CE44DD900F6998C /* WordPressAPI */,
242132C72CE69CE80021D8E8 /* WordPressAPI */,
);
productName = Example;
productReference = 2479BF7D2B621CB60014A01D /* Example.app */;
Expand Down Expand Up @@ -153,7 +159,7 @@
);
mainGroup = 2479BF742B621CB60014A01D;
packageReferences = (
2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */,
242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */,
);
productRefGroup = 2479BF7E2B621CB60014A01D /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -409,17 +415,25 @@
/* End XCConfigurationList section */

/* Begin XCLocalSwiftPackageReference section */
2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */ = {
242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../..;
relativePath = "../../../../wordpress-rs";
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
242132C72CE69CE80021D8E8 /* WordPressAPI */ = {
isa = XCSwiftPackageProductDependency;
productName = WordPressAPI;
};
2479BF902B621CCA0014A01D /* WordPressAPI */ = {
isa = XCSwiftPackageProductDependency;
productName = WordPressAPI;
};
24E77D022CE44DD900F6998C /* WordPressAPI */ = {
isa = XCSwiftPackageProductDependency;
productName = WordPressAPI;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 2479BF752B621CB60014A01D /* Project object */;
Expand Down
16 changes: 10 additions & 6 deletions native/swift/Example/Example/ExampleApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import SwiftUI
import WordPressAPI
import Combine

private let userListParams = UserListParams(perPage: 5)
private let postListParams = PostListParams(perPage: 5)

@main
struct ExampleApp: App {
Expand All @@ -13,9 +17,9 @@ struct ExampleApp: App {
.data
.map { $0.asListViewData }
}),
RootListData(name: "Users", callback: {
try await WordPressAPI.globalInstance.users.paginatedWithEditContext(params: UserListParams(perPage: 100))
.map { $0.asListViewData }
RootListData(name: "Users", sequence: {
let sequence = try WordPressAPI.globalInstance.users.sequenceWithEditContext(params: userListParams)
return ListViewSequence(underlyingSequence: sequence)
}),
RootListData(name: "Plugins", callback: {
try await WordPressAPI.globalInstance.plugins.listWithEditContext(params: .init())
Expand All @@ -27,9 +31,9 @@ struct ExampleApp: App {
value.asListViewData
}
}),
RootListData(name: "Posts", callback: {
try await WordPressAPI.globalInstance.posts.paginatedWithEditContext(params: PostListParams(perPage: 100))
.map { $0.asListViewData }
RootListData(name: "Posts", sequence: {
let sequence = try WordPressAPI.globalInstance.posts.sequenceWithEditContext(params: postListParams)
return ListViewSequence(underlyingSequence: sequence)
}),
RootListData(name: "Site Health Tests", callback: {
let items: [any ListViewDataConvertable] = [
Expand Down
19 changes: 18 additions & 1 deletion native/swift/Example/Example/ListViewData.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

struct ListViewData: Identifiable {
struct ListViewData: Identifiable, Comparable, Hashable {
let id: String
let title: String
let subtitle: String
let fields: [String: String]

static func < (lhs: ListViewData, rhs: ListViewData) -> Bool {
lhs.title < rhs.title
}
}

protocol ListViewDataConvertable: Identifiable {
Expand Down Expand Up @@ -151,3 +156,15 @@ extension PostWithEditContext: ListViewDataConvertable {
ListViewData(id: self.id, title: self.title.raw, subtitle: self.slug, fields: [:])
}
}

extension [PostWithEditContext] {
func asListViewData() -> [ListViewData] {
self.map { $0.asListViewData }
}
}

extension [ListViewDataConvertable] {
func asListViewData() -> [ListViewData] {
self.map { $0.asListViewData }
}
}
114 changes: 91 additions & 23 deletions native/swift/Example/Example/ListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,83 @@ import Foundation
import SwiftUI
import WordPressAPI

@Observable class ListViewModel {
@MainActor
protocol ListViewModel {

/// Guarantee only one object with each ID, but allow updating the object when new data comes in
var listItems: [String: ListViewData] { get }

var shouldPresentAlert: Bool { get set }

var error: MyError? { get set }

func task() async
}

@Observable class SequenceListViewModel: ListViewModel {
var listItems: [String: ListViewData] = [String: ListViewData](minimumCapacity: 250)

typealias SequenceProvider = () throws -> ListViewSequence

private let sequenceProvider: SequenceProvider

init(sequenceProvider: @escaping SequenceProvider) {
self.sequenceProvider = sequenceProvider
}

var shouldPresentAlert: Bool = false

var error: MyError?

var sequence: ListViewSequence?

func task() async {
do {
for try await page in try self.sequenceProvider() {
for item in page {
self.listItems[item.id] = item
}
}
} catch {
self.error = .init(underlyingError: error)
self.shouldPresentAlert = true
}
}

func reset() {

}
}

@Observable class TaskListViewModel: ListViewModel {

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

var listItems: [ListViewData] = []
var listItems: [String: ListViewData] = [:]
private var dataCallback: FetchDataTask
private var dataTask: Task<Void, any Error>?
var isLoading: Bool = false

var error: MyError?
var shouldPresentAlert = false

let loginManager: LoginManager

init(loginManager: LoginManager, dataCallback: @escaping FetchDataTask) {
self.loginManager = loginManager
init(dataCallback: @escaping FetchDataTask) {
self.dataCallback = dataCallback
}

func startFetching() {
self.error = nil
func task() async {
self.isLoading = true
self.shouldPresentAlert = false

self.dataTask = Task { @MainActor in
self.isLoading = true
self.shouldPresentAlert = false

do {
self.listItems = try await dataCallback()
} catch {
self.error = MyError(underlyingError: error)
self.shouldPresentAlert = true
do {
for item in try await dataCallback() {
listItems[item.id] = item
}

self.isLoading = false
} catch {
self.error = MyError(underlyingError: error)
self.shouldPresentAlert = true
}
}

func stopFetching() {
self.dataTask?.cancel()
self.isLoading = false
}
}

Expand All @@ -60,3 +97,34 @@ struct MyError: LocalizedError {
underlyingError.localizedDescription
}
}

struct ListViewSequence: AsyncSequence {
typealias Element = [ListViewData]

private let underlyingSequence: any AsyncSequence

init(underlyingSequence: any AsyncSequence) {
self.underlyingSequence = underlyingSequence
}

struct ListViewIterator: AsyncIteratorProtocol {
var underlyingSequence: any AsyncIteratorProtocol

mutating func next() async throws -> Element? {
guard let nextElement = try await underlyingSequence.next() else {
return nil
}

guard let listViewData = nextElement as? [any ListViewDataConvertable] else {
debugPrint("Unable to convert data to `ListViewDataConvertable`")
return nil
}

return listViewData.asListViewData()
}
}

func makeAsyncIterator() -> ListViewIterator {
ListViewIterator(underlyingSequence: underlyingSequence.makeAsyncIterator())
}
}
9 changes: 5 additions & 4 deletions native/swift/Example/Example/UI/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ struct ListView: View {
var viewModel: ListViewModel

var body: some View {
List(viewModel.listItems) { item in
List(viewModel.listItems.values.sorted(), id: \.id) { item in
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.footnote)
Expand All @@ -29,14 +29,15 @@ struct ListView: View {
}
}
)
.onAppear(perform: viewModel.startFetching)
.onDisappear(perform: viewModel.stopFetching)
.task {
await viewModel.task()
}
}
}

#Preview {

let viewModel = ListViewModel(loginManager: LoginManager(), dataCallback: {
let viewModel = TaskListViewModel(dataCallback: {
[
ListViewData(id: "1234", title: "Item 1", subtitle: "Subtitle", fields: [:])
]
Expand Down
49 changes: 36 additions & 13 deletions native/swift/Example/Example/UI/RootListView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import WordPressAPI
import Combine

struct RootListView: View {

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

var body: some View {
VStack(alignment: .leading, spacing: 4.0) {
NavigationLink {
ListView(
viewModel: ListViewModel(
loginManager: LoginManager(),
dataCallback: self.item.callback
switch item {
case .callback(let name, let fetchDataTask):
VStack(alignment: .leading, spacing: 4.0) {
NavigationLink {
ListView(
viewModel: TaskListViewModel(dataCallback: fetchDataTask)
)
)
} label: {
Text(item.name)
} label: {
Text(name)
}
}

case .sequence(let name, let sequenceProvider):
VStack(alignment: .leading, spacing: 4.0) {
NavigationLink {
ListView(
viewModel: SequenceListViewModel(sequenceProvider: sequenceProvider)
)
} label: {
Text(name)
}
}
}
}
}

struct RootListData: Identifiable {
enum RootListData: Identifiable {

let name: String
let callback: ListViewModel.FetchDataTask
case callback(String, TaskListViewModel.FetchDataTask)
case sequence(String, SequenceListViewModel.SequenceProvider)

var id: String {
self.name
switch self {
case .callback(let id, _): id
case .sequence(let id, _): id
}
}

init(name: String, callback: @escaping TaskListViewModel.FetchDataTask) {
self = .callback(name, callback)
}

init(name: String, sequence: @escaping SequenceListViewModel.SequenceProvider) {
self = .sequence(name, sequence)
}
}

Expand Down
Loading

0 comments on commit 82fc7e3

Please sign in to comment.