Skip to content

Commit

Permalink
MBL-1016: Create Paginator for pagination in Combine, plus Pagination…
Browse files Browse the repository at this point in the history
…ExampleView for example usage
  • Loading branch information
amy-at-kickstarter committed Feb 8, 2024
1 parent 3c828ad commit 0ee83f6
Show file tree
Hide file tree
Showing 5 changed files with 642 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import KsApi
import SwiftUI

private struct PaginationExampleProjectCell: View {
let title: String
var body: some View {
Text(title)
.padding(.all, 10)
}
}

private struct PaginationExampleProjectList: View {
@Binding var projectIdsAndTitles: [(Int, String)]
@Binding var showProgressView: Bool
@Binding var statusText: String

let onRefresh: @Sendable() -> Void
let onDidShowProgressView: @Sendable() -> Void

var body: some View {
HStack {
Spacer()
Text("👉 \(statusText)")
Spacer()
}
.padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0))
.background(Color.yellow)
List {
ForEach(projectIdsAndTitles, id: \.0) {
let title = $0.1
PaginationExampleProjectCell(title: $0.1)
}
if showProgressView {
HStack {
Spacer()
Text("Loading 😉")
.onAppear {
onDidShowProgressView()
}
Spacer()
}
.background(Color.yellow)
}
}
.refreshable {
onRefresh()
}
}
}

public struct PaginationExampleView: View {
@StateObject private var viewModel = PaginationExampleViewModel()

public var body: some View {
let capturedViewModel = viewModel

PaginationExampleProjectList(
projectIdsAndTitles: $viewModel.projectIdsAndTitles,
showProgressView: $viewModel.showProgressView,
statusText: $viewModel.statusText,
onRefresh: {
capturedViewModel.didRefresh()
},
onDidShowProgressView: {
capturedViewModel.didShowProgressView()
}
)
}
}

