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

MBP-4700 Pagination 트리거 로직을 개선하고 Secion 단위가 아닌 List 단위로 콜백받을 수 있도록 변경해요. #43

Closed
wants to merge 10 commits into from
70 changes: 59 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -163,20 +163,68 @@ Section(id: "Section1") {

### Pagination

We often implement pagination functionality.
KarrotListKit provides an convenience API that makes it easy to implement pagination functionality.
KarrotListKit’s Next Batch Fetching API makes it easy to add fetching chunks of new data. Usually this would be done in a `scrollViewDidScroll` method, but KarrotListKit provides a more structured mechanism.

NextBatchTrigger belongs to Section, and the trigger logic is very simple: threshold >= index of last Cell - index of Cell to will display
By default, as a user is scrolling, when they approach the point in the list where they are 2 “screens” away from the end of the current content, the KarrotListKit will try to fetch more data.

```swift
Section(id: "Section1") {
// ...
}
.withNextBatchTrigger(NextBatchTrigger(threshold: 10) { context in
// handle trigger
})
```
Here's how to set up pagination:

1. Configure how far away from the end you should be, just set the `leadingScreensForBatching` property on an `CollectionViewAdapterConfiguration`.

```swift
let configuration = CollectionViewAdapterConfiguration(
leadingScreensForNextBatching: 1.0 // default is 2.0
)
let adapter = CollectionViewAdapter(
configuration: configuration
...
)
```

2. Implement `NextBatchFetchDecisionProvider` protocol that decides if trigger next batch event or not.
```swift
class SomeNextBatchFetchDecisionProvider: NextBatchFetchDecisionProvider {

func shouldBeginNextBatchFetch() -> Bool {
// return a boolean value indicating whether the next batch fetch should start.
return true // or false
}
}

// or using type-erased wrapper for `NextBatchFetchDecisionProvider` using `AnyNextBatchFetchDecisionProvider`

let decisionProvider = AnyNextBatchFetchDecisionProvider {
// return a boolean value indicating whether the next batch fetch should start.
return true // or false
}
```

3. Set up trigger Event on `List`.
```swift
List(sections: [])
.onNextBatchTrigger(
decisionProvider: decisionProvider,
handler: { _ in
// Closure Trigger when reached bottom of list.
}
)
```

4. Once you’ve finished fetching your data, it is very important to let KarrotListKit know that you have finished the process. To do that, you need to call `completeBatchFetching()` method of `NextBatchContext` on Main-Thread(for avoid data race.). This assures that the whole batch fetching mechanism stays in sync and the next batch fetching cycle can happen.
```swift
List(sections: [])
.onNextBatchTrigger(
decisionProvider: decisionProvider,
handler: { event in
let nextBatchContext = event.context
fetchNextPage() {
DispatchQueue.main.async {
nextBatchContext.completeBatchFetching()
}
}
}
)
```


### Prefetching
106 changes: 89 additions & 17 deletions Sources/KarrotListKit/Adapter/CollectionViewAdapter.swift
Original file line number Diff line number Diff line change
@@ -41,6 +41,8 @@ final public class CollectionViewAdapter: NSObject {
completion: (() -> Void)?
)?

private let nextBatchContext = NextBatchContext()

private var componentSizeStorage: ComponentSizeStorage = ComponentSizeStorageImpl()

var list: List?
@@ -121,8 +123,6 @@ final public class CollectionViewAdapter: NSObject {

completion?()

collectionView.indexPathsForVisibleItems.forEach(handleNextBatchIfNeeded)

if let nextUpdate = queuedUpdate, collectionView.window != nil {
queuedUpdate = nil
isUpdating = false
@@ -233,30 +233,102 @@ 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 next batch of data needs to be fetched based on the current scroll position.
///
/// This method is triggered manually to ensure that the next batch update is initiated if needed.\
/// It checks if the collection view is not being dragged or tracked before calling the function to\
/// trigger the next batch update.
///
/// - NOTE: Basically, the Next Batch Update Trigger check is handled in the `scrollViewWillEndDragging` function.
private func manuallyCheckNextBatchUpdateIfNeeded() {
guard
let collectionView,
collectionView.isDragging == false,
collectionView.isTracking == false
else {
return
}
triggerNextBatchUpdateIfNeeded(contentOffset: collectionView.contentOffset)
}

/// Determines whether to trigger the next batch update based on the content offset.
///
/// This method calculates the remaining distance to the end of the content and compares it\
/// with a predefined trigger distance. If the remaining distance is less than or equal to the\
/// trigger distance, the next batch fetching is initiated.
///
/// - Parameter contentOffset: The current content offset of the collection view.
private func triggerNextBatchUpdateIfNeeded(contentOffset: CGPoint) {
guard
let event = list?.event(for: NextBatchTriggerEvent.self),
event.decisionProvider.shouldBeginNextBatchFetch() == true,
let collectionView, nextBatchContext.state != .fetching,
collectionView.bounds.isEmpty == false
else {
return
}

guard trigger.threshold >= section.cells.count - indexPath.item else {
let viewLength: CGFloat
let contentLength: CGFloat
let offset: CGFloat

switch scrollDirection {
case .vertical:
viewLength = collectionView.bounds.size.height
contentLength = collectionView.contentSize.height
offset = contentOffset.y

default:
viewLength = collectionView.bounds.size.width
contentLength = collectionView.contentSize.width
offset = contentOffset.x
}

if contentLength < viewLength {
beginNextBatchFetching(event: event)
return
}

trigger.context.state = .triggered
trigger.handler(trigger.context)
let triggerDistance = viewLength * configuration.leadingScreensForNextBatching
let remainingDistance = contentLength - viewLength - offset
if remainingDistance <= triggerDistance {
beginNextBatchFetching(event: event)
}
}

// MARK: - Action Methods
/// Initiates the fetching of the next batch triggering.
///
/// This method triggers the fetch by calling the handler associated with the `NextBatchTriggerEvent`.
private func beginNextBatchFetching(event: NextBatchTriggerEvent) {
nextBatchContext.beginBatchFetching()

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


// MARK: - CollectionViewLayoutAdapterDataSource

extension CollectionViewAdapter: CollectionViewLayoutAdapterDataSource {
@@ -294,10 +366,6 @@ extension CollectionViewAdapter: UICollectionViewDelegate {
return
}

if !isUpdating {
handleNextBatchIfNeeded(indexPath: indexPath)
}

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

manuallyCheckNextBatchUpdateIfNeeded()
}

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

triggerNextBatchUpdateIfNeeded(contentOffset: targetContentOffset.pointee)
}

public func scrollViewDidEndDragging(
Original file line number Diff line number Diff line change
@@ -18,17 +18,25 @@ public struct CollectionViewAdapterConfiguration {
/// The default value is 100.
public let batchUpdateInterruptCount: Int

/// The number of screens left to scroll before the next batch triggering.
///
/// The default value is 2.0.
public let leadingScreensForNextBatching: Double

/// Initialize a new instance of `UICollectionViewAdapter`.
///
/// - Parameters:
/// - refreshControl: RefreshControl of the CollectionView
/// - batchUpdateInterruptCount: maximum changeSet count that can be animated updates
/// - leadingScreensForNextBatching: number of screens left to scroll before the next batch triggering
public init(
refreshControl: RefreshControl = .disabled(),
batchUpdateInterruptCount: Int = 100
batchUpdateInterruptCount: Int = 100,
leadingScreensForNextBatching: Double = 2.0
) {
self.refreshControl = refreshControl
self.batchUpdateInterruptCount = batchUpdateInterruptCount
self.leadingScreensForNextBatching = leadingScreensForNextBatching
}
}

22 changes: 22 additions & 0 deletions Sources/KarrotListKit/Event/List/NextBatchTriggerEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright (c) 2024 Danggeun Market Inc.
//

import UIKit

/// Struct representing an event to trigger the next batch fetch in a listing view.
public struct NextBatchTriggerEvent: ListingViewEvent {

/// Context for the NextBatchTriggerEvent.
public struct EventContext {

/// The context for the next batch operation.
public let context: NextBatchContext
}

/// The decision provider to determine whether the next batch fetch should begin.
let decisionProvider: NextBatchFetchDecisionProvider

/// A closure that's called when the next batch fetch is triggered.
let handler: (EventContext) -> Void
}
33 changes: 33 additions & 0 deletions Sources/KarrotListKit/List.swift
Original file line number Diff line number Diff line change
@@ -55,6 +55,39 @@ extension List {
registerEvent(PullToRefreshEvent(handler: handler))
}

/// Register a callback handler that will be called when the next batch fetch triggered by scrolling the content.
///
/// Below is a sample code.
///
/// ```swift
/// List {
/// ...
/// }
/// .onNextBatchTrigger(
/// decisionProvider: .default {
/// return true // or false when do not want callback handler
/// },
/// handler: { [weak self] context in
/// self?.context = context
/// self?.fetchNextPage {
/// DispatchQueue.main.async {
/// self?.context.completeBatchFetching()
/// }
/// }
/// }
///)
/// ```
///
/// - Parameters:
/// - decisionProvider: A provider that decides whether the next batch should be fetched
/// - handler: The callback handler that will be called when the condition for fetching the next batch is met
public func onNextBatchTrigger(
decisionProvider: NextBatchFetchDecisionProvider,
handler: @escaping (NextBatchTriggerEvent.EventContext) -> Void
) -> Self {
registerEvent(NextBatchTriggerEvent(decisionProvider: decisionProvider, handler: handler))
}

/// Register a callback handler that will be called when the scrollView is about to start scrolling the content.
///
/// - Parameters:
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Copyright (c) 2024 Danggeun Market Inc.
//

import Foundation

/// A type-erased wrapper for any `NextBatchFetchDecisionProvider`.
public class AnyNextBatchFetchDecisionProvider: NextBatchFetchDecisionProvider {

/// The underlying decision closure.
private let decision: () -> Bool

/// Initializes a new instance of `AnyNextBatchFetchDecisionProvider` with a given decision closure.
///
/// - Parameter decision: A closure that returns a Boolean indicating whether the next batch fetch should start.
public init(decision: @escaping () -> Bool) {
self.decision = decision
}

/// Evaluates the decision to determine whether the next batch fetch should begin.
///
/// - Returns: A Boolean value indicating whether the next batch fetch should start.
public func shouldBeginNextBatchFetch() -> Bool {
decision()
}
}

/// Extension to create a type-erased `NextBatchFetchDecisionProvider`.
extension NextBatchFetchDecisionProvider where Self == AnyNextBatchFetchDecisionProvider {

/// Creates a type-erased `NextBatchFetchDecisionProvider`.
///
/// - Parameter decision: A closure that returns a Boolean indicating whether the next batch fetch should start.
/// - Returns: An instance of `AnyNextBatchFetchDecisionProvider`.
public static func `default`(decision: @escaping () -> Bool) -> Self {
.init(decision: decision)
}
}
Loading