Skip to content

Commit

Permalink
feat: Pagination (#16)
Browse files Browse the repository at this point in the history
Co-authored-by: danthorpe <[email protected]>
  • Loading branch information
danthorpe and danthorpe authored Jun 9, 2024
1 parent ac908ce commit f32c4ab
Show file tree
Hide file tree
Showing 18 changed files with 962 additions and 2 deletions.
13 changes: 13 additions & 0 deletions Sources/ComposableLoadable/Array+.swift
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 Sources/ComposableLoadable/Documentation.docc/Pagination.md
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.
17 changes: 17 additions & 0 deletions Sources/ComposableLoadable/IdentifiedArray+.swift
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.
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ extension LoadingAction where Value: Equatable {
}
}
}

public struct NoLoadingAction: Equatable {
private init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ extension Reducer {
child: child
)
}

/// Integrate some LoadableState which does not require a child domain
public func loadable<ChildState, Request>(
_ toLoadableState: WritableKeyPath<State, LoadableState<Request, ChildState>>,
action toLoadingAction: CaseKeyPath<Action, LoadingAction<Request, ChildState, NoLoadingAction>>,
load: @escaping @Sendable (Request, State) async throws -> ChildState,
fileID: StaticString = #fileID,
line: UInt = #line
) -> some ReducerOf<Self> {
LoadingReducer(
parent: self,
child: EmptyReducer(),
toLoadableState: toLoadableState,
toLoadingAction: toLoadingAction,
client: LoadingClient(load: load),
fileID: fileID,
line: line
)
}
}

// MARK: - Internal API
Expand Down
4 changes: 2 additions & 2 deletions Sources/ComposableLoadable/OpenExistential.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Adapted from swift-composable-architecute

// MARK: Equatable
// MARK: - Equatable

func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool {
(lhs as? any Equatable)?.isEqual(other: rhs) ?? false
Expand All @@ -12,7 +12,7 @@ extension Equatable {
}
}

// MARK: Identifiable
// MARK: - Identifiable

func _identifiableID(_ value: Any) -> AnyHashable? {
func open(_ value: some Identifiable) -> AnyHashable {
Expand Down
53 changes: 53 additions & 0 deletions Sources/ComposableLoadable/Pagination/PaginationContext.swift
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 Sources/ComposableLoadable/Pagination/PaginationDirection.swift
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 Sources/ComposableLoadable/Pagination/PaginationFeature+.swift
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
}
}
}
Loading

0 comments on commit f32c4ab

Please sign in to comment.