From e4a11a2f16dda3dec394afd6e521dd5766fee638 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Fri, 24 Apr 2026 14:30:17 -0500 Subject: [PATCH 1/2] Add approaching-bottom pagination observer --- CHANGELOG.md | 3 + .../Sources/ListScrollPositionInfo.swift | 184 ++++++---- .../ListStateObserver+ApproachingBottom.swift | 126 +++++++ ListableUI/Sources/ListStateObserver.swift | 339 +++++++++--------- .../Tests/ListScrollPositionInfoTests.swift | 120 +++++-- ...tStateObserverApproachingBottomTests.swift | 201 +++++++++++ 6 files changed, 703 insertions(+), 270 deletions(-) create mode 100644 ListableUI/Sources/ListStateObserver+ApproachingBottom.swift create mode 100644 ListableUI/Tests/ListStateObserverApproachingBottomTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e39e8f1e..6e1a62bbc 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/ListableUI/Sources/ListScrollPositionInfo.swift b/ListableUI/Sources/ListScrollPositionInfo.swift index 0ec599203..918587d91 100644 --- a/ListableUI/Sources/ListScrollPositionInfo.swift +++ b/ListableUI/Sources/ListScrollPositionInfo.swift @@ -8,22 +8,22 @@ import Foundation import UIKit - /// Information about the current scroll position of a list, /// including which edges of the list are visible, and which items are visible. /// /// This is useful within callback APIs where you as a developer may want to /// perform different behavior based on the position of the list, eg, do you /// want to allow an auto-scroll action, etc. -public struct ListScrollPositionInfo : Equatable { - +public struct ListScrollPositionInfo: Equatable { // + // MARK: Public + // - + /// Which items within the list are currently visible. - public var visibleItems : Set - + public var visibleItems: Set + /// The item from `visibleItems` that has the highest percentage of visibility. public var mostVisibleItem: VisibleItem? { visibleItems.reduce(into: VisibleItem?.none) { mostVisibleItem, next in @@ -32,12 +32,12 @@ public struct ListScrollPositionInfo : Equatable { } } } - + /// If the first item list is partially visible. - public var isFirstItemVisible : Bool - + public var isFirstItemVisible: Bool + /// If the last item list is partially visible. - public var isLastItemVisible : Bool + public var isLastItemVisible: Bool /// Distance required to scroll to the bottom public var bottomScrollOffset: CGFloat @@ -47,7 +47,12 @@ public struct ListScrollPositionInfo : Equatable { /// `safeAreaInsests` of the list view public var safeAreaInsets: UIEdgeInsets - + + /// `contentSize` of the list view + public var contentSize: CGSize { + scrollViewState.contentSize + } + /// /// Used to retrieve the visible content edges for the list's content. /// @@ -90,139 +95,166 @@ public struct ListScrollPositionInfo : Equatable { /// Generally, you want to include the `safeAreaInsets` for the top, left, and right, but may want to exclude the bottom /// if you consider the bottom edge visible if it's visible below the home indicator on a home button-less iPhone or iPad. /// - public func visibleContentEdges(includingSafeAreaEdges safeAreaEdges : UIRectEdge = .all) -> UIRectEdge + public func visibleContentEdges(includingSafeAreaEdges safeAreaEdges: UIRectEdge = .all) -> UIRectEdge { - let safeArea = self.scrollViewState.safeAreaInsets.masked(by: safeAreaEdges) - + let safeArea = scrollViewState.safeAreaInsets.masked(by: safeAreaEdges) + return UIRectEdge.visibleScrollViewContentEdges( - bounds: self.scrollViewState.bounds, - contentSize: self.scrollViewState.contentSize, + bounds: scrollViewState.bounds, + contentSize: scrollViewState.contentSize, safeAreaInsets: safeArea ) } - + + /// 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 + // - - private let scrollViewState : ScrollViewState - + + private let scrollViewState: ScrollViewState + /// Creates a `ListScrollPositionInfo` for the provided scroll view. public init( - scrollView : UIScrollView, - visibleItems : Set, - isFirstItemVisible : Bool, - isLastItemVisible : Bool + scrollView: UIScrollView, + visibleItems: Set, + isFirstItemVisible: Bool, + isLastItemVisible: Bool ) { - self.scrollViewState = ScrollViewState( + scrollViewState = ScrollViewState( bounds: scrollView.bounds, - contentSize : scrollView.contentSize, + contentSize: scrollView.contentSize, safeAreaInsets: scrollView.safeAreaInsets ) - + self.visibleItems = visibleItems - + self.isFirstItemVisible = isFirstItemVisible self.isLastItemVisible = isLastItemVisible - self.bottomScrollOffset = scrollView.contentSize.height - scrollView.bounds.size.height - scrollView.contentOffset.y + scrollView.adjustedContentInset.bottom + bottomScrollOffset = scrollView.contentSize.height - scrollView.bounds.size.height - scrollView.contentOffset.y + scrollView.adjustedContentInset.bottom - self.bounds = scrollView.bounds - self.safeAreaInsets = scrollView.safeAreaInsets + bounds = scrollView.bounds + safeAreaInsets = scrollView.safeAreaInsets } - - struct ScrollViewState : Equatable - { - var bounds : CGRect - var contentSize : CGSize - var safeAreaInsets : UIEdgeInsets + + struct ScrollViewState: Equatable { + var bounds: CGRect + var contentSize: CGSize + var safeAreaInsets: UIEdgeInsets } - + public struct VisibleItem: Hashable { - public let identifier: AnyIdentifier - + /// The percentage of this item within the collection view's visible frame. public let percentageVisible: CGFloat } } -extension UIEdgeInsets -{ - func masked(by edges : UIRectEdge) -> UIEdgeInsets - { +extension UIEdgeInsets { + func masked(by edges: UIRectEdge) -> UIEdgeInsets { var insets = UIEdgeInsets() - + if edges.contains(.top) { - insets.top = self.top + insets.top = top } - + if edges.contains(.left) { - insets.left = self.left + insets.left = left } - + if edges.contains(.bottom) { - insets.bottom = self.bottom + insets.bottom = bottom } - + if edges.contains(.right) { - insets.right = self.right + insets.right = right } - + return insets } } -extension UIRectEdge : CustomDebugStringConvertible -{ +extension UIRectEdge: CustomDebugStringConvertible { static func visibleScrollViewContentEdges( - bounds : CGRect, - contentSize : CGSize, - safeAreaInsets : UIEdgeInsets - ) -> UIRectEdge - { + bounds: CGRect, + contentSize: CGSize, + safeAreaInsets: UIEdgeInsets + ) -> UIRectEdge { let insetBounds = bounds.inset(by: safeAreaInsets) - + var edges = UIRectEdge() - + if insetBounds.minY <= 0.0 { edges.formUnion(.top) } - + if insetBounds.minX <= 0.0 { edges.formUnion(.left) } - + if insetBounds.maxY >= contentSize.height { edges.formUnion(.bottom) } - + if insetBounds.maxX >= contentSize.width { edges.formUnion(.right) } - + return edges } - + public var debugDescription: String { var components = [String]() - - if self.contains(.top) { + + if contains(.top) { components += [".top"] } - - if self.contains(.left) { + + if contains(.left) { components += [".left"] } - - if self.contains(.bottom) { + + if contains(.bottom) { components += [".bottom"] } - - if self.contains(.right) { + + if contains(.right) { components += [".right"] } - + return "UIRectEdge(\(components.joined(separator: ", ")))" } } diff --git a/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift new file mode 100644 index 000000000..70d09532c --- /dev/null +++ b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift @@ -0,0 +1,126 @@ +// +// ListStateObserver+ApproachingBottom.swift +// ListableUI +// +// Created by OpenAI Codex on 2026-04-24. +// + +import Foundation +import UIKit + +public extension ListStateObserver { + typealias OnApproachingBottom = (ApproachingBottom) -> Void + + /// 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`. + 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 + ) + + onDidScroll(observer.didScroll) + onVisibilityChanged(observer.visibilityChanged) + onContentUpdated(observer.contentUpdated) + onFrameChanged(observer.frameChanged) + } + + /// Parameters available for ``OnApproachingBottom`` callbacks. + 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 3f22a9f5b..4ca92ac09 100644 --- a/ListableUI/Sources/ListStateObserver.swift +++ b/ListableUI/Sources/ListStateObserver.swift @@ -8,7 +8,6 @@ import Foundation import UIKit - /// Allows reading state and events based on state changes within the list view. /// For example, you can determine when a user scrolls, when the content of a list /// changes, etc. @@ -41,18 +40,18 @@ import UIKit /// use Instruments.app to see what callback is slow. /// public struct ListStateObserver { - /// Creates and optionally allows you to configure an observer. - public init(_ configure : (inout ListStateObserver) -> () = { _ in }) - { + public init(_ configure: (inout ListStateObserver) -> Void = { _ in }) { configure(&self) } - + // + // MARK: Responding To Scrolling + // - - public typealias OnDidScroll = (DidScroll) -> () + + public typealias OnDidScroll = (DidScroll) -> Void /// Registers a callback which will be called when the list view is scrolled, or is /// scrolled to top. @@ -60,202 +59,209 @@ public struct ListStateObserver { /// ### ⚠️ Important Note! /// This callback is called very frequently when the user is scrolling the list (eg, every frame!). /// As such, make sure any work you do in the callback is efficient. - public mutating func onDidScroll( _ callback : @escaping OnDidScroll) - { - self.onDidScroll.append(callback) + public mutating func onDidScroll(_ callback: @escaping OnDidScroll) { + onDidScroll.append(callback) } - - private(set) var onDidScroll : [OnDidScroll] = [] - + + private(set) var onDidScroll: [OnDidScroll] = [] + // + // MARK: Responding to Scrolling Deceleration + // - public typealias OnDidEndDeceleration = (DidEndDeceleration) -> () + public typealias OnDidEndDeceleration = (DidEndDeceleration) -> Void /// Registers a callback which will be called when the list view is finished decelerating. - public mutating func onDidEndDeceleration( _ callback : @escaping OnDidEndDeceleration) - { - self.onDidEndDeceleration.append(callback) + public mutating func onDidEndDeceleration(_ callback: @escaping OnDidEndDeceleration) { + onDidEndDeceleration.append(callback) } private(set) var onDidEndDeceleration: [OnDidEndDeceleration] = [] // + // MARK: Responding to Scrolling Animation Ending + // - public typealias OnDidEndScrollingAnimation = (DidEndScrollingAnimation) -> () + public typealias OnDidEndScrollingAnimation = (DidEndScrollingAnimation) -> Void /// Registers a callback which will be called when the list view had ended scrolling animation. - public mutating func onDidEndScrollingAnimation( _ callback : @escaping OnDidEndScrollingAnimation) + public mutating func onDidEndScrollingAnimation(_ callback: @escaping OnDidEndScrollingAnimation) { - self.onDidEndScrollingAnimation.append(callback) + onDidEndScrollingAnimation.append(callback) } private(set) var onDidEndScrollingAnimation: [OnDidEndScrollingAnimation] = [] // + // MARK: Responding to Drag Begin + // - - public typealias OnBeginDrag = (BeginDrag) -> () - + + public typealias OnBeginDrag = (BeginDrag) -> Void + /// Registers a callback which will be called when the list view will begin dragging. - public mutating func onBeginDrag( _ callback: @escaping OnBeginDrag) - { - self.onBeginDrag.append(callback) + public mutating func onBeginDrag(_ callback: @escaping OnBeginDrag) { + onBeginDrag.append(callback) } - + private(set) var onBeginDrag: [OnBeginDrag] = [] - + // + // MARK: Responding To Content Updates + // - - public typealias OnContentUpdated = (ContentUpdated) -> () - + + public typealias OnContentUpdated = (ContentUpdated) -> Void + /// Registers a callback which will be called when the list view's content is updated /// due to a call to `setContent`. /// /// ### Note /// This method is called even if there were no actual changes made during the `setContent` /// call. To see if there were changes, check the `hadChanges` property on `ContentUpdated`. - public mutating func onContentUpdated( _ callback : @escaping OnContentUpdated) - { - self.onContentUpdated.append(callback) + public mutating func onContentUpdated(_ callback: @escaping OnContentUpdated) { + onContentUpdated.append(callback) } - - private(set) var onContentUpdated : [OnContentUpdated] = [] - + + private(set) var onContentUpdated: [OnContentUpdated] = [] + // + // MARK: Responding To Visibility Changes + // - - public typealias OnVisibilityChanged = (VisibilityChanged) -> () - + + public typealias OnVisibilityChanged = (VisibilityChanged) -> Void + /// Registers a callback which will be called when the visiblity of content within the list changes, /// either due to the user scrolling the list, or due to an update changing the visible content. /// /// If you'd like to (eg) update a pagination indicator or other indicator of what /// items / pages / etc are visible, use this method. - public mutating func onVisibilityChanged( _ callback : @escaping OnVisibilityChanged) - { - self.onVisibilityChanged.append(callback) + public mutating func onVisibilityChanged(_ callback: @escaping OnVisibilityChanged) { + onVisibilityChanged.append(callback) } - - private(set) var onVisibilityChanged : [OnVisibilityChanged] = [] - + + private(set) var onVisibilityChanged: [OnVisibilityChanged] = [] + // + // MARK: Responding To Frame Changes + // - - public typealias OnFrameChanged = (FrameChanged) -> () - + + public typealias OnFrameChanged = (FrameChanged) -> Void + /// Registers a callback which will be called when the list view's frame is changed. - public mutating func onFrameChanged(_ callback : @escaping OnFrameChanged) - { - self.onFrameChanged.append(callback) + public mutating func onFrameChanged(_ callback: @escaping OnFrameChanged) { + onFrameChanged.append(callback) } - - private(set) var onFrameChanged : [OnFrameChanged] = [] - + + private(set) var onFrameChanged: [OnFrameChanged] = [] + // + // MARK: Responding To Selection Changes + // - - public typealias OnSelectionChanged = (SelectionChanged) -> () - + + public typealias OnSelectionChanged = (SelectionChanged) -> Void + /// Registers a callback which will be called when the list view's selected items are changed by the user. - public mutating func onSelectionChanged(_ callback : @escaping OnSelectionChanged) - { - self.onSelectionChanged.append(callback) + public mutating func onSelectionChanged(_ callback: @escaping OnSelectionChanged) { + onSelectionChanged.append(callback) } - - private(set) var onSelectionChanged : [OnSelectionChanged] = [] - + + private(set) var onSelectionChanged: [OnSelectionChanged] = [] + // + // MARK: Responding To Reordered Items + // - - public typealias OnItemReordered = (ItemReordered) -> () - + + public typealias OnItemReordered = (ItemReordered) -> Void + /// Registers a callback which will be called when an item in the list view is reordered by the customer. /// May be called multiple times in a row for reorder events which contain multiple items. - public mutating func onItemReordered(_ callback : @escaping OnItemReordered) - { - self.onItemReordered.append(callback) + public mutating func onItemReordered(_ callback: @escaping OnItemReordered) { + onItemReordered.append(callback) } - - private(set) var onItemReordered : [OnItemReordered] = [] - + + private(set) var onItemReordered: [OnItemReordered] = [] + // + // MARK: Internal Methods + // - + static func perform( - _ callbacks : Array<(CallbackInfo) -> ()>, - _ loggingName : StaticString, - with listView : ListView, makeInfo : (ListActions) -> (CallbackInfo) - ){ + _ callbacks: [(CallbackInfo) -> Void], + _ loggingName: StaticString, + with listView: ListView, makeInfo: (ListActions) -> (CallbackInfo) + ) { guard callbacks.isEmpty == false else { return } - + let actions = ListActions() actions.listView = listView - + let callbackInfo = makeInfo(actions) - + SignpostLogger.log(log: .stateObserver, name: loggingName, for: listView) { callbacks.forEach { $0(callbackInfo) } } - + actions.listView = nil } } - -extension ListStateObserver -{ +public extension ListStateObserver { /// Parameters available for ``OnDidScroll`` callbacks. - public struct DidScroll { - public let actions : ListActions - public let positionInfo : ListScrollPositionInfo + struct DidScroll { + public let actions: ListActions + public let positionInfo: ListScrollPositionInfo } - + /// Parameters available for ``OnDidEndDeceleration`` callbacks. - public struct DidEndDeceleration { - public let positionInfo : ListScrollPositionInfo + struct DidEndDeceleration { + public let positionInfo: ListScrollPositionInfo } /// Parameters available for ``OnDidEndScrollingAnimation`` callbacks. - public struct DidEndScrollingAnimation { - public let positionInfo : ListScrollPositionInfo + struct DidEndScrollingAnimation { + public let positionInfo: ListScrollPositionInfo } /// Parameters available for ``OnBeginDrag`` callbacks. - public struct BeginDrag { - public let positionInfo : ListScrollPositionInfo + struct BeginDrag { + public let positionInfo: ListScrollPositionInfo } - + /// Parameters available for ``OnContentUpdated`` callbacks. - public struct ContentUpdated { - + struct ContentUpdated { // If there were any changes included in this content update. - public let hadChanges : Bool - + public let hadChanges: Bool + /// The insertions and removals in this change, if any. - public let insertionsAndRemovals : InsertionsAndRemovals - + public let insertionsAndRemovals: InsertionsAndRemovals + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions : ListActions - + public let actions: ListActions + /// The current scroll position of the list. - public let positionInfo : ListScrollPositionInfo - + public let positionInfo: ListScrollPositionInfo + /// The insertions and removals, for both sections and items, applied to a list /// as the result of an update. /// @@ -264,106 +270,103 @@ extension ListStateObserver /// contains a `Set`, two sections inserting (or removing) an item with an equal ID /// will only be included in `ChangedIDs.inserted/removed` set once. public struct InsertionsAndRemovals { - /// The inserted and removed sections. - public var sections : ChangedIDs - + public var sections: ChangedIDs + /// The inserted and removed items. - public var items : ChangedIDs - - init(diff : SectionedDiff) { - - self.sections = ChangedIDs( - inserted: Set(diff.changes.added.map{ $0.identifier }), - removed: Set(diff.changes.removed.map{ $0.identifier }) + public var items: ChangedIDs + + init( + sections: ChangedIDs = ChangedIDs(inserted: [], removed: []), + items: ChangedIDs = ChangedIDs(inserted: [], removed: []) + ) { + self.sections = sections + self.items = items + } + + init(diff: SectionedDiff) { + sections = ChangedIDs( + inserted: Set(diff.changes.added.map { $0.identifier }), + removed: Set(diff.changes.removed.map { $0.identifier }) ) - - self.items = ChangedIDs( + + items = ChangedIDs( inserted: diff.changes.addedItemIdentifiers, removed: diff.changes.removedItemIdentifiers ) } - + /// The changed IDs. public struct ChangedIDs { - /// The inserted IDs. - public var inserted : Set - + public var inserted: Set + /// The removed IDs. - public var removed : Set + public var removed: Set } } } - - + /// Parameters available for ``OnVisibilityChanged`` callbacks. - public struct VisibilityChanged { - + struct VisibilityChanged { /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions : ListActions - + public let actions: ListActions + /// The current scroll position of the list. - public let positionInfo : ListScrollPositionInfo - + public let positionInfo: ListScrollPositionInfo + /// The items which were scrolled into view or otherwise became visible. - public let displayed : [AnyItem] - + public let displayed: [AnyItem] + /// The items which were scrolled out of view or otherwise were removed from view. - public let endedDisplay : [AnyItem] + public let endedDisplay: [AnyItem] } - - + /// Parameters available for ``OnFrameChanged`` callbacks. - public struct FrameChanged { - + struct FrameChanged { /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions : ListActions - + public let actions: ListActions + /// The current scroll position of the list. - public let positionInfo : ListScrollPositionInfo + public let positionInfo: ListScrollPositionInfo /// The old frame within the bounds of the list. - public let old : CGRect - + public let old: CGRect + /// The new frame within the bounds of the list. - public let new : CGRect + public let new: CGRect } - - + /// Parameters available for ``OnSelectionChanged`` callbacks. - public struct SelectionChanged { - + struct SelectionChanged { /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions : ListActions - + public let actions: ListActions + /// The current scroll position of the list. - public let positionInfo : ListScrollPositionInfo + public let positionInfo: ListScrollPositionInfo /// The previously selected items' identifiers. - public let old : Set - + public let old: Set + /// The newly selected items' identifiers. - public let new : Set + public let new: Set } - - + /// Parameters available for ``OnItemReordered`` callbacks. - public struct ItemReordered { - + struct ItemReordered { /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions : ListActions - + public let actions: ListActions + /// The current scroll position of the list. - public let positionInfo : ListScrollPositionInfo - + public let positionInfo: ListScrollPositionInfo + /// The item which was reordered by the customer. - public let item : AnyItem - + public let item: AnyItem + /// The new state of all sections in the list. - public let sections : [Section] - - /// The detailed information about the reorder event. - public let result : ItemReordering.Result + public let sections: [Section] + + /// The detailed information about the reorder event. + public let result: ItemReordering.Result } } diff --git a/ListableUI/Tests/ListScrollPositionInfoTests.swift b/ListableUI/Tests/ListScrollPositionInfoTests.swift index 712ef0fd9..2c3cab90e 100644 --- a/ListableUI/Tests/ListScrollPositionInfoTests.swift +++ b/ListableUI/Tests/ListScrollPositionInfoTests.swift @@ -5,82 +5,142 @@ // Created by Kyle Van Essen on 5/4/20. // -import XCTest -import UIKit @testable import ListableUI +import UIKit +import XCTest - -final class UIRectEdgeTests : XCTestCase -{ - func test_visibleScrollViewContentEdges() - { +final class UIRectEdgeTests: XCTestCase { + func test_visibleScrollViewContentEdges() { do { // No offset + no safe area should mean all edges are visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: .zero, size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: .zero ) - + XCTAssertEqual(edges, .all) } - + do { // No offset + safe area should mean the edges outside the safe area are not visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: .zero, size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 20.0, right: 10.0) ) - + XCTAssertEqual(edges, [.bottom, .right]) } do { // No offset + safe area should mean the edges outside the safe area are not visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: CGPoint(x: -100.0, y: -50.0), size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 20.0, right: 10.0) ) - + XCTAssertEqual(edges, [.top, .left]) } - } - + func test_mostVisibleItem() { - let items: Set = [ .init(identifier: Identifier(0), percentageVisible: 0.25), .init(identifier: Identifier(1), percentageVisible: 0.5), .init(identifier: Identifier(2), percentageVisible: 1.0), .init(identifier: Identifier(3), percentageVisible: 0.0), ] - + let info = ListScrollPositionInfo( scrollView: UIScrollView(), visibleItems: items, isFirstItemVisible: true, isLastItemVisible: false ) - + XCTAssertEqual(info.mostVisibleItem?.identifier.anyValue, 2) XCTAssertEqual(info.mostVisibleItem?.percentageVisible, 1.0) } - - fileprivate struct TestingType { } + + 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)) + } + + fileprivate struct TestingType {} + + 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 + ) + } } -final class UIEdgeInsetsTests : XCTestCase -{ - func test_masked() - { +final class UIEdgeInsetsTests: XCTestCase { + func test_masked() { let insets = UIEdgeInsets(top: 10.0, left: 20.0, bottom: 30.0, right: 40.0) - + XCTAssertEqual(insets.masked(by: []), UIEdgeInsets()) XCTAssertEqual(insets.masked(by: [.top]), UIEdgeInsets(top: 10.0, left: 0.0, bottom: 0.0, right: 0.0)) XCTAssertEqual(insets.masked(by: [.top, .left]), UIEdgeInsets(top: 10.0, left: 20.0, bottom: 0.0, right: 0.0)) @@ -88,3 +148,11 @@ 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 000000000..433ce412a --- /dev/null +++ b/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift @@ -0,0 +1,201 @@ +// +// 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 + } +} From ed67c30bd86453e98b6f74cead7b52857e22f545 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Fri, 24 Apr 2026 14:43:32 -0500 Subject: [PATCH 2/2] Add pagination demo and reduce diff churn --- ...achingBottomPaginationViewController.swift | 191 ++++++++++ .../Demos/DemosRootViewController.swift | 8 + .../Sources/ListScrollPositionInfo.swift | 169 ++++----- .../ListStateObserver+ApproachingBottom.swift | 67 ++-- ListableUI/Sources/ListStateObserver.swift | 337 +++++++++--------- .../Tests/ListScrollPositionInfoTests.swift | 82 +++-- ...tStateObserverApproachingBottomTests.swift | 47 ++- 7 files changed, 572 insertions(+), 329 deletions(-) create mode 100644 Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift diff --git a/Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift b/Development/Sources/Demos/Demo Screens/ApproachingBottomPaginationViewController.swift new file mode 100644 index 000000000..676daa98c --- /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 272a8ef73..ce5e90890 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 918587d91..4748d31f8 100644 --- a/ListableUI/Sources/ListScrollPositionInfo.swift +++ b/ListableUI/Sources/ListScrollPositionInfo.swift @@ -8,22 +8,22 @@ import Foundation import UIKit + /// Information about the current scroll position of a list, /// including which edges of the list are visible, and which items are visible. /// /// This is useful within callback APIs where you as a developer may want to /// perform different behavior based on the position of the list, eg, do you /// want to allow an auto-scroll action, etc. -public struct ListScrollPositionInfo: Equatable { +public struct ListScrollPositionInfo : Equatable { + // - // MARK: Public - // - + /// Which items within the list are currently visible. - public var visibleItems: Set - + public var visibleItems : Set + /// The item from `visibleItems` that has the highest percentage of visibility. public var mostVisibleItem: VisibleItem? { visibleItems.reduce(into: VisibleItem?.none) { mostVisibleItem, next in @@ -32,12 +32,12 @@ public struct ListScrollPositionInfo: Equatable { } } } - + /// If the first item list is partially visible. - public var isFirstItemVisible: Bool - + public var isFirstItemVisible : Bool + /// If the last item list is partially visible. - public var isLastItemVisible: Bool + public var isLastItemVisible : Bool /// Distance required to scroll to the bottom public var bottomScrollOffset: CGFloat @@ -47,12 +47,12 @@ public struct ListScrollPositionInfo: Equatable { /// `safeAreaInsests` of the list view public var safeAreaInsets: UIEdgeInsets - + /// `contentSize` of the list view public var contentSize: CGSize { - scrollViewState.contentSize + self.scrollViewState.contentSize } - + /// /// Used to retrieve the visible content edges for the list's content. /// @@ -95,166 +95,171 @@ public struct ListScrollPositionInfo: Equatable { /// Generally, you want to include the `safeAreaInsets` for the top, left, and right, but may want to exclude the bottom /// if you consider the bottom edge visible if it's visible below the home indicator on a home button-less iPhone or iPad. /// - public func visibleContentEdges(includingSafeAreaEdges safeAreaEdges: UIRectEdge = .all) -> UIRectEdge + public func visibleContentEdges(includingSafeAreaEdges safeAreaEdges : UIRectEdge = .all) -> UIRectEdge { - let safeArea = scrollViewState.safeAreaInsets.masked(by: safeAreaEdges) - + let safeArea = self.scrollViewState.safeAreaInsets.masked(by: safeAreaEdges) + return UIRectEdge.visibleScrollViewContentEdges( - bounds: scrollViewState.bounds, - contentSize: scrollViewState.contentSize, + bounds: self.scrollViewState.bounds, + contentSize: self.scrollViewState.contentSize, safeAreaInsets: safeArea ) } - + /// 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 { + 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 { + 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 - // - - private let scrollViewState: ScrollViewState - + + private let scrollViewState : ScrollViewState + /// Creates a `ListScrollPositionInfo` for the provided scroll view. public init( - scrollView: UIScrollView, - visibleItems: Set, - isFirstItemVisible: Bool, - isLastItemVisible: Bool + scrollView : UIScrollView, + visibleItems : Set, + isFirstItemVisible : Bool, + isLastItemVisible : Bool ) { - scrollViewState = ScrollViewState( + self.scrollViewState = ScrollViewState( bounds: scrollView.bounds, - contentSize: scrollView.contentSize, + contentSize : scrollView.contentSize, safeAreaInsets: scrollView.safeAreaInsets ) - + self.visibleItems = visibleItems - + self.isFirstItemVisible = isFirstItemVisible self.isLastItemVisible = isLastItemVisible - bottomScrollOffset = scrollView.contentSize.height - scrollView.bounds.size.height - scrollView.contentOffset.y + scrollView.adjustedContentInset.bottom + self.bottomScrollOffset = scrollView.contentSize.height - scrollView.bounds.size.height - scrollView.contentOffset.y + scrollView.adjustedContentInset.bottom - bounds = scrollView.bounds - safeAreaInsets = scrollView.safeAreaInsets + self.bounds = scrollView.bounds + self.safeAreaInsets = scrollView.safeAreaInsets } - - struct ScrollViewState: Equatable { - var bounds: CGRect - var contentSize: CGSize - var safeAreaInsets: UIEdgeInsets + + struct ScrollViewState : Equatable + { + var bounds : CGRect + var contentSize : CGSize + var safeAreaInsets : UIEdgeInsets } - + public struct VisibleItem: Hashable { + public let identifier: AnyIdentifier - + /// The percentage of this item within the collection view's visible frame. public let percentageVisible: CGFloat } } -extension UIEdgeInsets { - func masked(by edges: UIRectEdge) -> UIEdgeInsets { +extension UIEdgeInsets +{ + func masked(by edges : UIRectEdge) -> UIEdgeInsets + { var insets = UIEdgeInsets() - + if edges.contains(.top) { - insets.top = top + insets.top = self.top } - + if edges.contains(.left) { - insets.left = left + insets.left = self.left } - + if edges.contains(.bottom) { - insets.bottom = bottom + insets.bottom = self.bottom } - + if edges.contains(.right) { - insets.right = right + insets.right = self.right } - + return insets } } -extension UIRectEdge: CustomDebugStringConvertible { +extension UIRectEdge : CustomDebugStringConvertible +{ static func visibleScrollViewContentEdges( - bounds: CGRect, - contentSize: CGSize, - safeAreaInsets: UIEdgeInsets - ) -> UIRectEdge { + bounds : CGRect, + contentSize : CGSize, + safeAreaInsets : UIEdgeInsets + ) -> UIRectEdge + { let insetBounds = bounds.inset(by: safeAreaInsets) - + var edges = UIRectEdge() - + if insetBounds.minY <= 0.0 { edges.formUnion(.top) } - + if insetBounds.minX <= 0.0 { edges.formUnion(.left) } - + if insetBounds.maxY >= contentSize.height { edges.formUnion(.bottom) } - + if insetBounds.maxX >= contentSize.width { edges.formUnion(.right) } - + return edges } - + public var debugDescription: String { var components = [String]() - - if contains(.top) { + + if self.contains(.top) { components += [".top"] } - - if contains(.left) { + + if self.contains(.left) { components += [".left"] } - - if contains(.bottom) { + + if self.contains(.bottom) { components += [".bottom"] } - - if contains(.right) { + + if self.contains(.right) { components += [".right"] } - + return "UIRectEdge(\(components.joined(separator: ", ")))" } } diff --git a/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift index 70d09532c..d2f9b1f55 100644 --- a/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift +++ b/ListableUI/Sources/ListStateObserver+ApproachingBottom.swift @@ -8,8 +8,10 @@ import Foundation import UIKit -public extension ListStateObserver { - typealias OnApproachingBottom = (ApproachingBottom) -> Void + +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. @@ -21,44 +23,47 @@ public extension ListStateObserver { /// Use `shouldPerform` to gate pagination work on external state such as `isLoading` /// or `hasMorePages`. The callback is only considered delivered once `shouldPerform` /// returns `true`. - mutating func onApproachingBottom( - within threshold: ListScrollPositionInfo.BottomThreshold = .screens(1.0), + 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 ) - onDidScroll(observer.didScroll) - onVisibilityChanged(observer.visibilityChanged) - onContentUpdated(observer.contentUpdated) - onFrameChanged(observer.frameChanged) + self.onDidScroll(observer.didScroll) + self.onVisibilityChanged(observer.visibilityChanged) + self.onContentUpdated(observer.contentUpdated) + self.onFrameChanged(observer.frameChanged) } /// Parameters available for ``OnApproachingBottom`` callbacks. - struct ApproachingBottom { + 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 + public let actions : ListActions /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo + public let positionInfo : ListScrollPositionInfo } } -private extension ListStateObserver { - final class ApproachingBottomObserver { - let threshold: ListScrollPositionInfo.BottomThreshold - let shouldPerform: (ListScrollPositionInfo) -> Bool - let callback: OnApproachingBottom +private extension ListStateObserver +{ + final class ApproachingBottomObserver + { + let threshold : ListScrollPositionInfo.BottomThreshold + let shouldPerform : (ListScrollPositionInfo) -> Bool + let callback : OnApproachingBottom - var contentVersion: Int = 0 + var contentVersion : Int = 0 var lastTriggeredContext: TriggerContext? init( - threshold: ListScrollPositionInfo.BottomThreshold, + threshold : ListScrollPositionInfo.BottomThreshold, shouldPerform: @escaping (ListScrollPositionInfo) -> Bool, callback: @escaping OnApproachingBottom ) { @@ -67,15 +72,18 @@ private extension ListStateObserver { self.callback = callback } - func didScroll(_ info: DidScroll) { + func didScroll(_ info : DidScroll) + { performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) } - func visibilityChanged(_ info: VisibilityChanged) { + func visibilityChanged(_ info : VisibilityChanged) + { performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) } - func contentUpdated(_ info: ContentUpdated) { + func contentUpdated(_ info : ContentUpdated) + { if info.hadChanges { contentVersion += 1 } @@ -83,11 +91,13 @@ private extension ListStateObserver { performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) } - func frameChanged(_ info: FrameChanged) { + func frameChanged(_ info : FrameChanged) + { performIfNeeded(actions: info.actions, positionInfo: info.positionInfo) } - private func performIfNeeded(actions: ListActions, positionInfo: ListScrollPositionInfo) { + private func performIfNeeded(actions : ListActions, positionInfo : ListScrollPositionInfo) + { guard positionInfo.isApproachingBottom(within: threshold) else { lastTriggeredContext = nil return @@ -118,9 +128,10 @@ private extension ListStateObserver { } } - struct TriggerContext: Equatable { - var contentVersion: Int - var boundsSize: CGSize - var safeAreaInsets: UIEdgeInsets + 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 4ca92ac09..b0b55f153 100644 --- a/ListableUI/Sources/ListStateObserver.swift +++ b/ListableUI/Sources/ListStateObserver.swift @@ -8,6 +8,7 @@ import Foundation import UIKit + /// Allows reading state and events based on state changes within the list view. /// For example, you can determine when a user scrolls, when the content of a list /// changes, etc. @@ -40,18 +41,18 @@ import UIKit /// use Instruments.app to see what callback is slow. /// public struct ListStateObserver { + /// Creates and optionally allows you to configure an observer. - public init(_ configure: (inout ListStateObserver) -> Void = { _ in }) { + public init(_ configure : (inout ListStateObserver) -> () = { _ in }) + { configure(&self) } - + // - // MARK: Responding To Scrolling - // - - public typealias OnDidScroll = (DidScroll) -> Void + + public typealias OnDidScroll = (DidScroll) -> () /// Registers a callback which will be called when the list view is scrolled, or is /// scrolled to top. @@ -59,209 +60,202 @@ public struct ListStateObserver { /// ### ⚠️ Important Note! /// This callback is called very frequently when the user is scrolling the list (eg, every frame!). /// As such, make sure any work you do in the callback is efficient. - public mutating func onDidScroll(_ callback: @escaping OnDidScroll) { - onDidScroll.append(callback) + public mutating func onDidScroll( _ callback : @escaping OnDidScroll) + { + self.onDidScroll.append(callback) } - - private(set) var onDidScroll: [OnDidScroll] = [] - + + private(set) var onDidScroll : [OnDidScroll] = [] + // - // MARK: Responding to Scrolling Deceleration - // - public typealias OnDidEndDeceleration = (DidEndDeceleration) -> Void + public typealias OnDidEndDeceleration = (DidEndDeceleration) -> () /// Registers a callback which will be called when the list view is finished decelerating. - public mutating func onDidEndDeceleration(_ callback: @escaping OnDidEndDeceleration) { - onDidEndDeceleration.append(callback) + public mutating func onDidEndDeceleration( _ callback : @escaping OnDidEndDeceleration) + { + self.onDidEndDeceleration.append(callback) } private(set) var onDidEndDeceleration: [OnDidEndDeceleration] = [] // - // MARK: Responding to Scrolling Animation Ending - // - public typealias OnDidEndScrollingAnimation = (DidEndScrollingAnimation) -> Void + public typealias OnDidEndScrollingAnimation = (DidEndScrollingAnimation) -> () /// Registers a callback which will be called when the list view had ended scrolling animation. - public mutating func onDidEndScrollingAnimation(_ callback: @escaping OnDidEndScrollingAnimation) + public mutating func onDidEndScrollingAnimation( _ callback : @escaping OnDidEndScrollingAnimation) { - onDidEndScrollingAnimation.append(callback) + self.onDidEndScrollingAnimation.append(callback) } private(set) var onDidEndScrollingAnimation: [OnDidEndScrollingAnimation] = [] // - // MARK: Responding to Drag Begin - // - - public typealias OnBeginDrag = (BeginDrag) -> Void - + + public typealias OnBeginDrag = (BeginDrag) -> () + /// Registers a callback which will be called when the list view will begin dragging. - public mutating func onBeginDrag(_ callback: @escaping OnBeginDrag) { - onBeginDrag.append(callback) + public mutating func onBeginDrag( _ callback: @escaping OnBeginDrag) + { + self.onBeginDrag.append(callback) } - + private(set) var onBeginDrag: [OnBeginDrag] = [] - + // - // MARK: Responding To Content Updates - // - - public typealias OnContentUpdated = (ContentUpdated) -> Void - + + public typealias OnContentUpdated = (ContentUpdated) -> () + /// Registers a callback which will be called when the list view's content is updated /// due to a call to `setContent`. /// /// ### Note /// This method is called even if there were no actual changes made during the `setContent` /// call. To see if there were changes, check the `hadChanges` property on `ContentUpdated`. - public mutating func onContentUpdated(_ callback: @escaping OnContentUpdated) { - onContentUpdated.append(callback) + public mutating func onContentUpdated( _ callback : @escaping OnContentUpdated) + { + self.onContentUpdated.append(callback) } - - private(set) var onContentUpdated: [OnContentUpdated] = [] - + + private(set) var onContentUpdated : [OnContentUpdated] = [] + // - // MARK: Responding To Visibility Changes - // - - public typealias OnVisibilityChanged = (VisibilityChanged) -> Void - + + public typealias OnVisibilityChanged = (VisibilityChanged) -> () + /// Registers a callback which will be called when the visiblity of content within the list changes, /// either due to the user scrolling the list, or due to an update changing the visible content. /// /// If you'd like to (eg) update a pagination indicator or other indicator of what /// items / pages / etc are visible, use this method. - public mutating func onVisibilityChanged(_ callback: @escaping OnVisibilityChanged) { - onVisibilityChanged.append(callback) + public mutating func onVisibilityChanged( _ callback : @escaping OnVisibilityChanged) + { + self.onVisibilityChanged.append(callback) } - - private(set) var onVisibilityChanged: [OnVisibilityChanged] = [] - + + private(set) var onVisibilityChanged : [OnVisibilityChanged] = [] + // - // MARK: Responding To Frame Changes - // - - public typealias OnFrameChanged = (FrameChanged) -> Void - + + public typealias OnFrameChanged = (FrameChanged) -> () + /// Registers a callback which will be called when the list view's frame is changed. - public mutating func onFrameChanged(_ callback: @escaping OnFrameChanged) { - onFrameChanged.append(callback) + public mutating func onFrameChanged(_ callback : @escaping OnFrameChanged) + { + self.onFrameChanged.append(callback) } - - private(set) var onFrameChanged: [OnFrameChanged] = [] - + + private(set) var onFrameChanged : [OnFrameChanged] = [] + // - // MARK: Responding To Selection Changes - // - - public typealias OnSelectionChanged = (SelectionChanged) -> Void - + + public typealias OnSelectionChanged = (SelectionChanged) -> () + /// Registers a callback which will be called when the list view's selected items are changed by the user. - public mutating func onSelectionChanged(_ callback: @escaping OnSelectionChanged) { - onSelectionChanged.append(callback) + public mutating func onSelectionChanged(_ callback : @escaping OnSelectionChanged) + { + self.onSelectionChanged.append(callback) } - - private(set) var onSelectionChanged: [OnSelectionChanged] = [] - + + private(set) var onSelectionChanged : [OnSelectionChanged] = [] + // - // MARK: Responding To Reordered Items - // - - public typealias OnItemReordered = (ItemReordered) -> Void - + + public typealias OnItemReordered = (ItemReordered) -> () + /// Registers a callback which will be called when an item in the list view is reordered by the customer. /// May be called multiple times in a row for reorder events which contain multiple items. - public mutating func onItemReordered(_ callback: @escaping OnItemReordered) { - onItemReordered.append(callback) + public mutating func onItemReordered(_ callback : @escaping OnItemReordered) + { + self.onItemReordered.append(callback) } - - private(set) var onItemReordered: [OnItemReordered] = [] - + + private(set) var onItemReordered : [OnItemReordered] = [] + // - // MARK: Internal Methods - // - + static func perform( - _ callbacks: [(CallbackInfo) -> Void], - _ loggingName: StaticString, - with listView: ListView, makeInfo: (ListActions) -> (CallbackInfo) - ) { + _ callbacks : Array<(CallbackInfo) -> ()>, + _ loggingName : StaticString, + with listView : ListView, makeInfo : (ListActions) -> (CallbackInfo) + ){ guard callbacks.isEmpty == false else { return } - + let actions = ListActions() actions.listView = listView - + let callbackInfo = makeInfo(actions) - + SignpostLogger.log(log: .stateObserver, name: loggingName, for: listView) { callbacks.forEach { $0(callbackInfo) } } - + actions.listView = nil } } -public extension ListStateObserver { + +extension ListStateObserver +{ /// Parameters available for ``OnDidScroll`` callbacks. - struct DidScroll { - public let actions: ListActions - public let positionInfo: ListScrollPositionInfo + public struct DidScroll { + public let actions : ListActions + public let positionInfo : ListScrollPositionInfo } - + /// Parameters available for ``OnDidEndDeceleration`` callbacks. - struct DidEndDeceleration { - public let positionInfo: ListScrollPositionInfo + public struct DidEndDeceleration { + public let positionInfo : ListScrollPositionInfo } /// Parameters available for ``OnDidEndScrollingAnimation`` callbacks. - struct DidEndScrollingAnimation { - public let positionInfo: ListScrollPositionInfo + public struct DidEndScrollingAnimation { + public let positionInfo : ListScrollPositionInfo } /// Parameters available for ``OnBeginDrag`` callbacks. - struct BeginDrag { - public let positionInfo: ListScrollPositionInfo + public struct BeginDrag { + public let positionInfo : ListScrollPositionInfo } - + /// Parameters available for ``OnContentUpdated`` callbacks. - struct ContentUpdated { + public struct ContentUpdated { + // If there were any changes included in this content update. - public let hadChanges: Bool - + public let hadChanges : Bool + /// The insertions and removals in this change, if any. - public let insertionsAndRemovals: InsertionsAndRemovals - + public let insertionsAndRemovals : InsertionsAndRemovals + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions: ListActions - + public let actions : ListActions + /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo - + public let positionInfo : ListScrollPositionInfo + /// The insertions and removals, for both sections and items, applied to a list /// as the result of an update. /// @@ -270,103 +264,114 @@ public extension ListStateObserver { /// contains a `Set`, two sections inserting (or removing) an item with an equal ID /// will only be included in `ChangedIDs.inserted/removed` set once. public struct InsertionsAndRemovals { - /// The inserted and removed sections. - public var sections: ChangedIDs + /// The inserted and removed sections. + public var sections : ChangedIDs + /// The inserted and removed items. - public var items: ChangedIDs - + public var items : ChangedIDs + init( - sections: ChangedIDs = ChangedIDs(inserted: [], removed: []), - items: ChangedIDs = ChangedIDs(inserted: [], removed: []) + sections : ChangedIDs = ChangedIDs(inserted: [], removed: []), + items : ChangedIDs = ChangedIDs(inserted: [], removed: []) ) { self.sections = sections self.items = items } - - init(diff: SectionedDiff) { - sections = ChangedIDs( - inserted: Set(diff.changes.added.map { $0.identifier }), - removed: Set(diff.changes.removed.map { $0.identifier }) + + init(diff : SectionedDiff) { + + self.sections = ChangedIDs( + inserted: Set(diff.changes.added.map{ $0.identifier }), + removed: Set(diff.changes.removed.map{ $0.identifier }) ) - - items = ChangedIDs( + + self.items = ChangedIDs( inserted: diff.changes.addedItemIdentifiers, removed: diff.changes.removedItemIdentifiers ) } - + /// The changed IDs. public struct ChangedIDs { + /// The inserted IDs. - public var inserted: Set - + public var inserted : Set + /// The removed IDs. - public var removed: Set + public var removed : Set } } } - + + /// Parameters available for ``OnVisibilityChanged`` callbacks. - struct VisibilityChanged { + public struct VisibilityChanged { + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions: ListActions - + public let actions : ListActions + /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo - + public let positionInfo : ListScrollPositionInfo + /// The items which were scrolled into view or otherwise became visible. - public let displayed: [AnyItem] - + public let displayed : [AnyItem] + /// The items which were scrolled out of view or otherwise were removed from view. - public let endedDisplay: [AnyItem] + public let endedDisplay : [AnyItem] } - + + /// Parameters available for ``OnFrameChanged`` callbacks. - struct FrameChanged { + public struct FrameChanged { + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions: ListActions - + public let actions : ListActions + /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo + public let positionInfo : ListScrollPositionInfo /// The old frame within the bounds of the list. - public let old: CGRect - + public let old : CGRect + /// The new frame within the bounds of the list. - public let new: CGRect + public let new : CGRect } - + + /// Parameters available for ``OnSelectionChanged`` callbacks. - struct SelectionChanged { + public struct SelectionChanged { + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions: ListActions - + public let actions : ListActions + /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo + public let positionInfo : ListScrollPositionInfo /// The previously selected items' identifiers. - public let old: Set - + public let old : Set + /// The newly selected items' identifiers. - public let new: Set + public let new : Set } - + + /// Parameters available for ``OnItemReordered`` callbacks. - struct ItemReordered { + public struct ItemReordered { + /// A set of methods you can use to perform actions on the list, eg scrolling to a given row. - public let actions: ListActions - + public let actions : ListActions + /// The current scroll position of the list. - public let positionInfo: ListScrollPositionInfo - + public let positionInfo : ListScrollPositionInfo + /// The item which was reordered by the customer. - public let item: AnyItem - + public let item : AnyItem + /// The new state of all sections in the list. - public let sections: [Section] - - /// The detailed information about the reorder event. - public let result: ItemReordering.Result + public let sections : [Section] + + /// The detailed information about the reorder event. + public let result : ItemReordering.Result } } diff --git a/ListableUI/Tests/ListScrollPositionInfoTests.swift b/ListableUI/Tests/ListScrollPositionInfoTests.swift index 2c3cab90e..1f8170016 100644 --- a/ListableUI/Tests/ListScrollPositionInfoTests.swift +++ b/ListableUI/Tests/ListScrollPositionInfoTests.swift @@ -5,114 +5,121 @@ // Created by Kyle Van Essen on 5/4/20. // -@testable import ListableUI -import UIKit import XCTest +import UIKit +@testable import ListableUI -final class UIRectEdgeTests: XCTestCase { - func test_visibleScrollViewContentEdges() { + +final class UIRectEdgeTests : XCTestCase +{ + func test_visibleScrollViewContentEdges() + { do { // No offset + no safe area should mean all edges are visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: .zero, size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: .zero ) - + XCTAssertEqual(edges, .all) } - + do { // No offset + safe area should mean the edges outside the safe area are not visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: .zero, size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 20.0, right: 10.0) ) - + XCTAssertEqual(edges, [.bottom, .right]) } do { // No offset + safe area should mean the edges outside the safe area are not visible. - + let edges = UIRectEdge.visibleScrollViewContentEdges( bounds: CGRect(origin: CGPoint(x: -100.0, y: -50.0), size: CGSize(width: 200, height: 100)), contentSize: CGSize(width: 100.0, height: 50.0), safeAreaInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 20.0, right: 10.0) ) - + XCTAssertEqual(edges, [.top, .left]) } - } + } + func test_mostVisibleItem() { + let items: Set = [ .init(identifier: Identifier(0), percentageVisible: 0.25), .init(identifier: Identifier(1), percentageVisible: 0.5), .init(identifier: Identifier(2), percentageVisible: 1.0), .init(identifier: Identifier(3), percentageVisible: 0.0), ] - + let info = ListScrollPositionInfo( scrollView: UIScrollView(), visibleItems: items, isFirstItemVisible: true, isLastItemVisible: false ) - + XCTAssertEqual(info.mostVisibleItem?.identifier.anyValue, 2) XCTAssertEqual(info.mostVisibleItem?.percentageVisible, 1.0) } - - func test_isApproachingBottom_withLastItemThreshold() { + + 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() { + + 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() { + + 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() { + + 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)) } - - fileprivate struct TestingType {} - + private func makeInfo( contentHeight: CGFloat, boundsHeight: CGFloat, @@ -127,7 +134,7 @@ final class UIRectEdgeTests: XCTestCase { scrollView.contentInset = .zero scrollView.verticalScrollIndicatorInsets = .zero scrollView.testSafeAreaInsets = safeAreaInsets - + return ListScrollPositionInfo( scrollView: scrollView, visibleItems: Set(), @@ -135,12 +142,16 @@ final class UIRectEdgeTests: XCTestCase { isLastItemVisible: isLastItemVisible ) } + + fileprivate struct TestingType { } } -final class UIEdgeInsetsTests: XCTestCase { - func test_masked() { +final class UIEdgeInsetsTests : XCTestCase +{ + func test_masked() + { let insets = UIEdgeInsets(top: 10.0, left: 20.0, bottom: 30.0, right: 40.0) - + XCTAssertEqual(insets.masked(by: []), UIEdgeInsets()) XCTAssertEqual(insets.masked(by: [.top]), UIEdgeInsets(top: 10.0, left: 0.0, bottom: 0.0, right: 0.0)) XCTAssertEqual(insets.masked(by: [.top, .left]), UIEdgeInsets(top: 10.0, left: 20.0, bottom: 0.0, right: 0.0)) @@ -149,9 +160,10 @@ final class UIEdgeInsetsTests: XCTestCase { } } -private final class TestScrollView: UIScrollView { +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 index 433ce412a..31576801a 100644 --- a/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift +++ b/ListableUI/Tests/ListStateObserverApproachingBottomTests.swift @@ -8,8 +8,10 @@ @testable import ListableUI import XCTest -final class ListStateObserverApproachingBottomTests: XCTestCase { - func test_callsBackOnceWhileRemainingWithinThreshold() { +final class ListStateObserverApproachingBottomTests : XCTestCase +{ + func test_callsBackOnceWhileRemainingWithinThreshold() + { var callCount = 0 var observer = ListStateObserver() @@ -26,7 +28,8 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { XCTAssertEqual(callCount, 1) } - func test_rearmsAfterScrollingAwayFromThreshold() { + func test_rearmsAfterScrollingAwayFromThreshold() + { var callCount = 0 var observer = ListStateObserver() @@ -41,7 +44,8 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { XCTAssertEqual(callCount, 2) } - func test_rearmsAfterContentChangesWhileRemainingWithinThreshold() { + func test_rearmsAfterContentChangesWhileRemainingWithinThreshold() + { var callCount = 0 var observer = ListStateObserver() @@ -60,7 +64,8 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { XCTAssertEqual(callCount, 2) } - func test_doesNotRearmForContentUpdatesWithoutChanges() { + func test_doesNotRearmForContentUpdatesWithoutChanges() + { var callCount = 0 var observer = ListStateObserver() @@ -79,7 +84,8 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { XCTAssertEqual(callCount, 1) } - func test_shouldPerformCanDelayTheFirstCallback() { + func test_shouldPerformCanDelayTheFirstCallback() + { var callCount = 0 var canLoadMore = false @@ -100,7 +106,8 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { XCTAssertEqual(callCount, 1) } - func test_rearmsWhenViewportChanges() { + func test_rearmsWhenViewportChanges() + { var callCount = 0 var observer = ListStateObserver() @@ -130,15 +137,17 @@ final class ListStateObserverApproachingBottomTests: XCTestCase { } } -private extension ListStateObserverApproachingBottomTests { - func didScroll(positionInfo: ListScrollPositionInfo) -> ListStateObserver.DidScroll { +private extension ListStateObserverApproachingBottomTests +{ + func didScroll(positionInfo : ListScrollPositionInfo) -> ListStateObserver.DidScroll + { ListStateObserver.DidScroll( actions: ListActions(), positionInfo: positionInfo ) } - func visibilityChanged(positionInfo: ListScrollPositionInfo) -> ListStateObserver.VisibilityChanged + func visibilityChanged(positionInfo : ListScrollPositionInfo) -> ListStateObserver.VisibilityChanged { ListStateObserver.VisibilityChanged( actions: ListActions(), @@ -149,8 +158,8 @@ private extension ListStateObserverApproachingBottomTests { } func contentUpdated( - positionInfo: ListScrollPositionInfo, - hadChanges: Bool + positionInfo : ListScrollPositionInfo, + hadChanges : Bool ) -> ListStateObserver.ContentUpdated { ListStateObserver.ContentUpdated( hadChanges: hadChanges, @@ -160,7 +169,8 @@ private extension ListStateObserverApproachingBottomTests { ) } - func frameChanged(positionInfo: ListScrollPositionInfo) -> ListStateObserver.FrameChanged { + func frameChanged(positionInfo : ListScrollPositionInfo) -> ListStateObserver.FrameChanged + { ListStateObserver.FrameChanged( actions: ListActions(), positionInfo: positionInfo, @@ -170,10 +180,10 @@ private extension ListStateObserverApproachingBottomTests { } func makeInfo( - bottomScrollOffset: CGFloat, - boundsHeight: CGFloat = 400.0, - safeAreaInsets: UIEdgeInsets = .zero, - isLastItemVisible: Bool = false + 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)) @@ -192,7 +202,8 @@ private extension ListStateObserverApproachingBottomTests { } } -private final class TestScrollView: UIScrollView { +private final class TestScrollView : UIScrollView +{ var testSafeAreaInsets: UIEdgeInsets = .zero override var safeAreaInsets: UIEdgeInsets {