#Preview {
PaginationExampleProjectList(
projectIdsAndTitles: .constant([
(1, "Cool project one"),
(2, "Cool project two"),
(3, "Cool project three")
]),
showProgressView: .constant(true),
statusText: .constant("Example status text"),
onRefresh: {}, onDidShowProgressView: {}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Combine
import Foundation
import KsApi
import Library

extension Project: Identifiable {}

internal class PaginationExampleViewModel: ObservableObject {
var paginator: Paginator<DiscoveryEnvelope, Project, String, ErrorEnvelope, DiscoveryParams>

@Published var projectIdsAndTitles: [(Int, String)] = []
@Published var showProgressView: Bool = true
@Published var statusText: String = ""

init() {
self.paginator = Paginator(
valuesFromEnvelope: {
$0.projects
},
cursorFromEnvelope: {
$0.urls.api.moreProjects
},
requestFromParams: {
AppEnvironment.current.apiService.fetchDiscovery_combine(params: $0)
},
requestFromCursor: {
AppEnvironment.current.apiService.fetchDiscovery_combine(paginationUrl: $0)
}
)

self.paginator.$values.map { projects in
projects.map { ($0.id, $0.name) }
}.assign(to: &$projectIdsAndTitles)

let canLoadMore = self.paginator.$state.map { state in
state == .someLoaded || state == .unloaded
}

Publishers.CombineLatest(self.paginator.$isLoading, canLoadMore)
.map { isLoading, canLoadMore in
isLoading || canLoadMore
}.assign(to: &$showProgressView)

self.paginator.$state.map { [weak self] state in
switch state {
case .error:
let errorText = self?.paginator.error?.errorMessages.first ?? "Unknown error"
return "Error: \(errorText)"
case .unloaded:
return "Waiting to load"
case .someLoaded:
let count = self?.paginator.values.count ?? 0
return "Got \(count) results; more are available"
case .allLoaded:
return "Loaded all results"
case .empty:
return "No results"
}
}
.assign(to: &$statusText)
}

var searchParams: DiscoveryParams {
var params = DiscoveryParams.defaults
params.staffPicks = true
params.sort = .magic
return params
}

func didShowProgressView() {
if self.paginator.isLoading {
return
}

if self.paginator.state == .someLoaded {
self.paginator.requestNextPage()
} else if self.paginator.state == .unloaded {
self.paginator.requestFirstPage(withParams: self.searchParams)
}
}

func didRefresh() {
self.paginator.requestFirstPage(withParams: self.searchParams)
}
}
24 changes: 24 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1498,8 +1498,12 @@
E10D06632ACF385E00470B5C /* FetchBackerProjectsQuery.json in Resources */ = {isa = PBXBuildFile; fileRef = E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */; };
E10D06652AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test in Resources */ = {isa = PBXBuildFile; fileRef = E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */; };
E10F75E82B6937FA00024AD1 /* PKCETest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EEED2A2B686829009976D9 /* PKCETest.swift */; };
E118351F2B75639F007B42E6 /* PaginationExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */; };
E11CFE4B2B6C42CE00497375 /* OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CFE492B6C41B400497375 /* OAuth.swift */; };
E170B9112B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */; };
E17611E02B7287CF00DF2F50 /* PaginationExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */; };
E17611E42B751E8100DF2F50 /* Paginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E32B751E8100DF2F50 /* Paginator.swift */; };
E17611E62B75242A00DF2F50 /* PaginatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E52B75242A00DF2F50 /* PaginatorTests.swift */; };
E1A1491E2ACDD76800F49709 /* FetchBackerProjectsQuery.graphql in Resources */ = {isa = PBXBuildFile; fileRef = E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */; };
E1A149202ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */; };
E1A149222ACE013100F49709 /* FetchProjectsEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */; };
Expand Down Expand Up @@ -3077,8 +3081,12 @@
E10BE8E52B151CC800F73DC9 /* BlockUserInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserInputTests.swift; sourceTree = "<group>"; };
E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FetchBackerProjectsQuery.json; sourceTree = "<group>"; };
E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */ = {isa = PBXFileReference; lastKnownFileType = text; path = FetchBackerProjectsQueryRequestForTests.graphql_test; sourceTree = "<group>"; };
E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationExampleViewModel.swift; sourceTree = "<group>"; };
E11CFE492B6C41B400497375 /* OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth.swift; sourceTree = "<group>"; };
E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationExampleView.swift; sourceTree = "<group>"; };
E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockGraphQLClient+CombineTests.swift"; sourceTree = "<group>"; };
E17611E32B751E8100DF2F50 /* Paginator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paginator.swift; sourceTree = "<group>"; };
E17611E52B75242A00DF2F50 /* PaginatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatorTests.swift; sourceTree = "<group>"; };
E1889D8D2B6065D6004FBE21 /* CombineTestObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserverTests.swift; sourceTree = "<group>"; };
E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = FetchBackerProjectsQuery.graphql; sourceTree = "<group>"; };
E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4667,6 +4675,7 @@
1937A6ED28C92EC700DD732D /* MessageBanner */,
1937A6EE28C92ED400DD732D /* MessageDialog */,
19A97D2D28C7FF330031B857 /* MessageThreads */,
E11835202B75799F007B42E6 /* PaginationExample */,
19A97D3228C8001C0031B857 /* PaymentMethods */,
19A97D3828C801AB0031B857 /* PillCollectionView_DEPRECATED_09_06_2022 */,
1937A6F128C92F0C00DD732D /* PledgeAmount */,
Expand Down Expand Up @@ -6020,6 +6029,8 @@
94C92E7B2659EDBF00A96818 /* PaddingLabel.swift */,
A77D7B061CBAAF5D0077586B /* Paginate.swift */,
A7ED1F1C1E830FDC00BFFA01 /* PaginateTests.swift */,
E17611E32B751E8100DF2F50 /* Paginator.swift */,
E17611E52B75242A00DF2F50 /* PaginatorTests.swift */,
373AB25C222A0D8900769FC2 /* PasswordValidation.swift */,
373AB25E222A0DAC00769FC2 /* PasswordValidationTests.swift */,
7703B4232321844900169EF3 /* PKPaymentRequest+Helpers.swift */,
Expand Down Expand Up @@ -6894,6 +6905,15 @@
path = RichPushNotifications;
sourceTree = "<group>";
};
E11835202B75799F007B42E6 /* PaginationExample */ = {
isa = PBXGroup;
children = (
E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */,
E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */,
);
path = PaginationExample;
sourceTree = "<group>";
};
E1A149252ACE060E00F49709 /* templates */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -7607,6 +7627,7 @@
37DEC1E72257C9F30051EF9B /* PledgeViewModel.swift in Sources */,
A75CFB081CCE7FCF004CD5FA /* StaticTableViewCell.swift in Sources */,
8A3BF51923F5C347002AD818 /* CoreTelephonyNetworkInfoType.swift in Sources */,
E17611E42B751E8100DF2F50 /* Paginator.swift in Sources */,
597073521D05FE6B00B00444 /* ProjectNotificationsViewModel.swift in Sources */,
8A45168D24B3D02700D8CAEF /* RewardAddOnSelectionViewModel.swift in Sources */,
77F6E73721222E97005A5C55 /* SettingsCellType.swift in Sources */,
Expand Down Expand Up @@ -7774,6 +7795,7 @@
8AD9CF1424C8D46800F77223 /* PledgeShippingSummaryViewModelTests.swift in Sources */,
77D19FF22406D5A40058FC8E /* CategorySelectionViewModelTests.swift in Sources */,
3706408822A8A6F200889CBD /* PledgeShippingLocationViewModelTests.swift in Sources */,
E17611E62B75242A00DF2F50 /* PaginatorTests.swift in Sources */,
8AFB8C99233E9A1F006779B5 /* CreatePaymentSourceInput+ConstructorTests.swift in Sources */,
A7ED1F2B1E830FDC00BFFA01 /* IsValidEmailTests.swift in Sources */,
37C7B81723187BAC00C78278 /* ShippingRuleCellViewModelTests.swift in Sources */,
Expand Down Expand Up @@ -8011,6 +8033,7 @@
771E3C632289DBA8003E7CF1 /* SheetOverlayViewController.swift in Sources */,
0146E3231CC0296900082C5B /* FacebookConfirmationViewController.swift in Sources */,
20BCBEB0264DAA4B00510EDF /* CommentComposerView.swift in Sources */,
E118351F2B75639F007B42E6 /* PaginationExampleViewModel.swift in Sources */,
94114D5B265305210063E8F6 /* CommentPostFailedCell.swift in Sources */,
D79F0F712102973A00D3B32C /* SettingsPrivacyDeleteOrRequestCell.swift in Sources */,
379C00012242DAFF00F6F0C2 /* WebViewController.swift in Sources */,
Expand Down Expand Up @@ -8131,6 +8154,7 @@
37059843226F79A700BDA6E3 /* PledgeShippingLocationViewController.swift in Sources */,
01940B291D467ECE0074FCE3 /* HelpWebViewController.swift in Sources */,
8AA3DB35250AE46D009AC8EA /* SettingsAccountViewModel.swift in Sources */,
E17611E02B7287CF00DF2F50 /* PaginationExampleView.swift in Sources */,
8AB87DF3243FF22B006D7451 /* PledgePaymentMethodAddCell.swift in Sources */,
473DE014273C551C0033331D /* ProjectRisksDisclaimerCell.swift in Sources */,
D6C3845B210B9AC400ADB671 /* SettingsNewslettersTopCell.swift in Sources */,
Expand Down
136 changes: 136 additions & 0 deletions Library/Paginator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Combine
import Foundation

