diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e39e8f1..6e1a62bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Added `ListStateObserver.onApproachingBottom(within:shouldPerform:_:)` to make end-of-list pagination easier to implement and test. +- Added `ListScrollPositionInfo.BottomThreshold`, `contentSize`, and `isApproachingBottom(within:)` to support approaching-bottom pagination observers. + ### Removed ### Changed diff --git a/Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift b/Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift new file mode 100644 index 00000000..676daa98 --- /dev/null +++ b/Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift @@ -0,0 +1,191 @@ +// +// ApproachingBottomPaginationViewController.swift +// Demo +// +// Created by OpenAI Codex on 2026-04-24. +// + +import ListableUI +import BlueprintUILists +import BlueprintUI +import BlueprintUICommonControls +import UIKit + + +final class ApproachingBottomPaginationViewController : ListViewController +{ + private let actions = ListActions() + + private let pageSize = 20 + private let maxPageCount = 5 + + private var items : [DemoItem] = [] + private var nextItemNumber = 1 + private var loadedPageCount = 0 + private var approachingBottomCallCount = 0 + private var isLoadingNextPage = false + + private var loadTask : Task? + + private var hasMorePages : Bool { + self.loadedPageCount < self.maxPageCount + } + + deinit { + self.loadTask?.cancel() + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.title = "Approaching Bottom" + + self.navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetDemo)), + UIBarButtonItem(title: "Bottom", style: .plain, target: self, action: #selector(scrollToBottom)), + ] + + self.reset() + } + + override func configure(list : inout ListProperties) + { + list.appearance = .demoAppearance + list.layout = .demoLayout() + list.actions = self.actions + + list.content.header = DemoHeader( + title: "Approaching Bottom Pagination", + detail: """ + Uses `onApproachingBottom(within: .screens(1.0))` to load the next page as the list nears its rendered end. + Pages: \(loadedPageCount)/\(maxPageCount) + Observer Calls: \(approachingBottomCallCount) + Loading: \(isLoadingNextPage ? "Yes" : "No") + """ + ) + + list.stateObserver = ListStateObserver { observer in + observer.onApproachingBottom( + within: .screens(1.0), + shouldPerform: { [weak self] _ in + guard let self = self else { return false } + return self.isLoadingNextPage == false && self.hasMorePages + } + ) { [weak self] _ in + self?.approachingBottomCallCount += 1 + self?.loadNextPage() + } + } + + list("items") { section in + for item in self.items { + section += item + } + + if self.isLoadingNextPage { + section += PaginationLoadingItem(identifierValue: "loading-next-page") + } else if self.hasMorePages == false { + section.footer = DemoFooter(text: "Reached the end of the demo list.") + } + } + } + + @objc private func resetDemo() + { + self.reset() + } + + @objc private func scrollToBottom() + { + self.actions.scrolling.scrollToLastItem(animated: true) + } + + private func reset() + { + self.loadTask?.cancel() + self.loadTask = nil + + self.items = [] + self.nextItemNumber = 1 + self.loadedPageCount = 0 + self.approachingBottomCallCount = 0 + self.isLoadingNextPage = false + + self.appendPage() + self.reload(animated: false) + } + + private func loadNextPage() + { + guard self.isLoadingNextPage == false else { return } + guard self.hasMorePages else { return } + + self.isLoadingNextPage = true + self.reload(animated: true) + + self.loadTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_000_000_000) + + guard let self = self else { return } + guard Task.isCancelled == false else { return } + + self.isLoadingNextPage = false + self.appendPage() + self.reload(animated: true) + self.loadTask = nil + } + } + + private func appendPage() + { + let end = self.nextItemNumber + self.pageSize + + for itemNumber in self.nextItemNumber.. Element + { + Row(alignment: .center, minimumSpacing: 10.0) { + PaginationActivityIndicatorElement() + Label(text: "Loading next page…") { + $0.font = .systemFont(ofSize: 17.0, weight: .medium) + $0.color = .darkGray + } + } + .inset(horizontal: 15.0, vertical: 13.0) + } + + func backgroundElement(with info: ApplyItemContentInfo) -> Element? + { + Box( + backgroundColor: .white, + cornerStyle: .rounded(radius: 8.0) + ) + } +} + +fileprivate struct PaginationActivityIndicatorElement : UIViewElement { + func makeUIView() -> UIActivityIndicatorView + { + UIActivityIndicatorView(style: .medium) + } + + func updateUIView(_ view: UIActivityIndicatorView, with context: UIViewElementContext) + { + if context.isMeasuring == false && view.isAnimating == false { + view.startAnimating() + } + } +} diff --git a/Development/Sources/Demos/DemosRootViewController.swift b/Development/Sources/Demos/DemosRootViewController.swift index 272a8ef7..ce5e9089 100644 --- a/Development/Sources/Demos/DemosRootViewController.swift +++ b/Development/Sources/Demos/DemosRootViewController.swift @@ -105,6 +105,14 @@ public final class DemosRootViewController : ListViewController self?.push(ListStateViewController()) } ) + + Item( + DemoItem(text: "Approaching Bottom Pagination"), + selectionStyle: .selectable(), + onSelect: { _ in + self?.push(ApproachingBottomPaginationViewController()) + } + ) Item( DemoItem(text: "Itemization Editor"), diff --git a/ListableUI/Sources/ListScrollPositionInfo.swift b/ListableUI/Sources/ListScrollPositionInfo.swift index 0ec59920..4748d31f 100644 --- a/ListableUI/Sources/ListScrollPositionInfo.swift +++ b/ListableUI/Sources/ListScrollPositionInfo.swift @@ -48,6 +48,11 @@ public struct ListScrollPositionInfo : Equatable { /// `safeAreaInsests` of the list view public var safeAreaInsets: UIEdgeInsets + /// `contentSize` of the list view + public var contentSize: CGSize { + self.scrollViewState.contentSize + } + /// /// Used to retrieve the visible content edges for the list's content. /// @@ -101,6 +106,38 @@ public struct ListScrollPositionInfo : Equatable { ) } + /// Controls how close to the bottom edge of a list a user must scroll before the + /// list is considered to be approaching the bottom. + public enum BottomThreshold : Equatable { + /// The list is approaching the bottom once the final rendered item is visible. + case lastItem + + /// The list is approaching the bottom once the remaining vertical scroll distance + /// is less than or equal to the provided number of points. + case offset(CGFloat) + + /// The list is approaching the bottom once the remaining vertical scroll distance + /// is less than or equal to the provided number of visible viewport heights. + case screens(CGFloat) + } + + /// Returns whether the list is approaching the bottom for a given threshold. + public func isApproachingBottom(within threshold : BottomThreshold) -> Bool + { + switch threshold { + case .lastItem: + return isLastItemVisible + + case let .offset(offset): + return bottomScrollOffset <= max(offset, 0.0) + + case let .screens(screens): + let visibleHeight = max(bounds.height - safeAreaInsets.top - safeAreaInsets.bottom, 0.0) + let screenHeight = visibleHeight > 0.0 ? visibleHeight : bounds.height + return bottomScrollOffset <= screenHeight * max(screens, 0.0) + } + } + // // MARK: Private // diff --git a/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift new file mode 100644 index 00000000..d2f9b1f5 --- /dev/null +++ b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift @@ -0,0 +1,137 @@ +// +// ListStateObserver+ApproachingBottom.swift +// ListableUI +// +// Created by OpenAI Codex on 2026-04-24. +// + +import Foundation +import UIKit + + +extension ListStateObserver +{ + public typealias OnApproachingBottom = (ApproachingBottom) -> () + + /// Registers a callback which will be called when a vertically scrolling list approaches + /// the bottom of its rendered content. + /// + /// This convenience observer de-duplicates callbacks while the list remains within the + /// provided threshold. It re-arms once the list scrolls away from the threshold, the list's + /// content changes, or the list's viewport changes size. + /// + /// Use `shouldPerform` to gate pagination work on external state such as `isLoading` + /// or `hasMorePages`. The callback is only considered delivered once `shouldPerform` + /// returns `true`. + public mutating func onApproachingBottom( + within threshold : ListScrollPositionInfo.BottomThreshold = .screens(1.0), + shouldPerform: @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, + _ callback: @escaping OnApproachingBottom + ) + { + let observer = ApproachingBottomObserver( + threshold: threshold, + shouldPerform: shouldPerform, + callback: callback + ) + + self.onDidScroll(observer.didScroll) + self.onVisibilityChanged(observer.visibilityChanged) + self.onContentUpdated(observer.contentUpdated) + self.onFrameChanged(observer.frameChanged) + } + + /// Parameters available for ``OnApproachingBottom`` callbacks. + public struct ApproachingBottom { + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. + public let actions : ListActions + + /// The current scroll position of the list. + public let positionInfo : ListScrollPositionInfo + } +} + +private extension ListStateObserver +{ + final class ApproachingBottomObserver + { + let threshold : ListScrollPositionInfo.BottomThreshold + let shouldPerform : (ListScrollPositionInfo) -> Bool + let callback : OnApproachingBottom + + var contentVersion : Int = 0 + var lastTriggeredContext: TriggerContext? + + init( + threshold : ListScrollPositionInfo.BottomThreshold, + shouldPerform: @escaping (ListScrollPositionInfo) -> Bool, + callback: @escaping OnApproachingBottom + ) { + self.threshold = threshold + self.shouldPerform = shouldPerform + self.callback = callback + } + + func didScroll(_ info : DidScroll) + { + performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) + } + + func visibilityChanged(_ info : VisibilityChanged) + { + performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) + } + + func contentUpdated(_ info : ContentUpdated) + { + if info.hadChanges { + contentVersion += 1 + } + + performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) + } + + func frameChanged(_ info : FrameChanged) + { + performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) + } + + private func performIfNeeded(actions : ListActions, positionInfo : ListScrollPositionInfo) + { + guard positionInfo.isApproachingBottom(within: threshold) else { + lastTriggeredContext = nil + return + } + + guard shouldPerform(positionInfo) else { + return + } + + let currentContext = TriggerContext( + contentVersion: contentVersion, + boundsSize: positionInfo.bounds.size, + safeAreaInsets: positionInfo.safeAreaInsets + ) + + guard lastTriggeredContext != currentContext else { + return + } + + lastTriggeredContext = currentContext + + callback( + ApproachingBottom( + actions: actions, + positionInfo: positionInfo + ) + ) + } + } + + struct TriggerContext : Equatable + { + var contentVersion : Int + var boundsSize : CGSize + var safeAreaInsets : UIEdgeInsets + } +} diff --git a/ListableUI/Sources/ListStateObserver.swift b/ListableUI/Sources/ListStateObserver.swift index 3f22a9f5..b0b55f15 100644 --- a/ListableUI/Sources/ListStateObserver.swift +++ b/ListableUI/Sources/ListStateObserver.swift @@ -271,6 +271,14 @@ extension ListStateObserver /// The inserted and removed items. public var items : ChangedIDs + init( + sections : ChangedIDs = ChangedIDs(inserted: [], removed: []), + items : ChangedIDs = ChangedIDs(inserted: [], removed: []) + ) { + self.sections = sections + self.items = items + } + init(diff : SectionedDiff) { self.sections = ChangedIDs( diff --git a/ListableUI/Tests/ListScrollPositionInfoTests.swift b/ListableUI/Tests/ListScrollPositionInfoTests.swift index 712ef0fd..1f817001 100644 --- a/ListableUI/Tests/ListScrollPositionInfoTests.swift +++ b/ListableUI/Tests/ListScrollPositionInfoTests.swift @@ -72,6 +72,77 @@ final class UIRectEdgeTests : XCTestCase XCTAssertEqual(info.mostVisibleItem?.percentageVisible, 1.0) } + func test_isApproachingBottom_withLastItemThreshold() + { + let info = makeInfo( + contentHeight: 1000.0, + boundsHeight: 400.0, + contentOffsetY: 200.0, + isLastItemVisible: true + ) + + XCTAssertTrue(info.isApproachingBottom(within: .lastItem)) + } + + func test_isApproachingBottom_withOffsetThreshold() + { + let info = makeInfo( + contentHeight: 1000.0, + boundsHeight: 400.0, + contentOffsetY: 540.0 + ) + + XCTAssertTrue(info.isApproachingBottom(within: .offset(60.0))) + XCTAssertFalse(info.isApproachingBottom(within: .offset(59.0))) + } + + func test_isApproachingBottom_withScreensThreshold() + { + let info = makeInfo( + contentHeight: 1000.0, + boundsHeight: 400.0, + contentOffsetY: 300.0, + safeAreaInsets: UIEdgeInsets(top: 20.0, left: 0.0, bottom: 30.0, right: 0.0) + ) + + XCTAssertTrue(info.isApproachingBottom(within: .screens(1.0))) + XCTAssertFalse(info.isApproachingBottom(within: .screens(0.35))) + } + + func test_contentSize() + { + let info = makeInfo( + contentHeight: 1000.0, + boundsHeight: 400.0, + contentOffsetY: 200.0 + ) + + XCTAssertEqual(info.contentSize, CGSize(width: 100.0, height: 1000.0)) + } + + private func makeInfo( + contentHeight: CGFloat, + boundsHeight: CGFloat, + contentOffsetY: CGFloat, + safeAreaInsets: UIEdgeInsets = .zero, + isLastItemVisible: Bool = false + ) -> ListScrollPositionInfo { + let scrollView = TestScrollView() + scrollView.bounds = CGRect(origin: .zero, size: CGSize(width: 100.0, height: boundsHeight)) + scrollView.contentSize = CGSize(width: 100.0, height: contentHeight) + scrollView.contentOffset = CGPoint(x: 0.0, y: contentOffsetY) + scrollView.contentInset = .zero + scrollView.verticalScrollIndicatorInsets = .zero + scrollView.testSafeAreaInsets = safeAreaInsets + + return ListScrollPositionInfo( + scrollView: scrollView, + visibleItems: Set(), + isFirstItemVisible: false, + isLastItemVisible: isLastItemVisible + ) + } + fileprivate struct TestingType { } } @@ -88,3 +159,12 @@ final class UIEdgeInsetsTests : XCTestCase XCTAssertEqual(insets.masked(by: [.top, .left, .bottom, .right]), UIEdgeInsets(top: 10.0, left: 20.0, bottom: 30.0, right: 40.0)) } } + +private final class TestScrollView : UIScrollView +{ + var testSafeAreaInsets: UIEdgeInsets = .zero + + override var safeAreaInsets: UIEdgeInsets { + testSafeAreaInsets + } +} diff --git a/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift b/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift new file mode 100644 index 00000000..31576801 --- /dev/null +++ b/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift @@ -0,0 +1,212 @@ +// +// ListStateObserverApproachingBottomTests.swift +// ListableUI-Unit-Tests +// +// Created by OpenAI Codex on 2026-04-24. +// + +@testable import ListableUI +import XCTest + +final class ListStateObserverApproachingBottomTests : XCTestCase +{ + func test_callsBackOnceWhileRemainingWithinThreshold() + { + var callCount = 0 + + var observer = ListStateObserver() + observer.onApproachingBottom(within: .offset(100.0)) { _ in + callCount += 1 + } + + let info = makeInfo(bottomScrollOffset: 80.0) + + observer.onDidScroll.first?(didScroll(positionInfo: info)) + observer.onDidScroll.first?(didScroll(positionInfo: info)) + observer.onVisibilityChanged.first?(visibilityChanged(positionInfo: info)) + + XCTAssertEqual(callCount, 1) + } + + func test_rearmsAfterScrollingAwayFromThreshold() + { + var callCount = 0 + + var observer = ListStateObserver() + observer.onApproachingBottom(within: .offset(100.0)) { _ in + callCount += 1 + } + + observer.onDidScroll.first?(didScroll(positionInfo: makeInfo(bottomScrollOffset: 80.0))) + observer.onDidScroll.first?(didScroll(positionInfo: makeInfo(bottomScrollOffset: 180.0))) + observer.onDidScroll.first?(didScroll(positionInfo: makeInfo(bottomScrollOffset: 60.0))) + + XCTAssertEqual(callCount, 2) + } + + func test_rearmsAfterContentChangesWhileRemainingWithinThreshold() + { + var callCount = 0 + + var observer = ListStateObserver() + observer.onApproachingBottom(within: .offset(100.0)) { _ in + callCount += 1 + } + + observer.onDidScroll.first?(didScroll(positionInfo: makeInfo(bottomScrollOffset: 80.0))) + observer.onContentUpdated.first?( + contentUpdated( + positionInfo: makeInfo(bottomScrollOffset: 90.0), + hadChanges: true + ) + ) + + XCTAssertEqual(callCount, 2) + } + + func test_doesNotRearmForContentUpdatesWithoutChanges() + { + var callCount = 0 + + var observer = ListStateObserver() + observer.onApproachingBottom(within: .offset(100.0)) { _ in + callCount += 1 + } + + observer.onDidScroll.first?(didScroll(positionInfo: makeInfo(bottomScrollOffset: 80.0))) + observer.onContentUpdated.first?( + contentUpdated( + positionInfo: makeInfo(bottomScrollOffset: 80.0), + hadChanges: false + ) + ) + + XCTAssertEqual(callCount, 1) + } + + func test_shouldPerformCanDelayTheFirstCallback() + { + var callCount = 0 + var canLoadMore = false + + var observer = ListStateObserver() + observer.onApproachingBottom( + within: .offset(100.0), + shouldPerform: { _ in canLoadMore } + ) { _ in + callCount += 1 + } + + let info = makeInfo(bottomScrollOffset: 80.0) + + observer.onDidScroll.first?(didScroll(positionInfo: info)) + canLoadMore = true + observer.onDidScroll.first?(didScroll(positionInfo: info)) + + XCTAssertEqual(callCount, 1) + } + + func test_rearmsWhenViewportChanges() + { + var callCount = 0 + + var observer = ListStateObserver() + observer.onApproachingBottom(within: .screens(1.0)) { _ in + callCount += 1 + } + + observer.onDidScroll.first?( + didScroll( + positionInfo: makeInfo( + bottomScrollOffset: 300.0, + boundsHeight: 400.0 + ) + ) + ) + + observer.onFrameChanged.first?( + frameChanged( + positionInfo: makeInfo( + bottomScrollOffset: 300.0, + boundsHeight: 500.0 + ) + ) + ) + + XCTAssertEqual(callCount, 2) + } +} + +private extension ListStateObserverApproachingBottomTests +{ + func didScroll(positionInfo : ListScrollPositionInfo) -> ListStateObserver.DidScroll + { + ListStateObserver.DidScroll( + actions: ListActions(), + positionInfo: positionInfo + ) + } + + func visibilityChanged(positionInfo : ListScrollPositionInfo) -> ListStateObserver.VisibilityChanged + { + ListStateObserver.VisibilityChanged( + actions: ListActions(), + positionInfo: positionInfo, + displayed: [], + endedDisplay: [] + ) + } + + func contentUpdated( + positionInfo : ListScrollPositionInfo, + hadChanges : Bool + ) -> ListStateObserver.ContentUpdated { + ListStateObserver.ContentUpdated( + hadChanges: hadChanges, + insertionsAndRemovals: .init(), + actions: ListActions(), + positionInfo: positionInfo + ) + } + + func frameChanged(positionInfo : ListScrollPositionInfo) -> ListStateObserver.FrameChanged + { + ListStateObserver.FrameChanged( + actions: ListActions(), + positionInfo: positionInfo, + old: .zero, + new: positionInfo.bounds + ) + } + + func makeInfo( + bottomScrollOffset : CGFloat, + boundsHeight : CGFloat = 400.0, + safeAreaInsets : UIEdgeInsets = .zero, + isLastItemVisible : Bool = false + ) -> ListScrollPositionInfo { + let scrollView = TestScrollView() + scrollView.bounds = CGRect(origin: .zero, size: CGSize(width: 100.0, height: boundsHeight)) + scrollView.contentSize = CGSize(width: 100.0, height: boundsHeight + bottomScrollOffset) + scrollView.contentOffset = .zero + scrollView.contentInset = .zero + scrollView.verticalScrollIndicatorInsets = .zero + scrollView.testSafeAreaInsets = safeAreaInsets + + return ListScrollPositionInfo( + scrollView: scrollView, + visibleItems: Set(), + isFirstItemVisible: false, + isLastItemVisible: isLastItemVisible + ) + } +} + +private final class TestScrollView : UIScrollView +{ + var testSafeAreaInsets: UIEdgeInsets = .zero + + override var safeAreaInsets: UIEdgeInsets { + testSafeAreaInsets + } +}