-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
MBL-1016: Create Paginator for pagination in Combine, plus Pagination…
…ExampleView for example usage
- Loading branch information
1 parent
6fbb726
commit 8a58626
Showing
5 changed files
with
642 additions
and
0 deletions.
There are no files selected for viewing
82 changes: 82 additions & 0 deletions
82
Kickstarter-iOS/Features/PaginationExample/PaginationExampleView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: {} | ||
) | ||
} |
85 changes: 85 additions & 0 deletions
85
Kickstarter-iOS/Features/PaginationExample/PaginationExampleViewModel.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} | ||
} |
Oops, something went wrong.