/**
Used to coordinate the process of paginating through values. This class is specific to the type of pagination
in which a page's results contains a cursor that can be used to request the next page of values.
This class is designed to work with SwiftUI/Combine. For an example, see `PaginationExampleView.swift`.
This class is generic over the following types:
* `Value`: The type of value that is being paginated, i.e. a single row, not the array of rows. The
value must be equatable.
* `Envelope`: The type of response we get from fetching a new page of values.
* `SomeError`: The type of error we might get from fetching a new page of values.
* `Cursor`: The type of value that can be extracted from `Envelope` to request the next page of
values.
* `RequestParams`: The type that allows us to make a request for values without a cursor.
- parameter valuesFromEnvelope: A function to get an array of values from the results envelope.
- parameter cursorFromEnvelope: A function to get the cursor for the next page from a results envelope.
- parameter requestFromParams: A function to get a request for values from a params value.
- parameter requestFromCursor: A function to get a request for values from a cursor value.
You can observe the results of `values`, `isLoading`, `error` and `state` to access the loaded data.
*/

public class Paginator<Envelope, Value: Equatable, Cursor, SomeError: Error, RequestParams> {
public enum Results: Equatable {
case unloaded
case someLoaded
case allLoaded
case empty
case error
}

@Published public var values: [Value]
@Published public var isLoading: Bool
@Published public var error: SomeError?
@Published public var state: Results

private var valuesFromEnvelope: (Envelope) -> [Value]
private var cursorFromEnvelope: (Envelope) -> Cursor?
private var requestFromParams: (RequestParams) -> AnyPublisher<Envelope, SomeError>
private var requestFromCursor: (Cursor) -> AnyPublisher<Envelope, SomeError>
private var cancellables = Set<AnyCancellable>()

private var lastCursor: Cursor?

public init(valuesFromEnvelope: @escaping ((Envelope) -> [Value]),
cursorFromEnvelope: @escaping ((Envelope) -> Cursor?),
requestFromParams: @escaping ((RequestParams) -> AnyPublisher<Envelope, SomeError>),
requestFromCursor: @escaping ((Cursor) -> AnyPublisher<Envelope, SomeError>)) {
self.values = []
self.isLoading = false
self.error = nil
self.state = .unloaded

self.valuesFromEnvelope = valuesFromEnvelope
self.cursorFromEnvelope = cursorFromEnvelope
self.requestFromParams = requestFromParams
self.requestFromCursor = requestFromCursor
}

func handleRequest(_ request: AnyPublisher<Envelope, SomeError>) {
request
.receive(on: RunLoop.main)
.catch { [weak self] error -> AnyPublisher<Envelope, SomeError> in
self?.error = error
self?.state = .error
return Empty<Envelope, SomeError>().eraseToAnyPublisher()
}
.assertNoFailure()
.handleEvents(receiveCompletion: { [weak self] _ in
self?.isLoading = false
}, receiveCancel: { [weak self] in
self?.isLoading = false
})
.sink(receiveValue: { [weak self] envelope in
guard let self = self else {
return
}

let newValues = self.valuesFromEnvelope(envelope)
self.values.append(contentsOf: newValues)

let cursor = self.cursorFromEnvelope(envelope)
self.lastCursor = cursor

if self.values.count == 0 {
self.state = .empty
} else if cursor == nil || newValues.count == 0 {
self.state = .allLoaded
} else {
self.state = .someLoaded
}
})
.store(in: &self.cancellables)
}

public func requestFirstPage(withParams params: RequestParams) {
self.cancel()

self.values = []
self.isLoading = true
self.error = nil

let request = self.requestFromParams(params)
self.handleRequest(request)
}

public func requestNextPage() {
if self.isLoading {
return
}

if self.state != .someLoaded {
return
}

self.isLoading = true
guard let cursor = self.lastCursor else {
assert(false, "Requested next page, but there is no cursor.")
}

let request = self.requestFromCursor(cursor)
self.handleRequest(request)
}

public func cancel() {
self.cancellables.forEach { cancellable in
cancellable.cancel()
}
}
}

0 comments on commit 0ee83f6

Please sign in to comment.