From 233d85a9809c75d1fc1b48a1719df716b1826078 Mon Sep 17 00:00:00 2001 From: Aaron Alaniz Date: Thu, 21 May 2026 09:25:05 -0500 Subject: [PATCH] Add custom item offset scrolling Expose a Listable-owned item scrolling API that lets callers compute a vertical content offset adjustment without accessing the scroll view. --- CHANGELOG.md | 2 + ListableUI/Sources/ListActions.swift | 49 +++++++ .../Sources/ListItemScrollPositionInfo.swift | 26 ++++ .../Sources/ListScrollPositionInfo.swift | 4 + ListableUI/Sources/ListStateObserver.swift | 2 + .../Sources/ListView/ListView.Delegate.swift | 6 +- ListableUI/Sources/ListView/ListView.swift | 129 ++++++++++++++++- .../Tests/ListScrollPositionInfoTests.swift | 46 ++++++ ListableUI/Tests/ListView/ListViewTests.swift | 132 ++++++++++++++++++ 9 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 ListableUI/Sources/ListItemScrollPositionInfo.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e39e8f1e..590020016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added item-aware content offset adjustment APIs and scroll-in-progress state for custom scrolling behaviors. + ### Removed ### Changed diff --git a/ListableUI/Sources/ListActions.swift b/ListableUI/Sources/ListActions.swift index d8a1a73c7..9a2266aa5 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 000000000..bbf137670 --- /dev/null +++ b/ListableUI/Sources/ListItemScrollPositionInfo.swift @@ -0,0 +1,26 @@ +// +// 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 + +/// 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 +} diff --git a/ListableUI/Sources/ListScrollPositionInfo.swift b/ListableUI/Sources/ListScrollPositionInfo.swift index 0ec599203..0631433b3 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 3f22a9f5b..1695d0848 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 e0ad9c92c..ef9354ee6 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 ) } @@ -341,8 +342,9 @@ extension ListView { 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 ) } diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 651b6b30c..2545878f9 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -544,6 +544,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 +657,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 +883,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 +894,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 +935,7 @@ public final class ListView : UIView performEmptyBatchUpdates() let positionInfo = scrollPositionInfo handlers.forEach { handler in - handler(ListStateObserver.DidEndScrollingAnimation(positionInfo: positionInfo)) + performScrollCompletion(handler, positionInfo: positionInfo) } } @@ -1571,6 +1654,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 712ef0fd9..1866a7e79 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 4846e55de..29db6ebe6 100644 --- a/ListableUI/Tests/ListView/ListViewTests.swift +++ b/ListableUI/Tests/ListView/ListViewTests.swift @@ -1048,6 +1048,138 @@ class ListViewTests: XCTestCase } } } + + func test_scroll_to_item_with_content_offset_adjustment() throws { + + try testControllerCase("applies custom offset") { viewController in + var capturedInfo: ListItemScrollPositionInfo? + let scrollExpectation = expectation(description: "Scroll completed") + + let didScroll = viewController.list.scrollTo( + item: TestContent.Identifier("Item 75"), + contentOffsetAdjustment: { info in + capturedInfo = info + return 125.0 + }, + animated: false, + completion: { _ in + scrollExpectation.fulfill() + } + ) + + XCTAssertTrue(didScroll) + wait(for: [scrollExpectation], timeout: 0.5) + XCTAssertEqual(viewController.list.collectionView.contentOffset.y, 125.0, accuracy: 0.1) + + let itemIndexPath = try XCTUnwrap( + viewController.list.storage.allContent.firstIndexPathForItem(with: TestContent.Identifier("Item 75")) + ) + XCTAssertEqual(capturedInfo?.itemFrame, viewController.list.collectionViewLayout.frameForItem(at: itemIndexPath)) + XCTAssertEqual(capturedInfo?.visibleContentFrame.origin.y, 0.0, accuracy: 0.1) + XCTAssertEqual(capturedInfo?.positionInfo.isScrollInProgress, false) + } + + try testControllerCase("clamps custom offset at bottom and top") { viewController in + let maxOffset = viewController.list.collectionViewLayout.collectionViewContentSize.height + - viewController.list.collectionView.visibleContentFrame.height + - viewController.list.collectionView.adjustedContentInset.top + + scrollWithAdjustment(100_000.0, item: TestContent.Identifier("Item 75"), using: viewController) + XCTAssertEqual(viewController.list.collectionView.contentOffset.y, maxOffset, 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] {