Skip to content

Commit

Permalink
Merge pull request #44 from daangn/feature/ben/MBP-4700-next-batch-tr…
Browse files Browse the repository at this point in the history
…igger-2

MBP-4700 Pagination 트리거 인터페이스를 변경 및 개선해요. (BreakingChanges)
  • Loading branch information
ppth0608 committed Jul 2, 2024
2 parents 377d208 + 1bea6e0 commit 0e80b84
Show file tree
Hide file tree
Showing 8 changed files with 380 additions and 229 deletions.
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ let list = List {
}
.willDisplay { context in
// handle displaying
}
}
}
.withHeader(ButtonComponent(viewModel: .init(title: "Header")))
.withFooter(ButtonComponent(viewModel: .init(title: "Footer")))
Expand Down Expand Up @@ -162,21 +162,32 @@ Section(id: "Section1") {


### Pagination
`KarrotListKit` provides an easy-to-use interface for handling pagination when loading the next page of data.
While traditionally, this might be implemented within a `scrollViewDidScroll` method, `KarrotListKit` offers a more structured mechanism for this purpose.

We often implement pagination functionality.
KarrotListKit provides an convenience API that makes it easy to implement pagination functionality.

NextBatchTrigger belongs to Section, and the trigger logic is very simple: threshold >= index of last Cell - index of Cell to will display
`List` provides an `onReachEnd` modifier, which is called when the end of the list is reached. This modifier can be attached to a `List`.

```swift
Section(id: "Section1") {
// ...
}
.withNextBatchTrigger(NextBatchTrigger(threshold: 10) { context in
// handle trigger
})
List(sections: [])
.onReachEnd(
offset: .absolute(100.0),
handler: { _ in
// Closure Trigger when reached end of list.
}
)
```

The first parameter, `offset`, is an enum of type `ReachedEndEvent.OffsetFromEnd`, allowing users to set the trigger condition.

Two options are provided:

- `case relativeToContainerSize(multiplier: CGFloat)`: Triggers the event when the user scrolls within a multiple of the height of the content view.
- `case absolute(CGFloat)`: Triggers the event when the user scrolls within an absolute point value from the end.

By default, the value is set to `.relativeToContainerSize(multiplier: 2.0)`, which triggers the event when the scroll position is within twice the height of the list view from the end of the list.

The second parameter, `handler`, is the callback handler that performs an asynchronous action when reached end of the list.



### Prefetching
Expand Down
98 changes: 80 additions & 18 deletions Sources/KarrotListKit/Adapter/CollectionViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,6 @@ final public class CollectionViewAdapter: NSObject {

completion?()

collectionView.indexPathsForVisibleItems.forEach(handleNextBatchIfNeeded)

if let nextUpdate = queuedUpdate, collectionView.window != nil {
queuedUpdate = nil
isUpdating = false
Expand Down Expand Up @@ -233,30 +231,94 @@ final public class CollectionViewAdapter: NSObject {
sectionItem(at: indexPath.section)?.cells[safe: indexPath.item]
}

private func handleNextBatchIfNeeded(indexPath: IndexPath) {
guard let section = sectionItem(at: indexPath.section),
let trigger = section.nextBatchTrigger,
trigger.context.state == .pending
// MARK: - Action Methods

@objc
private func pullToRefresh() {
list?.event(for: PullToRefreshEvent.self)?.handler(.init())
}
}


// MARK: - Next Batch Trigger

extension CollectionViewAdapter {

private var scrollDirection: UICollectionView.ScrollDirection {
let layout = collectionView?.collectionViewLayout as? UICollectionViewCompositionalLayout
return layout?.configuration.scrollDirection ?? .vertical
}

/// Checks if the collection view has reached the end, and triggers the `ReachedEndEvent` if needed.
///
/// This method manually evaluates if the collection view is near the end, based on the current content offset and view bounds.\
/// Should call this method on `scrollViewDidScroll(_:)` function of `UIScrollViewDelegate`.\
/// Basically, the `ReachedEndEvent` check is handled in the `scrollViewWillEndDragging` method.
private func manuallyCheckReachedEndEventIfNeeded() {
guard
let collectionView,
collectionView.isDragging == false,
collectionView.isTracking == false
else {
return
}
triggerReachedEndEventIfNeeded(contentOffset: collectionView.contentOffset)
}

guard trigger.threshold >= section.cells.count - indexPath.item else {
/// Evaluates the position of the content offset and triggers the `ReachedEndEvent` if the end of the content is near.
///
/// - Parameter contentOffset: The current offset of the content view.
///
/// This method calculates the distance to the end of the content, considering the current scroll direction (vertical or horizontal).\
/// It computes the view length, content length, and offset based on the scroll direction. If the content length is smaller than the view length,\
/// it immediately triggers the `ReachedEndEvent`. Otherwise, it calculates the remaining distance and compares it to the trigger distance.\
/// If the remaining distance is less than or equal to the trigger distance, the `ReachedEndEvent` is triggered.\
private func triggerReachedEndEventIfNeeded(contentOffset: CGPoint) {
guard
let event = list?.event(for: ReachedEndEvent.self),
let collectionView, collectionView.bounds.isEmpty == false
else {
return
}

trigger.context.state = .triggered
trigger.handler(trigger.context)
}
let viewLength: CGFloat
let contentLength: CGFloat
let offset: CGFloat

// MARK: - Action Methods
switch scrollDirection {
case .vertical:
viewLength = collectionView.bounds.size.height
contentLength = collectionView.contentSize.height
offset = contentOffset.y

@objc
private func pullToRefresh() {
list?.event(for: PullToRefreshEvent.self)?.handler(.init())
default:
viewLength = collectionView.bounds.size.width
contentLength = collectionView.contentSize.width
offset = contentOffset.x
}

if contentLength < viewLength {
event.handler(.init())
return
}

let triggerDistance: CGFloat = {
switch event.offset {
case .absolute(let offset):
return offset
case .relativeToContainerSize(let multiplier):
return viewLength * multiplier
}
}()

let remainingDistance = contentLength - viewLength - offset
if remainingDistance <= triggerDistance {
event.handler(.init())
}
}
}


// MARK: - CollectionViewLayoutAdapterDataSource

extension CollectionViewAdapter: CollectionViewLayoutAdapterDataSource {
Expand Down Expand Up @@ -294,10 +356,6 @@ extension CollectionViewAdapter: UICollectionViewDelegate {
return
}

if !isUpdating {
handleNextBatchIfNeeded(indexPath: indexPath)
}

item.event(for: WillDisplayEvent.self)?.handler(
.init(
indexPath: indexPath,
Expand Down Expand Up @@ -415,6 +473,8 @@ extension CollectionViewAdapter {
collectionView: collectionView
)
)

manuallyCheckReachedEndEventIfNeeded()
}

public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
Expand Down Expand Up @@ -445,6 +505,8 @@ extension CollectionViewAdapter {
targetContentOffset: targetContentOffset
)
)

triggerReachedEndEventIfNeeded(contentOffset: targetContentOffset.pointee)
}

public func scrollViewDidEndDragging(
Expand Down
25 changes: 25 additions & 0 deletions Sources/KarrotListKit/Event/List/ReachedEndEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// Copyright (c) 2024 Danggeun Market Inc.
//

import Foundation

/// An event that triggers when the user scrolls to the end of a list view.
public struct ReachedEndEvent: ListingViewEvent {

/// Context for the `ReachedEndEvent`.
public struct EventContext {}

/// Defines the offset from the end of the list view that will trigger the event.
public enum OffsetFromEnd {
/// Triggers the event when the user scrolls within a multiple of the height of the content view.
case relativeToContainerSize(multiplier: CGFloat)
/// Triggers the event when the user scrolls within an absolute point value from the end.
case absolute(CGFloat)
}

/// The offset from the end of the list view that will trigger the event.
let offset: OffsetFromEnd
/// The handler that will be called when the event is triggered.
let handler: (EventContext) -> Void
}
18 changes: 18 additions & 0 deletions Sources/KarrotListKit/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ extension List {
registerEvent(PullToRefreshEvent(handler: handler))
}

/// Register a callback handler that will be called when the user scrolls near the end of the content view.
///
/// - Parameters:
/// - offset: The offset from the end of the content view that triggers the event. Default is two times the height of the content view.
/// - handler: The callback handler for on reached end event.
/// - Returns: An updated `List` with the registered event handler.
public func onReachEnd(
offsetFromEnd offset: ReachedEndEvent.OffsetFromEnd = .relativeToContainerSize(multiplier: 2.0),
_ handler: @escaping (ReachedEndEvent.EventContext) -> Void
) -> Self {
registerEvent(
ReachedEndEvent(
offset: offset,
handler: handler
)
)
}

/// Register a callback handler that will be called when the scrollView is about to start scrolling the content.
///
/// - Parameters:
Expand Down
27 changes: 0 additions & 27 deletions Sources/KarrotListKit/NextBatchTrigger/NextBatchContext.swift

This file was deleted.

47 changes: 0 additions & 47 deletions Sources/KarrotListKit/NextBatchTrigger/NextBatchTrigger.swift

This file was deleted.

25 changes: 0 additions & 25 deletions Sources/KarrotListKit/Section.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ public struct Section: Identifiable, ListingViewEventHandler {
/// The footer that representing footer view
public var footer: SupplementaryView?

/// The object that encapsulates information about the next batch of updates.
public var nextBatchTrigger: NextBatchTrigger?

private var sectionLayout: CompositionalLayoutSectionFactory.SectionLayout?

let eventStorage: ListingViewEventStorage
Expand Down Expand Up @@ -130,28 +127,6 @@ public struct Section: Identifiable, ListingViewEventHandler {
return copy
}

/// The modifier that sets the NextBatchTrigger for the Section.
///
/// The Section supports Pagination.
/// It allows us to create and receive callbacks for the timing of the next batch update.
/// Below is a sample code.
///
/// ```swift
/// Section(id: UUID()) {
/// ...
/// }
/// .withNextBatchTrigger(threshold: 7) {
/// /// handle next batch trigger
/// }
/// ```
/// - Parameters:
/// - trigger: A trigger object that stores the timing and handler for the next batch update.
public func withNextBatchTrigger(_ trigger: NextBatchTrigger?) -> Self {
var copy = self
copy.nextBatchTrigger = trigger
return copy
}

func layout(
index: Int,
environment: NSCollectionLayoutEnvironment,
Expand Down
Loading

0 comments on commit 0e80b84

Please sign in to comment.