diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e39e8f1..a2613f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- Added item-aware content offset adjustment APIs, declarative auto-scroll support, and scroll-in-progress state for custom scrolling behaviors. + ```swift + list.autoScrollAction = .pin( + .item(targetIdentifier), + itemPosition: .verticalContentOffsetAdjustment { info in + max(0.0, info.itemFrame.maxY - info.visibleContentFrame.maxY) + }, + scrollInterruptionPolicy: .deferDuringUserScrolling + ) + ``` + Use `.skipDuringUserScrolling` instead when the auto-scroll should be dropped rather than retried after the user scroll ends. + ### Removed ### Changed diff --git a/Development/Sources/Demos/Demo Screens/CustomAutoScrollingViewController.swift b/Development/Sources/Demos/Demo Screens/CustomAutoScrollingViewController.swift new file mode 100644 index 00000000..4dcdb939 --- /dev/null +++ b/Development/Sources/Demos/Demo Screens/CustomAutoScrollingViewController.swift @@ -0,0 +1,254 @@ +// +// CustomAutoScrollingViewController.swift +// Demo +// +// Created by Square on 5/22/26. +// + +import BlueprintUI +import BlueprintUICommonControls +import BlueprintUILists +import ListableUI +import UIKit + + +final class CustomAutoScrollingViewController : UIViewController +{ + private let list = ListView() + private let footer = UIView() + private let footerTitle = UILabel() + + private var selectedRow = 24 + private var expandedRows = Set() + private var hasPerformedInitialLayoutUpdate = false + + override func loadView() + { + self.view = UIView() + self.view.backgroundColor = .white + + self.list.translatesAutoresizingMaskIntoConstraints = false + self.footer.translatesAutoresizingMaskIntoConstraints = false + + self.view.addSubview(self.list) + self.view.addSubview(self.footer) + + NSLayoutConstraint.activate([ + self.list.topAnchor.constraint(equalTo: self.view.topAnchor), + self.list.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.list.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.list.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + + self.footer.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.footer.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.footer.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.footer.heightAnchor.constraint(equalToConstant: 112.0), + ]) + + self.configureFooter() + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.title = "Custom Auto Scrolling" + self.updateList() + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + guard self.hasPerformedInitialLayoutUpdate == false else { + return + } + + self.hasPerformedInitialLayoutUpdate = true + self.updateList() + } + + private func configureFooter() + { + self.footer.backgroundColor = .systemBackground + self.footer.layer.shadowColor = UIColor.black.cgColor + self.footer.layer.shadowOpacity = 0.18 + self.footer.layer.shadowRadius = 8.0 + self.footer.layer.shadowOffset = CGSize(width: 0.0, height: -2.0) + + let previous = UIButton(type: .system) + previous.setTitle("Previous", for: .normal) + previous.addTarget(self, action: #selector(selectPreviousRow), for: .touchUpInside) + + let next = UIButton(type: .system) + next.setTitle("Next", for: .normal) + next.addTarget(self, action: #selector(selectNextRow), for: .touchUpInside) + + let toggleHeight = UIButton(type: .system) + toggleHeight.setTitle("Toggle Height", for: .normal) + toggleHeight.addTarget(self, action: #selector(toggleSelectedRowHeight), for: .touchUpInside) + + self.footerTitle.font = .systemFont(ofSize: 16.0, weight: .semibold) + self.footerTitle.textAlignment = .center + + let buttons = UIStackView(arrangedSubviews: [previous, next, toggleHeight]) + buttons.axis = .horizontal + buttons.alignment = .center + buttons.distribution = .equalSpacing + buttons.spacing = 16.0 + + let stack = UIStackView(arrangedSubviews: [self.footerTitle, buttons]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 10.0 + + self.footer.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: self.footer.topAnchor, constant: 12.0), + stack.leadingAnchor.constraint(equalTo: self.footer.leadingAnchor, constant: 20.0), + stack.trailingAnchor.constraint(equalTo: self.footer.trailingAnchor, constant: -20.0), + ]) + } + + @objc private func selectPreviousRow() + { + self.selectedRow = max(0, self.selectedRow - 1) + self.updateList() + } + + @objc private func selectNextRow() + { + self.selectedRow = min(Self.rowCount - 1, self.selectedRow + 1) + self.updateList() + } + + @objc private func toggleSelectedRowHeight() + { + if self.expandedRows.contains(self.selectedRow) { + self.expandedRows.remove(self.selectedRow) + } else { + self.expandedRows.insert(self.selectedRow) + } + + self.updateList() + } + + private func updateList() + { + self.footerTitle.text = "Target row \(self.selectedRow + 1) stays above the fixed footer" + + let selectedRow = self.selectedRow + let targetIdentifier = FooterAwarePinnedItem.identifier(with: selectedRow) + + self.list.configure { list in + list.appearance = .demoAppearance + list.layout = .demoLayout + list.animation = .fast + list.scrollIndicatorInsets.bottom = 112.0 + + list.autoScrollAction = .pin( + .item(targetIdentifier), + itemPosition: .verticalContentOffsetAdjustment { [weak self] info in + self?.footerAwareScrollDelta(for: info) ?? 0.0 + }, + animated: false, + scrollInterruptionPolicy: .deferDuringUserScrolling, + shouldPerform: { _ in true } + ) + + list += Section("rows") { + for row in 0.. CGFloat + { + let topGap : CGFloat = 16.0 + let footerGap : CGFloat = 16.0 + let footerHeight = self.footer.bounds.height + + let idealTop = info.visibleContentFrame.minY + topGap + let idealBottom = info.visibleContentFrame.maxY - footerHeight - footerGap + + if info.itemFrame.height > idealBottom - idealTop { + return info.itemFrame.minY - idealTop + } + + if info.itemFrame.minY < idealTop { + return info.itemFrame.minY - idealTop + } + + if info.itemFrame.maxY > idealBottom { + return info.itemFrame.maxY - idealBottom + } + + return 0.0 + } + + private static let rowCount = 50 +} + + +private struct FooterAwarePinnedItem : BlueprintItemContent, Equatable +{ + var row : Int + var isSelected : Bool + var isExpanded : Bool + + var identifierValue : Int { + self.row + } + + func element(with info : ApplyItemContentInfo) -> Element + { + let title = Label(text: "Row \(self.row + 1)") { + $0.font = .systemFont(ofSize: 17.0, weight: self.isSelected ? .semibold : .regular) + $0.color = self.isSelected ? .systemBlue : .label + } + + let detail = Label(text: self.detailText) { + $0.font = .systemFont(ofSize: 14.0, weight: .regular) + $0.color = .secondaryLabel + } + + let content = Column(alignment: .fill, minimumSpacing: 6.0) { + title + detail + } + + var box = Box( + backgroundColor: self.isSelected ? UIColor.systemBlue.withAlphaComponent(0.08) : .white, + cornerStyle: .rounded(radius: 6.0), + wrapping: Inset( + uniformInset: 14.0, + wrapping: content + ) + ) + + box.borderStyle = .solid( + color: self.isSelected ? .systemBlue : .white(0.9), + width: 2.0 + ) + + return box + } + + private var detailText : String { + if self.isExpanded { + return "Expanded row content demonstrates a layout update that re-runs declarative custom auto-scroll." + } else if self.isSelected { + return "Selected target row" + } else { + return "Regular row" + } + } +} diff --git a/Development/Sources/Demos/DemosRootViewController.swift b/Development/Sources/Demos/DemosRootViewController.swift index 272a8ef7..35304179 100644 --- a/Development/Sources/Demos/DemosRootViewController.swift +++ b/Development/Sources/Demos/DemosRootViewController.swift @@ -81,6 +81,14 @@ public final class DemosRootViewController : ListViewController self?.push(PinAutoscrollingViewController()) } ) + + Item( + DemoItem(text: "Custom Auto Scrolling (Footer-Aware Pin)"), + selectionStyle: .selectable(), + onSelect : { _ in + self?.push(CustomAutoScrollingViewController()) + } + ) Item( DemoItem(text: "scrollTo(item: ...) completion handler"), @@ -426,4 +434,3 @@ public final class DemosRootViewController : ListViewController } } } - diff --git a/ListableUI/Sources/AutoScrollAction.swift b/ListableUI/Sources/AutoScrollAction.swift index e866f948..51ceaf36 100644 --- a/ListableUI/Sources/AutoScrollAction.swift +++ b/ListableUI/Sources/AutoScrollAction.swift @@ -59,6 +59,30 @@ public enum AutoScrollAction { onInsertOf insertedIdentifier: AnyIdentifier, position: ScrollPosition, animated : Bool = false, + scrollInterruptionPolicy : ScrollInterruptionPolicy = .performImmediately, + shouldPerform : @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, + didPerform : @escaping (ListScrollPositionInfo) -> () = { _ in } + ) -> AutoScrollAction + { + self.scrollTo( + destination, + onInsertOf: insertedIdentifier, + itemPosition: .standard(position), + animated: animated, + scrollInterruptionPolicy: scrollInterruptionPolicy, + shouldPerform: shouldPerform, + didPerform: didPerform + ) + } + + /// Scrolls to the specified item when the list is updated if the item was inserted in this update, + /// using a custom item positioning strategy. + public static func scrollTo( + _ destination : ScrollDestination? = nil, + onInsertOf insertedIdentifier: AnyIdentifier, + itemPosition: ListItemScrollPosition, + animated : Bool = false, + scrollInterruptionPolicy : ScrollInterruptionPolicy = .performImmediately, shouldPerform : @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, didPerform : @escaping (ListScrollPositionInfo) -> () = { _ in } ) -> AutoScrollAction @@ -67,7 +91,8 @@ public enum AutoScrollAction { onInsertOf: .init( destination: destination ?? .item(insertedIdentifier), insertedIdentifier: insertedIdentifier, - position: position, + itemPosition: itemPosition, + scrollInterruptionPolicy: scrollInterruptionPolicy, animated: animated, shouldPerform: shouldPerform, didPerform: didPerform @@ -110,6 +135,27 @@ public enum AutoScrollAction { _ destination : ScrollDestination, position: ScrollPosition, animated : Bool = false, + scrollInterruptionPolicy : ScrollInterruptionPolicy = .performImmediately, + shouldPerform : @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, + didPerform : @escaping (ListScrollPositionInfo) -> () = { _ in } + ) -> AutoScrollAction + { + self.pin( + destination, + itemPosition: .standard(position), + animated: animated, + scrollInterruptionPolicy: scrollInterruptionPolicy, + shouldPerform: shouldPerform, + didPerform: didPerform + ) + } + + /// Scrolls to the specified item when the list is updated using a custom item positioning strategy. + public static func pin( + _ destination : ScrollDestination, + itemPosition: ListItemScrollPosition, + animated : Bool = false, + scrollInterruptionPolicy : ScrollInterruptionPolicy = .performImmediately, shouldPerform : @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, didPerform : @escaping (ListScrollPositionInfo) -> () = { _ in } ) -> AutoScrollAction @@ -117,7 +163,8 @@ public enum AutoScrollAction { .pin( to: .init( destination: destination, - position: position, + itemPosition: itemPosition, + scrollInterruptionPolicy: scrollInterruptionPolicy, animated: animated, shouldPerform: shouldPerform, didPerform: didPerform @@ -129,6 +176,18 @@ public enum AutoScrollAction { extension AutoScrollAction { + /// Controls how an auto-scroll action behaves when user scrolling is active. + public enum ScrollInterruptionPolicy { + /// Perform the auto-scroll action as soon as the list updates. + case performImmediately + + /// Wait until the current user scroll finishes before performing the auto-scroll action. + case deferDuringUserScrolling + + /// Do not perform the auto-scroll action while the user is scrolling. + case skipDuringUserScrolling + } + /// Where to scroll as a result of an `AutoScrollAction`. public enum ScrollDestination : Equatable { @@ -182,8 +241,24 @@ extension AutoScrollAction /// The identifier of the item for which the `AutoScrollAction` should be performed. public var insertedIdentifier : AnyIdentifier - - public var position : ScrollPosition + + public var itemPosition : ListItemScrollPosition + + public var position : ScrollPosition { + get { + switch self.itemPosition.storage { + case .standard(let position): + return position + case .verticalContentOffsetAdjustment: + return ScrollPosition(position: .bottom) + } + } + set { + self.itemPosition = .standard(newValue) + } + } + + public var scrollInterruptionPolicy : ScrollInterruptionPolicy public var animated : Bool @@ -197,7 +272,23 @@ extension AutoScrollAction { public var destination : ScrollDestination - public var position : ScrollPosition + public var itemPosition : ListItemScrollPosition + + public var position : ScrollPosition { + get { + switch self.itemPosition.storage { + case .standard(let position): + return position + case .verticalContentOffsetAdjustment: + return ScrollPosition(position: .bottom) + } + } + set { + self.itemPosition = .standard(newValue) + } + } + + public var scrollInterruptionPolicy : ScrollInterruptionPolicy public var animated : Bool @@ -206,3 +297,11 @@ extension AutoScrollAction public var didPerform : (ListScrollPositionInfo) -> () } } + +protocol _AutoScrollActionConfiguration: AutoScrollAction.Configuration { + var itemPosition : ListItemScrollPosition { get set } + var scrollInterruptionPolicy : AutoScrollAction.ScrollInterruptionPolicy { get set } +} + +extension AutoScrollAction.OnInsertedItem: _AutoScrollActionConfiguration {} +extension AutoScrollAction.Pin: _AutoScrollActionConfiguration {} diff --git a/ListableUI/Sources/ListActions.swift b/ListableUI/Sources/ListActions.swift index d8a1a73c..9a2266aa 100644 --- a/ListableUI/Sources/ListActions.swift +++ b/ListableUI/Sources/ListActions.swift @@ -109,6 +109,28 @@ public final class ListActions { completion: completion ) } + + /// + /// Scrolls to a custom vertical offset for the provided item. + /// The adjustment receives the item's frame and visible content frame, + /// then returns the vertical delta to apply. + /// If the item is contained in the list, true is returned. If it is not, false is returned. + /// + @discardableResult + public func scrollTo( + item : AnyItem, + contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, + animated : Bool = false, + completion: ScrollCompletion? = nil + ) -> Bool + { + self.scrollTo( + item: item.anyIdentifier, + contentOffsetAdjustment: contentOffsetAdjustment, + animated: animated, + completion: completion + ) + } /// /// Scrolls to the item with the provided identifier, with the provided positioning. @@ -135,6 +157,33 @@ public final class ListActions { ) } + /// + /// Scrolls to a custom vertical offset for the item with the provided identifier. + /// The adjustment receives the item's frame and visible content frame, + /// then returns the vertical delta to apply. + /// If there is more than one item with the same identifier, the list scrolls to the first. + /// If the item is contained in the list, true is returned. If it is not, false is returned. + /// + @discardableResult + public func scrollTo( + item : AnyIdentifier, + contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, + animated : Bool = false, + completion: ScrollCompletion? = nil + ) -> Bool + { + guard let listView = self.listView else { + return false + } + + return listView.scrollTo( + item: item, + contentOffsetAdjustment: contentOffsetAdjustment, + animated: animated, + completion: completion + ) + } + /// /// Scrolls to the section with the given identifier, with the provided scroll and section positioning. /// diff --git a/ListableUI/Sources/ListItemScrollPositionInfo.swift b/ListableUI/Sources/ListItemScrollPositionInfo.swift new file mode 100644 index 00000000..1aba736c --- /dev/null +++ b/ListableUI/Sources/ListItemScrollPositionInfo.swift @@ -0,0 +1,59 @@ +// +// ListItemScrollPositionInfo.swift +// ListableUI +// +// Created by Square on 5/21/26. +// + +import Foundation +import UIKit + + +/// Returns the vertical delta to apply to the list's current content offset. +public typealias ListItemScrollPositionAdjustment = (ListItemScrollPositionInfo) -> CGFloat + +/// Specifies how to position an item in a list when requesting the list scrolls to it. +public struct ListItemScrollPosition { + + enum Storage { + case standard(ScrollPosition) + case verticalContentOffsetAdjustment(ListItemScrollPositionAdjustment) + } + + let storage: Storage + + /// Positions the item using Listable's standard item scroll positioning. + public static func standard(_ position: ScrollPosition) -> ListItemScrollPosition { + ListItemScrollPosition(storage: .standard(position)) + } + + /// Positions the item by applying a custom vertical delta to the current content offset. + public static func verticalContentOffsetAdjustment( + _ adjustment: @escaping ListItemScrollPositionAdjustment + ) -> ListItemScrollPosition { + ListItemScrollPosition(storage: .verticalContentOffsetAdjustment(adjustment)) + } +} + +/// Information available when calculating a custom scroll adjustment for an item. +public struct ListItemScrollPositionInfo: Equatable { + + /// The item's frame in the list content coordinate space. + public let itemFrame: CGRect + + /// The visible content frame in the list content coordinate space. + public let visibleContentFrame: CGRect + + /// The current scroll position of the list. + public let positionInfo: ListScrollPositionInfo + + public init( + itemFrame: CGRect, + visibleContentFrame: CGRect, + positionInfo: ListScrollPositionInfo + ) { + self.itemFrame = itemFrame + self.visibleContentFrame = visibleContentFrame + self.positionInfo = positionInfo + } +} diff --git a/ListableUI/Sources/ListScrollPositionInfo.swift b/ListableUI/Sources/ListScrollPositionInfo.swift index 0ec59920..0631433b 100644 --- a/ListableUI/Sources/ListScrollPositionInfo.swift +++ b/ListableUI/Sources/ListScrollPositionInfo.swift @@ -47,6 +47,9 @@ public struct ListScrollPositionInfo : Equatable { /// `safeAreaInsests` of the list view public var safeAreaInsets: UIEdgeInsets + + /// Whether the scroll view is currently being interacted with or decelerating. + public var isScrollInProgress: Bool /// /// Used to retrieve the visible content edges for the list's content. @@ -129,6 +132,7 @@ public struct ListScrollPositionInfo : Equatable { self.bounds = scrollView.bounds self.safeAreaInsets = scrollView.safeAreaInsets + self.isScrollInProgress = scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating } struct ScrollViewState : Equatable diff --git a/ListableUI/Sources/ListStateObserver.swift b/ListableUI/Sources/ListStateObserver.swift index 3f22a9f5..1695d084 100644 --- a/ListableUI/Sources/ListStateObserver.swift +++ b/ListableUI/Sources/ListStateObserver.swift @@ -228,11 +228,13 @@ extension ListStateObserver /// Parameters available for ``OnDidEndDeceleration`` callbacks. public struct DidEndDeceleration { + public let actions : ListActions public let positionInfo : ListScrollPositionInfo } /// Parameters available for ``OnDidEndScrollingAnimation`` callbacks. public struct DidEndScrollingAnimation { + public let actions : ListActions public let positionInfo : ListScrollPositionInfo } diff --git a/ListableUI/Sources/ListView/ListView.Delegate.swift b/ListableUI/Sources/ListView/ListView.Delegate.swift index e0ad9c92..81c00db4 100644 --- a/ListableUI/Sources/ListView/ListView.Delegate.swift +++ b/ListableUI/Sources/ListView/ListView.Delegate.swift @@ -100,8 +100,9 @@ extension ListView func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - ListStateObserver.perform(self.view.stateObserver.onDidEndScrollingAnimation, "Did End Scrolling Animation", with: self.view) { _ in + ListStateObserver.perform(self.view.stateObserver.onDidEndScrollingAnimation, "Did End Scrolling Animation", with: self.view) { actions in ListStateObserver.DidEndScrollingAnimation( + actions: actions, positionInfo: self.view.scrollPositionInfo ) } @@ -326,6 +327,8 @@ extension ListView func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + self.view.isUserScrollInProgress = true + self.view.liveCells.perform { $0.closeSwipeActions() } @@ -336,16 +339,30 @@ extension ListView ) } } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) + { + guard decelerate == false else { + return + } + + self.view.isUserScrollInProgress = false + self.view.flushPendingAutoScrollAction() + } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.view.isUserScrollInProgress = false self.view.updatePresentationState(for: .didEndDecelerating) - ListStateObserver.perform(self.view.stateObserver.onDidEndDeceleration, "Did End Deceleration", with: self.view) { _ in + ListStateObserver.perform(self.view.stateObserver.onDidEndDeceleration, "Did End Deceleration", with: self.view) { actions in ListStateObserver.DidEndDeceleration( + actions: actions, positionInfo: self.view.scrollPositionInfo ) } + + self.view.flushPendingAutoScrollAction() } func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 651b6b30..a0d8f9e5 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -185,6 +185,10 @@ public final class ListView : UIView private var sourcePresenter : AnySourcePresenter private var autoScrollAction : AutoScrollAction + + private var pendingAutoScrollAction : PendingAutoScrollAction? + + var isUserScrollInProgress = false private let dataSource : DataSource @@ -544,6 +548,28 @@ public final class ListView : UIView completion: completion ) } + + /// + /// Scrolls to a custom vertical offset for the provided item. + /// The adjustment receives the item's frame and visible content frame, + /// then returns the vertical delta to apply. + /// If the item is contained in the list, true is returned. If it is not, false is returned. + /// + @discardableResult + public func scrollTo( + item : AnyItem, + contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, + animated : Bool = false, + completion: ScrollCompletion? = nil + ) -> Bool + { + self.scrollTo( + item: item.anyIdentifier, + contentOffsetAdjustment: contentOffsetAdjustment, + animated: animated, + completion: completion + ) + } /// /// Scrolls to the item with the provided identifier, with the provided positioning. @@ -635,6 +661,60 @@ public final class ListView : UIView } } + /// + /// Scrolls to a custom vertical offset for the item with the provided identifier. + /// The adjustment receives the item's frame and visible content frame, + /// then returns the vertical delta to apply. + /// If there is more than one item with the same identifier, the list scrolls to the first. + /// If the item is contained in the list, true is returned. If it is not, false is returned. + /// + @discardableResult + public func scrollTo( + item : AnyIdentifier, + contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, + animated : Bool = false, + completion: ScrollCompletion? = nil + ) -> Bool + { + // Make sure the item identifier is valid. + + guard let toIndexPath = self.storage.allContent.firstIndexPathForItem(with: item) else { + handleScrollCompletion(reason: .cannotScroll, completion: completion) + return false + } + + // If user is performing this in a `UIView.performWithoutAnimation` block, respect that and don't animate, regardless of what the animated parameter is. + let shouldAnimate = animated && UIView.areAnimationsEnabled + + return preparePresentationStateForScroll(to: toIndexPath, handlerWhenFailed: completion) { + + /// `preparePresentationStateForScroll(to:)` is asynchronous in some + /// cases, we need to re-query the item index path in case it changed or is no longer valid. + + guard let toIndexPath = self.storage.allContent.firstIndexPathForItem(with: item) else { + self.handleScrollCompletion(reason: .cannotScroll, completion: completion) + return + } + + let itemFrame = self.collectionViewLayout.frameForItem(at: toIndexPath) + let visibleContentFrame = self.collectionView.visibleContentFrame + let positionInfo = ListItemScrollPositionInfo( + itemFrame: itemFrame, + visibleContentFrame: visibleContentFrame, + positionInfo: self.scrollPositionInfo + ) + + var resultOffset = self.collectionView.contentOffset + resultOffset.y += contentOffsetAdjustment(positionInfo) + + self.performScroll( + toContentOffset: resultOffset, + animated: shouldAnimate, + completion: completion + ) + } + } + /// /// Scrolls to the section with the given identifier, with the provided scroll and section positioning. /// @@ -807,7 +887,7 @@ public final class ListView : UIView // Dispatch so that the completion handler executes on the next runloop // execution. DispatchQueue.main.async { - completion(ListStateObserver.DidEndScrollingAnimation(positionInfo: self.scrollPositionInfo)) + self.performScrollCompletion(completion, positionInfo: self.scrollPositionInfo) } case .scrolled(let animated): if animated { @@ -818,11 +898,18 @@ public final class ListView : UIView DispatchQueue.main.async { // Sync the `scrollPositionInfo` before executing the handler. self.performEmptyBatchUpdates() - completion(ListStateObserver.DidEndScrollingAnimation(positionInfo: self.scrollPositionInfo)) + self.performScrollCompletion(completion, positionInfo: self.scrollPositionInfo) } } } } + + private func performScrollCompletion(_ completion: ScrollCompletion, positionInfo: ListScrollPositionInfo) { + let actions = ListActions() + actions.listView = self + completion(ListStateObserver.DidEndScrollingAnimation(actions: actions, positionInfo: positionInfo)) + actions.listView = nil + } /// This is used to house the completion handlers of scrolling APIs. This is kept /// internal and separate from `ListStateObserver` and its handlers. @@ -852,7 +939,7 @@ public final class ListView : UIView performEmptyBatchUpdates() let positionInfo = scrollPositionInfo handlers.forEach { handler in - handler(ListStateObserver.DidEndScrollingAnimation(positionInfo: positionInfo)) + performScrollCompletion(handler, positionInfo: positionInfo) } } @@ -1453,6 +1540,11 @@ public final class ListView : UIView } } + private struct PendingAutoScrollAction { + var addedItems : Set + var animated : Bool + } + private func performAutoScrollAction(with addedItems : Set, animated : Bool) { switch self.autoScrollAction { @@ -1468,52 +1560,142 @@ public final class ListView : UIView autoScroll(with: pin) } - func autoScroll(with info: AutoScrollAction.Configuration) { - if info.shouldPerform(self.scrollPositionInfo) { - - /// Only animate the scroll if both the update **and** the scroll action are animated. - let animated = info.animated && animated - - if let destination = info.destination.destination(with: self.content) { - - if behavior.verticalLayoutGravity == .bottom { - /// Temporarily ignore the bottom gravity offest overrides before scrolling. This - /// avoids an issue where: - /// - the list has `VerticalLayoutGravity.bottom` and `AutoScrollAction` behaviors - /// - the list has offscreen items that haven't been sized - /// - the `AutoScrollAction` has been triggered - /// - the resulting scroll position will adjust the collection view's `contentSize` - /// as items are dequeued and sized - /// - /// Without ignoring the custom `VerticalLayoutGravity.bottom` offset behavior, the - /// above scenario will force the scroll offset to the bottom, discarding this scroll - /// update. - collectionView.ignoreBottomGravityOffsetOverride = true - } - - guard self.scrollTo(item: destination, position: info.position, animated: animated) else { - collectionView.ignoreBottomGravityOffsetOverride = false - return - } - if animated { - stateObserver.onDidEndScrollingAnimation { [weak self] state in - self?.collectionView.ignoreBottomGravityOffsetOverride = false - info.didPerform(state.positionInfo) - } - } else { - /// Perform an update after an animationless scroll so that `CollectionViewLayout`'s - /// `prepare()` function will synchronously execute before calling `didPerform`. Otherwise, - /// the list's `visibleContent` and the resulting `scrollPositionInfo.visibleItems` will - /// be stale. - performEmptyBatchUpdates() - collectionView.ignoreBottomGravityOffsetOverride = false - info.didPerform(scrollPositionInfo) - } + func autoScroll(with info: _AutoScrollActionConfiguration) { + if self.shouldSkipAutoScroll(info) { + return + } + + if self.shouldDeferAutoScroll(info) { + self.pendingAutoScrollAction = PendingAutoScrollAction( + addedItems: addedItems, + animated: animated + ) + return + } + + guard info.shouldPerform(self.scrollPositionInfo) else { + return + } + + /// Only animate the scroll if both the update **and** the scroll action are animated. + let shouldAnimate = info.animated && animated + + guard let destination = info.destination.destination(with: self.content) else { + return + } + + if behavior.verticalLayoutGravity == .bottom { + /// Temporarily ignore the bottom gravity offest overrides before scrolling. This + /// avoids an issue where: + /// - the list has `VerticalLayoutGravity.bottom` and `AutoScrollAction` behaviors + /// - the list has offscreen items that haven't been sized + /// - the `AutoScrollAction` has been triggered + /// - the resulting scroll position will adjust the collection view's `contentSize` + /// as items are dequeued and sized + /// + /// Without ignoring the custom `VerticalLayoutGravity.bottom` offset behavior, the + /// above scenario will force the scroll offset to the bottom, discarding this scroll + /// update. + collectionView.ignoreBottomGravityOffsetOverride = true + } + + var didBeginScroll = false + let completion : ScrollCompletion = { [weak self] _ in + guard didBeginScroll else { + return + } + + guard let self else { + return + } + + self.performEmptyBatchUpdates() + self.collectionView.ignoreBottomGravityOffsetOverride = false + info.didPerform(self.scrollPositionInfo) + } + + switch info.itemPosition.storage { + case .standard where shouldAnimate == false: + didBeginScroll = self.scrollTo( + item: destination, + itemPosition: info.itemPosition, + animated: false, + completion: nil + ) + + if didBeginScroll { + performEmptyBatchUpdates() + collectionView.ignoreBottomGravityOffsetOverride = false + info.didPerform(scrollPositionInfo) } + case .standard, .verticalContentOffsetAdjustment: + didBeginScroll = self.scrollTo( + item: destination, + itemPosition: info.itemPosition, + animated: shouldAnimate, + completion: completion + ) + } + + if didBeginScroll == false { + collectionView.ignoreBottomGravityOffsetOverride = false } } } + private func shouldSkipAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { + guard info.scrollInterruptionPolicy == .skipDuringUserScrolling else { + return false + } + + return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress + } + + private func shouldDeferAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { + guard info.scrollInterruptionPolicy == .deferDuringUserScrolling else { + return false + } + + return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress + } + + internal func flushPendingAutoScrollAction() { + guard let pendingAutoScrollAction else { + return + } + + self.pendingAutoScrollAction = nil + self.performAutoScrollAction( + with: pendingAutoScrollAction.addedItems, + animated: pendingAutoScrollAction.animated + ) + } + + @discardableResult + private func scrollTo( + item : AnyIdentifier, + itemPosition : ListItemScrollPosition, + animated : Bool = false, + completion: ScrollCompletion? = nil + ) -> Bool { + switch itemPosition.storage { + case .standard(let position): + return self.scrollTo( + item: item, + position: position, + animated: animated, + completion: completion + ) + case .verticalContentOffsetAdjustment(let adjustment): + return self.scrollTo( + item: item, + contentOffsetAdjustment: adjustment, + animated: animated, + completion: completion + ) + } + } + private func performScroll( to targetFrame : CGRect, scrollPosition : ScrollPosition, @@ -1571,6 +1753,46 @@ public final class ListView : UIView } } + private func performScroll( + toContentOffset contentOffset : CGPoint, + animated: Bool = false, + completion: ScrollCompletion? = nil + ) { + let resultOffset = clampedContentOffset(contentOffset) + + let roundedResultOffset = CGPoint( + x: round(resultOffset.x), + y: round(resultOffset.y) + ) + let roundedCurrentOffset = CGPoint( + x: round(collectionView.contentOffset.x), + y: round(collectionView.contentOffset.y) + ) + if roundedCurrentOffset != roundedResultOffset { + collectionView.setContentOffset(resultOffset, animated: animated) + handleScrollCompletion(reason: .scrolled(animated: animated), completion: completion) + } else { + handleScrollCompletion(reason: .cannotScroll, completion: completion) + } + } + + private func clampedContentOffset(_ contentOffset : CGPoint) -> CGPoint { + var resultOffset = contentOffset + + // Don't scroll past the bottom of the list. + + let topInset = collectionView.adjustedContentInset.top + let contentFrameHeight = collectionView.visibleContentFrame.height + let maxOffsetHeight = collectionViewLayout.collectionViewContentSize.height - contentFrameHeight - topInset + resultOffset.y = min(resultOffset.y, maxOffsetHeight) + + // Don't scroll beyond the top of the list. + + resultOffset.y = max(resultOffset.y, -topInset) + + return resultOffset + } + private func preparePresentationStateForScroll(to toIndexPath: IndexPath, handlerWhenFailed: ScrollCompletion?, scroll: @escaping () -> Void) -> Bool { // Make sure we have a last loaded index path. diff --git a/ListableUI/Tests/ListScrollPositionInfoTests.swift b/ListableUI/Tests/ListScrollPositionInfoTests.swift index 712ef0fd..1866a7e7 100644 --- a/ListableUI/Tests/ListScrollPositionInfoTests.swift +++ b/ListableUI/Tests/ListScrollPositionInfoTests.swift @@ -71,8 +71,54 @@ final class UIRectEdgeTests : XCTestCase XCTAssertEqual(info.mostVisibleItem?.identifier.anyValue, 2) XCTAssertEqual(info.mostVisibleItem?.percentageVisible, 1.0) } + + func test_isScrollInProgress() { + + let scrollView = ScrollView() + + func makeInfo() -> ListScrollPositionInfo { + ListScrollPositionInfo( + scrollView: scrollView, + visibleItems: [], + isFirstItemVisible: true, + isLastItemVisible: false + ) + } + + XCTAssertFalse(makeInfo().isScrollInProgress) + + scrollView.isTrackingValue = true + XCTAssertTrue(makeInfo().isScrollInProgress) + + scrollView.isTrackingValue = false + scrollView.isDraggingValue = true + XCTAssertTrue(makeInfo().isScrollInProgress) + + scrollView.isDraggingValue = false + scrollView.isDeceleratingValue = true + XCTAssertTrue(makeInfo().isScrollInProgress) + } fileprivate struct TestingType { } + + private final class ScrollView : UIScrollView { + + var isTrackingValue = false + var isDraggingValue = false + var isDeceleratingValue = false + + override var isTracking : Bool { + isTrackingValue + } + + override var isDragging : Bool { + isDraggingValue + } + + override var isDecelerating : Bool { + isDeceleratingValue + } + } } final class UIEdgeInsetsTests : XCTestCase diff --git a/ListableUI/Tests/ListView/ListViewTests.swift b/ListableUI/Tests/ListView/ListViewTests.swift index 4846e55d..4cbeb29a 100644 --- a/ListableUI/Tests/ListView/ListViewTests.swift +++ b/ListableUI/Tests/ListView/ListViewTests.swift @@ -653,7 +653,7 @@ class ListViewTests: XCTestCase TestContent(content: "A") } vc.list.configure(with: content) - waitFor { vc.list.updateQueue.isEmpty } + waitFor { didPerform.count == 1 } XCTAssertEqual(didPerform.count, 1) guard let visibleItems = didPerform.first?.visibleItems else { @@ -731,7 +731,7 @@ class ListViewTests: XCTestCase ) vc.list.configure(with: content) - waitFor { vc.list.updateQueue.isEmpty } + waitFor { didPerform.count == 1 } XCTAssertEqual(didPerform.count, 1) guard let visibleItems = didPerform.first?.visibleItems else { @@ -812,7 +812,7 @@ class ListViewTests: XCTestCase TestContent(content: "A") } vc.list.configure(with: content) - waitFor { vc.list.updateQueue.isEmpty } + waitFor { didPerform.count == 1 } XCTAssertEqual(didPerform.count, 1) guard let visibleItems = didPerform.first?.visibleItems else { @@ -846,6 +846,354 @@ class ListViewTests: XCTestCase } } } + + func test_auto_scroll_action_with_vertical_content_offset_adjustment() throws { + + try self.testcase("pin applies custom offset") { + var capturedInfo: ListItemScrollPositionInfo? + var offsetAtAdjustment: CGFloat? + var didPerform: [ListScrollPositionInfo] = [] + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + let content = makeAutoScrollContent( + target: TestContent.Identifier("Item 75"), + itemPosition: .verticalContentOffsetAdjustment { info in + capturedInfo = info + offsetAtAdjustment = vc.list.collectionView.contentOffset.y + return 125.0 + }, + didPerform: { didPerform.append($0) } + ) + + try show(vc: vc) { vc in + vc.list.configure(with: content) + waitFor { didPerform.count == 1 } + + let itemIndexPath = try XCTUnwrap( + vc.list.storage.allContent.firstIndexPathForItem(with: TestContent.Identifier("Item 75")) + ) + let info = try XCTUnwrap(capturedInfo) + XCTAssertEqual( + vc.list.collectionView.contentOffset.y, + try XCTUnwrap(offsetAtAdjustment) + 125.0, + accuracy: 0.1 + ) + XCTAssertEqual(info.itemFrame, vc.list.collectionViewLayout.frameForItem(at: itemIndexPath)) + XCTAssertEqual(info.visibleContentFrame.origin.y, 0.0, accuracy: 0.1) + XCTAssertEqual(info.positionInfo.isScrollInProgress, false) + } + } + + self.testcase("pin clamps custom offset") { + var didPerform: [ListScrollPositionInfo] = [] + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + show(vc: vc) { vc in + vc.list.configure(with: makeAutoScrollContent( + target: TestContent.Identifier("Item 75"), + itemPosition: .verticalContentOffsetAdjustment { _ in 100_000.0 }, + didPerform: { didPerform.append($0) } + )) + waitFor { didPerform.count == 1 } + + XCTAssertEqual( + vc.list.collectionView.bounds.maxY, + vc.list.collectionView.contentSize.height + vc.list.collectionView.adjustedContentInset.bottom, + accuracy: 0.1 + ) + + vc.list.configure(with: makeAutoScrollContent( + target: TestContent.Identifier("Item 1"), + itemPosition: .verticalContentOffsetAdjustment { _ in -100_000.0 }, + didPerform: { didPerform.append($0) } + )) + waitFor { didPerform.count == 2 } + + XCTAssertEqual( + vc.list.collectionView.contentOffset.y, + -vc.list.collectionView.adjustedContentInset.top, + accuracy: 0.1 + ) + } + } + + for animated in [true, false] { + self.testcase("pin runs didPerform once animated: \(animated)") { + var didPerform: [ListScrollPositionInfo] = [] + + let content = makeAutoScrollContent( + target: TestContent.Identifier("Item 75"), + itemPosition: .verticalContentOffsetAdjustment { _ in 125.0 }, + animated: animated, + didPerform: { didPerform.append($0) } + ) + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + show(vc: vc) { vc in + vc.list.configure(with: content) + waitFor { didPerform.count == 1 } + XCTAssertEqual(didPerform.count, 1) + } + } + } + + try self.testcase("scroll to inserted item uses custom offset") { + var didPerform: [ListScrollPositionInfo] = [] + var capturedInfo: ListItemScrollPositionInfo? + + let insertedID = TestContent.Identifier("A") + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + var offsetAtAdjustment: CGFloat? + var content = makeInsertedAutoScrollContent( + insertedID: insertedID, + itemPosition: .verticalContentOffsetAdjustment { info in + capturedInfo = info + offsetAtAdjustment = vc.list.collectionView.contentOffset.y + return 125.0 + }, + didPerform: { didPerform.append($0) } + ) + + try show(vc: vc) { vc in + vc.list.configure(with: content) + waitFor { vc.list.updateQueue.isEmpty } + XCTAssertEqual(didPerform.count, 0) + + content.content += Section("new") { + TestContent(content: "A") + } + vc.list.configure(with: content) + waitFor { didPerform.count == 1 } + + let info = try XCTUnwrap(capturedInfo) + XCTAssertEqual( + vc.list.collectionView.contentOffset.y, + try XCTUnwrap(offsetAtAdjustment) + 125.0, + accuracy: 0.1 + ) + XCTAssertEqual(info.positionInfo.isScrollInProgress, false) + } + } + + func makeAutoScrollContent( + target: TestContent.Identifier, + itemPosition: ListItemScrollPosition, + animated: Bool = false, + scrollInterruptionPolicy: AutoScrollAction.ScrollInterruptionPolicy = .performImmediately, + shouldPerform: @escaping (ListScrollPositionInfo) -> Bool = { _ in true }, + didPerform: @escaping (ListScrollPositionInfo) -> Void = { _ in } + ) -> ListProperties { + ListProperties.default { list in + list.animatesChanges = animated + list.layout = .table { layout in + layout.contentInsetAdjustmentBehavior = .never + } + list.sections = [ + Section("items") { + for itemNumber in 1...100 { + TestContent(content: "Item \(itemNumber)") + } + } + ] + + list.autoScrollAction = .pin( + .item(target), + itemPosition: itemPosition, + animated: animated, + scrollInterruptionPolicy: scrollInterruptionPolicy, + shouldPerform: shouldPerform, + didPerform: didPerform + ) + } + } + + func makeInsertedAutoScrollContent( + insertedID: TestContent.Identifier, + itemPosition: ListItemScrollPosition, + didPerform: @escaping (ListScrollPositionInfo) -> Void + ) -> ListProperties { + ListProperties.default { list in + list.animatesChanges = false + list.layout = .table { layout in + layout.contentInsetAdjustmentBehavior = .never + } + list.sections = [ + Section("items") { + for itemNumber in 1...100 { + TestContent(content: "Item \(itemNumber)") + } + } + ] + + list.autoScrollAction = .scrollTo( + .item(insertedID), + onInsertOf: insertedID, + itemPosition: itemPosition, + animated: false, + shouldPerform: { _ in true }, + didPerform: didPerform + ) + } + } + } + + func test_deferred_auto_scroll_action_with_vertical_content_offset_adjustment() throws { + + try self.testcase("defers while dragging and runs when dragging ends") { + var didPerform: [ListScrollPositionInfo] = [] + var capturedInfo: ListItemScrollPositionInfo? + var offsetAtAdjustment: CGFloat? + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + try show(vc: vc) { vc in + vc.list.configure(with: makeAutoScrollContent()) + waitFor { vc.list.updateQueue.isEmpty } + + vc.list.delegate.scrollViewWillBeginDragging(vc.list.collectionView) + vc.list.configure(with: makeAutoScrollContent( + contentOffsetAdjustment: { info in + capturedInfo = info + offsetAtAdjustment = vc.list.collectionView.contentOffset.y + return 125.0 + }, + scrollInterruptionPolicy: .deferDuringUserScrolling, + shouldPerform: { _ in true }, + didPerform: { didPerform.append($0) } + )) + waitFor { vc.list.updateQueue.isEmpty } + + XCTAssertEqual(didPerform.count, 0) + XCTAssertEqual(vc.list.collectionView.contentOffset.y, 0.0, accuracy: 0.1) + + vc.list.delegate.scrollViewDidEndDragging(vc.list.collectionView, willDecelerate: false) + waitFor { didPerform.count == 1 } + + _ = try XCTUnwrap(capturedInfo) + XCTAssertEqual( + vc.list.collectionView.contentOffset.y, + try XCTUnwrap(offsetAtAdjustment) + 125.0, + accuracy: 0.1 + ) + } + } + + self.testcase("defers until deceleration ends") { + var didPerform: [ListScrollPositionInfo] = [] + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + show(vc: vc) { vc in + vc.list.configure(with: makeAutoScrollContent()) + waitFor { vc.list.updateQueue.isEmpty } + + vc.list.delegate.scrollViewWillBeginDragging(vc.list.collectionView) + vc.list.configure(with: makeAutoScrollContent( + scrollInterruptionPolicy: .deferDuringUserScrolling, + shouldPerform: { _ in true }, + didPerform: { didPerform.append($0) } + )) + waitFor { vc.list.updateQueue.isEmpty } + + vc.list.delegate.scrollViewDidEndDragging(vc.list.collectionView, willDecelerate: true) + XCTAssertEqual(didPerform.count, 0) + + vc.list.delegate.scrollViewDidEndDecelerating(vc.list.collectionView) + waitFor { didPerform.count == 1 } + } + } + + self.testcase("re-evaluates shouldPerform when deferred scroll retries") { + var didPerform: [ListScrollPositionInfo] = [] + var shouldPerform = false + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + show(vc: vc) { vc in + vc.list.configure(with: makeAutoScrollContent()) + waitFor { vc.list.updateQueue.isEmpty } + + vc.list.delegate.scrollViewWillBeginDragging(vc.list.collectionView) + vc.list.configure(with: makeAutoScrollContent( + scrollInterruptionPolicy: .deferDuringUserScrolling, + shouldPerform: { _ in shouldPerform }, + didPerform: { didPerform.append($0) } + )) + waitFor { vc.list.updateQueue.isEmpty } + + shouldPerform = true + vc.list.delegate.scrollViewDidEndDragging(vc.list.collectionView, willDecelerate: false) + waitFor { didPerform.count == 1 } + } + } + + self.testcase("skips while dragging") { + var didPerform: [ListScrollPositionInfo] = [] + + let vc = ViewController() + vc.listFramingBehavior = .exactly(CGSize(width: 400, height: 600)) + + show(vc: vc) { vc in + vc.list.configure(with: makeAutoScrollContent()) + waitFor { vc.list.updateQueue.isEmpty } + + vc.list.delegate.scrollViewWillBeginDragging(vc.list.collectionView) + vc.list.configure(with: makeAutoScrollContent( + scrollInterruptionPolicy: .skipDuringUserScrolling, + shouldPerform: { _ in true }, + didPerform: { didPerform.append($0) } + )) + waitFor { vc.list.updateQueue.isEmpty } + + XCTAssertEqual(didPerform.count, 0) + + vc.list.delegate.scrollViewDidEndDragging(vc.list.collectionView, willDecelerate: false) + waitFor { vc.list.updateQueue.isEmpty } + XCTAssertEqual(didPerform.count, 0) + } + } + + func makeAutoScrollContent( + contentOffsetAdjustment: @escaping ListItemScrollPositionAdjustment = { _ in 125.0 }, + scrollInterruptionPolicy: AutoScrollAction.ScrollInterruptionPolicy = .performImmediately, + shouldPerform: @escaping (ListScrollPositionInfo) -> Bool = { _ in false }, + didPerform: @escaping (ListScrollPositionInfo) -> Void = { _ in } + ) -> ListProperties { + ListProperties.default { list in + list.animatesChanges = false + list.layout = .table { layout in + layout.contentInsetAdjustmentBehavior = .never + } + list.sections = [ + Section("items") { + for itemNumber in 1...100 { + TestContent(content: "Item \(itemNumber)") + } + } + ] + + list.autoScrollAction = .pin( + .item(TestContent.Identifier("Item 75")), + itemPosition: .verticalContentOffsetAdjustment(contentOffsetAdjustment), + animated: false, + scrollInterruptionPolicy: scrollInterruptionPolicy, + shouldPerform: shouldPerform, + didPerform: didPerform + ) + } + } + } func test_scroll_to_item_completion() throws { @@ -1048,6 +1396,145 @@ class ListViewTests: XCTestCase } } } + + func test_scroll_to_item_with_content_offset_adjustment() throws { + + try testControllerCase("applies custom offset") { viewController in + var capturedInfo: ListItemScrollPositionInfo? + var offsetAtAdjustment: CGFloat? + let scrollExpectation = expectation(description: "Scroll completed") + + let didScroll = viewController.list.scrollTo( + item: TestContent.Identifier("Item 75"), + contentOffsetAdjustment: { info in + capturedInfo = info + offsetAtAdjustment = viewController.list.collectionView.contentOffset.y + return 125.0 + }, + animated: false, + completion: { _ in + scrollExpectation.fulfill() + } + ) + + XCTAssertTrue(didScroll) + wait(for: [scrollExpectation], timeout: 0.5) + + let itemIndexPath = try XCTUnwrap( + viewController.list.storage.allContent.firstIndexPathForItem(with: TestContent.Identifier("Item 75")) + ) + let info = try XCTUnwrap(capturedInfo) + XCTAssertEqual( + viewController.list.collectionView.contentOffset.y, + try XCTUnwrap(offsetAtAdjustment) + 125.0, + accuracy: 0.1 + ) + XCTAssertEqual(info.itemFrame, viewController.list.collectionViewLayout.frameForItem(at: itemIndexPath)) + XCTAssertEqual(info.visibleContentFrame.origin.y, 0.0, accuracy: 0.1) + XCTAssertEqual(info.positionInfo.isScrollInProgress, false) + } + + try testControllerCase("clamps custom offset at bottom and top") { viewController in + scrollWithAdjustment(100_000.0, item: TestContent.Identifier("Item 75"), using: viewController) + XCTAssertEqual( + viewController.list.collectionView.bounds.maxY, + viewController.list.collectionView.contentSize.height + viewController.list.collectionView.adjustedContentInset.bottom, + accuracy: 0.1 + ) + + scrollWithAdjustment(-100_000.0, item: TestContent.Identifier("Item 1"), using: viewController) + XCTAssertEqual( + viewController.list.collectionView.contentOffset.y, + -viewController.list.collectionView.adjustedContentInset.top, + accuracy: 0.1 + ) + } + + try testControllerCase("runs completion for no-op custom offset") { viewController in + let startingOffset = viewController.list.collectionView.contentOffset + let scrollExpectation = expectation(description: "Scroll completed") + + let didScroll = viewController.list.scrollTo( + item: TestContent.Identifier("Item 1"), + contentOffsetAdjustment: { _ in 0.0 }, + animated: true, + completion: { _ in + scrollExpectation.fulfill() + } + ) + + XCTAssertTrue(didScroll) + wait(for: [scrollExpectation], timeout: 0.5) + XCTAssertEqual(viewController.list.collectionView.contentOffset, startingOffset) + } + + try testControllerCase("runs completion for missing custom offset item") { viewController in + let scrollExpectation = expectation(description: "Scroll completed") + + let didScroll = viewController.list.scrollTo( + item: TestContent.Identifier("Missing"), + contentOffsetAdjustment: { _ in + XCTFail("Unexpected adjustment request") + return 0.0 + }, + animated: true, + completion: { _ in + scrollExpectation.fulfill() + } + ) + + XCTAssertFalse(didScroll) + wait(for: [scrollExpectation], timeout: 0.5) + XCTAssertEqual(viewController.list.collectionView.contentOffset.y, 0.0, accuracy: 0.1) + } + + func scrollWithAdjustment(_ adjustment: CGFloat, item: TestContent.Identifier, using viewController: ViewController) { + let scrollExpectation = expectation(description: "Scroll completed") + let didScroll = viewController.list.scrollTo( + item: item, + contentOffsetAdjustment: { _ in adjustment }, + animated: false, + completion: { _ in + scrollExpectation.fulfill() + } + ) + + XCTAssertTrue(didScroll) + wait(for: [scrollExpectation], timeout: 0.5) + } + } + + func test_settled_scroll_callbacks_include_actions() throws { + + try testControllerCase { viewController in + var didEndDecelerationCanUseActions = false + var didEndScrollingAnimationCanUseActions = false + + viewController.list.stateObserver = ListStateObserver { observer in + observer.onDidEndDeceleration { state in + didEndDecelerationCanUseActions = state.actions.scrolling.scrollTo( + item: TestContent.Identifier("Item 20"), + position: ScrollPosition(position: .top), + animated: false + ) + } + + observer.onDidEndScrollingAnimation { state in + didEndScrollingAnimationCanUseActions = state.actions.scrolling.scrollTo( + item: TestContent.Identifier("Item 21"), + position: ScrollPosition(position: .top), + animated: false + ) + } + } + + viewController.list.delegate.scrollViewDidEndDecelerating(viewController.list.collectionView) + viewController.list.delegate.scrollViewDidEndScrollingAnimation(viewController.list.collectionView) + + XCTAssertTrue(didEndDecelerationCanUseActions) + XCTAssertTrue(didEndScrollingAnimationCanUseActions) + } + } func test_scroll_to_section_completion() throws { for animated in [true, false] {