-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: danthorpe <[email protected]>
- Loading branch information
Showing
18 changed files
with
962 additions
and
2 deletions.
There are no files selected for viewing
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,13 @@ | ||
import Foundation | ||
|
||
extension Array { | ||
func unique<Key: Hashable>(by keyPath: KeyPath<Element, Key>) -> [Element] { | ||
var seen: Set<Key> = [] | ||
return filter { | ||
let id = $0[keyPath: keyPath] | ||
if seen.contains(id) { return false } | ||
seen.insert(id) | ||
return true | ||
} | ||
} | ||
} |
106 changes: 106 additions & 0 deletions
106
Sources/ComposableLoadable/Documentation.docc/Pagination.md
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,106 @@ | ||
# Pagination | ||
|
||
When an API supports _pagination_, we mean that its response is a subset of items, but with a cursor to load the next "page" of items. | ||
|
||
## Overview | ||
|
||
Most applications which make use of an API to load a set of items because it cannot return all available items in a single request. To load more items, typically the application makes another request using a "pagination cursor". This can go on indefinitely until all of the items have been loaded, and the API returns a nil pagination cursor. | ||
|
||
To that end this library contains a TCA compatible feature to support "infinite" scrolling and paginating items. | ||
|
||
### Basics | ||
|
||
For example lets says that you get a list of messages from an API, something like: | ||
|
||
```json | ||
{ | ||
"messages": [ | ||
/* etc */ | ||
], | ||
"nextPage": "<next-page-of-messages>" | ||
} | ||
``` | ||
|
||
You can get the next page of messages, by sending the value for `nextPage` as a query parameter in a subsequent request. | ||
|
||
By combining `LoadableState` and `PaginationFeature` from this library, we can write a feature like this: | ||
|
||
```swift | ||
struct Message: Identifiable { | ||
var id: String | ||
} | ||
@Reducer | ||
struct MessagesFeature { | ||
struct State { | ||
// Wrap PaginationFeature state with LoadableState | ||
@LoadableStateWith<MessageRequest, PaginationFeature<Message>.State> var messages | ||
} | ||
enum Action { | ||
// The corresponding messages action | ||
case messages( | ||
// is a Loading Action | ||
LoadingAction< | ||
// generic over the kind of request sent to the `load` closure | ||
MessageRequest, | ||
// and the state/action types | ||
PaginationFeature<Message>.State, | ||
PaginationFeature<Message>.Action | ||
> | ||
) | ||
} | ||
@Dependency(\.api.loadMessages) var loadMessages | ||
var body: some ReducerOf<Self> { | ||
Reduce { state, action in | ||
// etc | ||
return .none | ||
} | ||
.loadable(\.$messages, action: \.messages) { | ||
PaginationFeature<Messages> { pageRequest in | ||
let response = try await loadMessages(cursor: pageRequest.cursor) | ||
return Page( | ||
next: response.nextPage, | ||
elements: response.messages | ||
) | ||
} | ||
} load: { request, state in | ||
let response = try await loadMessages() | ||
guard let firstMessage = response.messages.first else { | ||
fatalError("Handle no-results case") | ||
} | ||
return PaginationFeature.State( | ||
selection: firstMessage.id, | ||
next: response.nextPage, | ||
elements: response.messages | ||
) | ||
} | ||
} | ||
} | ||
``` | ||
|
||
In the above example, we _compose_ `PaginationFeature.State` inside the application feature. `PaginationFeature` is generic of the kind of object which is to be paginated, so `Message` in this example. | ||
|
||
Because we need to also fetch the initial page of messages without any pagination cursors, we _wrap_ the `PaginationFeature<Message>.State` with `@LoadableState`. So this means that the `.messages` property itself is an optional, because initially it is in a `.pending` state. | ||
|
||
### Initial Load | ||
|
||
When the feature performs the initial `.load`, the closure calls the API, and constructs `PaginationFeature.State` from the response. In addition to the array of elements returned, the state value accepts `previous` and `next` pagination cursors. Here we define "next" to mean the values in the array which would be sorted after the current page of elements. If you API supports bi-directional pagination, the "previous" cursor is for those before the responses elements, however this value defaults to `nil`. | ||
|
||
### Selection | ||
|
||
The non-optional `selection` property can be used to track which element (across all pages) is currently selected. Because this is not and optional value, it might be necessary to consider how to handle the "empty result" edge case. A good idea would be to have a separate enum based feature for the `ResultsFeature` which includes a case for `.noResults`. | ||
|
||
### Pagination Context | ||
|
||
In some cases, it might be desirable to associate some additional metadata alongside your results. For example, perhaps the API also requires a "result-set-identifier", or it's necessary to keep hold of the total number of result elements. This is data which does not change or require mutation when performing pagination. In which case, create a specialised struct, which should conform to ``PaginationContext`` protocol, and set it as the `context` property. | ||
|
||
## Pagination | ||
|
||
To load another page or to change the selection, use the actions available via `PaginationFeature.Action`. | ||
|
||
### Pagination Direction | ||
|
||
The ``PaginationDirection`` enum has four cases, `.top`, `.bottom` for paging while vertically scrolling. And `.leading`, `.trailing` to enable "horizontal" paging, which will update the selection and also fetch a new page. The directions, `.top` and `.leading` both evaluate to showing or fetching elements which are before the current selection, while `.bottom` and `.trailing` show elements which are after the current selection. In other words, `.top` would prepend new elements to the list, and `.bottom` will append new elements. | ||
|
||
### Load More | ||
|
||
``PaginationLoadMore`` is a SwiftUI view which can be placed in a scroll view to support infinite scrolling. If using a SwiftUI List or a ScrollView, we would expect to place a `ForEach` view followed by a ``PaginationLoadMore`` view, to trigger loading of more elements. |
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,17 @@ | ||
import IdentifiedCollections | ||
|
||
extension IdentifiedArray { | ||
subscript(before id: ID) -> Element? { | ||
guard let idx = index(id: id), idx > startIndex else { return nil } | ||
let beforeIdx = index(before: idx) | ||
guard beforeIdx >= startIndex else { return nil } | ||
return self[beforeIdx] | ||
} | ||
|
||
subscript(after id: ID) -> Element? { | ||
guard let idx = index(id: id), idx < endIndex else { return nil } | ||
let afterIdx = index(after: idx) | ||
guard afterIdx < endIndex else { return nil } | ||
return self[afterIdx] | ||
} | ||
} |
File renamed without changes.
File renamed without changes.
File renamed without changes.
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
File renamed without changes.
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
53 changes: 53 additions & 0 deletions
53
Sources/ComposableLoadable/Pagination/PaginationContext.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,53 @@ | ||
import Foundation | ||
|
||
/// `PaginationContext` allows framework consumers to associate | ||
/// _any_ additional information which might be required for pagination. | ||
/// | ||
/// For example, perhaps there are additional identifiers other | ||
/// than a pagination cursor which is required to load a page of | ||
/// elements. To do this, create a type to hold onto additional | ||
/// info, e.g. | ||
/// | ||
/// ```swift | ||
/// struct AdditionalInfo: Equatable, PaginationContext { | ||
/// let userId: String | ||
/// let searchId: String | ||
/// } | ||
/// ``` | ||
public protocol PaginationContext: Equatable { | ||
|
||
/// A convenience to determine equality between any two | ||
/// contexts. | ||
/// - Parameter to other: any other `PaginationContext` value | ||
/// - Returns: a `Bool` | ||
func isEqual(to: any PaginationContext) -> Bool | ||
} | ||
|
||
extension PaginationContext { | ||
public func isEqual(to other: any PaginationContext) -> Bool { | ||
_isEqual(self, other) | ||
} | ||
} | ||
|
||
/// `NoPaginationContext` can be used in cases where you don't | ||
/// require any kind of context. | ||
public struct NoPaginationContext: PaginationContext { | ||
public static let none = NoPaginationContext() | ||
private init() {} | ||
} | ||
|
||
/// A helpful error which can be thrown if required. | ||
/// | ||
/// For example, if it is necessary to differentiate between | ||
/// different contexts, and the received context cannot be | ||
/// mapped to a specific type, this is a useful error to throw. | ||
public struct UnexpectedPaginationContext: Equatable, Error { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
lhs.context.isEqual(to: rhs.context) | ||
} | ||
/// The unexpected context value | ||
public let context: any PaginationContext | ||
public init(context: any PaginationContext) { | ||
self.context = context | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
Sources/ComposableLoadable/Pagination/PaginationDirection.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,39 @@ | ||
import Foundation | ||
|
||
/// The direction of selection/pagination | ||
/// | ||
/// For example, vertical scrolling is .bottom | ||
/// when revealing elements at the bottom of a list. | ||
/// And .leading/.trailing is for when paging through | ||
/// a selected element sideways. | ||
public enum PaginationDirection: Equatable { | ||
case top, bottom, leading, trailing | ||
} | ||
|
||
extension PaginationDirection { | ||
|
||
var isPrevious: Bool { | ||
switch self { | ||
case .top, .leading: true | ||
case .bottom, .trailing: false | ||
} | ||
} | ||
|
||
var isNext: Bool { | ||
false == isPrevious | ||
} | ||
|
||
var isVerticalScrolling: Bool { | ||
switch self { | ||
case .top, .bottom: true | ||
case .leading, .trailing: false | ||
} | ||
} | ||
|
||
var isHorizontalPaging: Bool { | ||
switch self { | ||
case .leading, .trailing: true | ||
case .top, .bottom: false | ||
} | ||
} | ||
} |
50 changes: 50 additions & 0 deletions
50
Sources/ComposableLoadable/Pagination/PaginationFeature+.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,50 @@ | ||
extension PaginationFeature.State.Page: Equatable where Element: Equatable { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
lhs.previous == rhs.previous | ||
&& lhs.next == rhs.next | ||
&& lhs.elements == rhs.elements | ||
} | ||
} | ||
|
||
extension PaginationFeature.State.PageRequest: Equatable where Element: Equatable { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
lhs.direction == rhs.direction | ||
&& lhs.context.isEqual(to: rhs.context) | ||
&& lhs.cursor == rhs.cursor | ||
} | ||
} | ||
|
||
extension PaginationFeature.State: Equatable where Element: Equatable { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
lhs.context.isEqual(to: rhs.context) | ||
&& lhs.selection == rhs.selection | ||
&& lhs.$page == rhs.$page | ||
&& lhs.pages == rhs.pages | ||
} | ||
} | ||
|
||
extension PaginationFeature.Action.Delegate: Equatable where Element: Equatable { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
switch (lhs, rhs) { | ||
case let (.didSelect(lhs), .didSelect(rhs)): | ||
return lhs == rhs | ||
case let (.didUpdate(context: lhsC, pages: lhsP), .didUpdate(context: rhsC, pages: rhsP)): | ||
return lhsC.isEqual(to: rhsC) && lhsP == rhsP | ||
default: | ||
return false | ||
} | ||
} | ||
} | ||
|
||
extension PaginationFeature.Action: Equatable where Element: Equatable { | ||
public static func == (lhs: Self, rhs: Self) -> Bool { | ||
switch (lhs, rhs) { | ||
case let (.delegate(lhs), .delegate(rhs)): lhs == rhs | ||
case let (.loadPage(lhs), .loadPage(rhs)): lhs == rhs | ||
case let (.page(lhs), .page(rhs)): lhs == rhs | ||
case let (.select(lhs), .select(rhs)): lhs == rhs | ||
case let (.selectElement(lhs), .selectElement(rhs)): lhs == rhs | ||
default: false | ||
} | ||
} | ||
} |
Oops, something went wrong.