Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MBL-1016: Create Paginator for pagination in SwiftUI/Combine #1939

Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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)]
Copy link
Contributor Author

@amy-at-kickstarter amy-at-kickstarter Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my note below - the actual display view is just taking simple types. This could take a list of Projects, and that's what I did originally, but it got a little annoying - I had to use Project.template in the preview, and that's marked as private to KsApi. So I chose to just keep it to strings-and-ints for now.

@Binding var showProgressView: Bool
@Binding var statusText: String

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed a weird bug (?) where ProgressView sometimes disappeared while scrolling. Out of scope to figure out why, so you get a winky face instead of a spinner.

.onAppear {
onDidShowProgressView()
}
Spacer()
}
.background(.yellow)
}
}
.refreshable {
onRefresh()
}
}
}

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

public var body: some View {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I very intentionally split up PaginationExampleView from PaginationExampleProjectList. PaginationExampleProjectList is decoupled from the view model, basically containing only display logic for simple types. This made it really easy to write the SwiftUI preview; instead of having to mock out an entire environment with a view model and stubbed networking on so on, I could just pass in some strings.

// Note that PaginationExampleProjectList is decoupled from the view model;
// all the information it needs is passed in via bindings.
// This makes it easy to write a preview!
PaginationExampleProjectList(
projectIdsAndTitles: $viewModel.projectIdsAndTitles,
showProgressView: $viewModel.showProgressView,
statusText: $viewModel.statusText,
onRefresh: {
viewModel.didRefresh()
},
onDidShowProgressView: {
viewModel.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 @@ -1500,9 +1500,13 @@
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 */; };
E17611E22B73D9A400DF2F50 /* Data+PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E12B73D9A400DF2F50 /* Data+PKCE.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 @@ -3082,9 +3086,13 @@
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>"; };
E17611E12B73D9A400DF2F50 /* Data+PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+PKCE.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 @@ -4673,6 +4681,7 @@
1937A6ED28C92EC700DD732D /* MessageBanner */,
1937A6EE28C92ED400DD732D /* MessageDialog */,
19A97D2D28C7FF330031B857 /* MessageThreads */,
E11835202B75799F007B42E6 /* PaginationExample */,
19A97D3228C8001C0031B857 /* PaymentMethods */,
19A97D3828C801AB0031B857 /* PillCollectionView_DEPRECATED_09_06_2022 */,
1937A6F128C92F0C00DD732D /* PledgeAmount */,
Expand Down Expand Up @@ -6027,6 +6036,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 @@ -6903,6 +6914,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 @@ -7617,6 +7637,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 @@ -7784,6 +7805,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 @@ -8021,6 +8043,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 @@ -8141,6 +8164,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
8 changes: 8 additions & 0 deletions KsApi/MockService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,14 @@
+ "\(draft.update.id)/preview"
)
}

func fetchDiscovery_combine(params _: DiscoveryParams) -> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope> {
return Empty(completeImmediately: false).eraseToAnyPublisher()
}

func fetchDiscovery_combine(paginationUrl _: String) -> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope> {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
}

private extension MockService {
Expand Down
57 changes: 38 additions & 19 deletions KsApi/Service+RequestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import ReactiveSwift
extension Service {
private static let session = URLSession(configuration: .default)

func request<M: Decodable>(_ route: Route)
-> SignalProducer<M, ErrorEnvelope> {
func request<Model: Decodable>(_ route: Route)
-> SignalProducer<Model, ErrorEnvelope> {
let properties = route.requestProperties

guard let URL = URL(string: properties.path, relativeTo: self.serverConfig.apiBaseUrl as URL) else {
Expand All @@ -25,7 +25,7 @@ extension Service {
.flatMap(self.decodeModelToSignal)
}

func request<M: Decodable>(_ route: Route) -> AnyPublisher<M, ErrorEnvelope> {
func request<Model: Decodable>(_ route: Route) -> AnyPublisher<Model, ErrorEnvelope> {
let properties = route.requestProperties

guard let URL = URL(string: properties.path, relativeTo: self.serverConfig.apiBaseUrl as URL) else {
Expand All @@ -38,24 +38,11 @@ extension Service {
preparedRequest(forURL: URL, method: properties.method, query: properties.query),
uploading: properties.file.map { ($1, $0.rawValue) },
and: self.perimeterXClient
).tryMap { data in
let result: Result<M?, ErrorEnvelope> = self.decodeModelToResult(data: data, ofType: M.self)

switch result {
case let .success(value):
return value
case let .failure(error):
throw error
}
}
.compactMap { $0 }
.mapError { error in
error as! ErrorEnvelope
}.eraseToAnyPublisher()
).handle_combine_dataResponse(service: self)
}

func requestPaginationDecodable<M: Decodable>(_ paginationUrl: String)
-> SignalProducer<M, ErrorEnvelope> {
func requestPaginationDecodable<Model: Decodable>(_ paginationUrl: String)
-> SignalProducer<Model, ErrorEnvelope> {
guard let paginationUrl = URL(string: paginationUrl) else {
return .init(error: .invalidPaginationUrl)
}
Expand All @@ -64,4 +51,36 @@ extension Service {
.rac_dataResponse(preparedRequest(forURL: paginationUrl), and: self.perimeterXClient)
.flatMap(self.decodeModelToSignal)
}

func requestPaginationDecodable<Model: Decodable>(_ paginationUrl: String)
-> AnyPublisher<Model, ErrorEnvelope> {
guard let paginationUrl = URL(string: paginationUrl) else {
fatalError("Invalid pagination URL \(paginationUrl)")
}

return Service.session
.combine_dataResponse(preparedRequest(forURL: paginationUrl), and: self.perimeterXClient)
.handle_combine_dataResponse(service: self)
}
}

extension Publisher where Output == Data, Failure == ErrorEnvelope {
func handle_combine_dataResponse<Model: Decodable>(service: Service) -> AnyPublisher<Model, ErrorEnvelope> {
return self
.tryMap { data in
let result: Result<Model?, ErrorEnvelope> = service
.decodeModelToResult(data: data, ofType: Model.self)

switch result {
case let .success(value):
return value
case let .failure(error):
throw error
}
}
.compactMap { $0 }
.mapError { error in
error as! ErrorEnvelope
}.eraseToAnyPublisher()
}
}
10 changes: 10 additions & 0 deletions KsApi/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ public struct Service: ServiceType {
return request(.discover(params))
}

public func fetchDiscovery_combine(paginationUrl: String)
-> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope> {
return requestPaginationDecodable(paginationUrl)
}

public func fetchDiscovery_combine(params: DiscoveryParams)
-> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope> {
return request(.discover(params))
}

public func fetchFriends() -> SignalProducer<FindFriendsEnvelope, ErrorEnvelope> {
return request(.friends)
}
Expand Down
6 changes: 6 additions & 0 deletions KsApi/ServiceType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ public protocol ServiceType {
-> SignalProducer<FetchProjectsEnvelope, ErrorEnvelope>

func blockUser(input: BlockUserInput) -> SignalProducer<EmptyResponseEnvelope, ErrorEnvelope>

func fetchDiscovery_combine(paginationUrl: String)
-> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope>

func fetchDiscovery_combine(params: DiscoveryParams)
-> AnyPublisher<DiscoveryEnvelope, ErrorEnvelope>
}

extension ServiceType {
Expand Down
Loading