Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- Added item-aware content offset adjustment APIs and scroll-in-progress state for custom scrolling behaviors.

### Removed

### Changed
Expand Down
49 changes: 49 additions & 0 deletions ListableUI/Sources/ListActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
///
Expand Down
26 changes: 26 additions & 0 deletions ListableUI/Sources/ListItemScrollPositionInfo.swift
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions ListableUI/Sources/ListScrollPositionInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ListableUI/Sources/ListStateObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 4 additions & 2 deletions ListableUI/Sources/ListView/ListView.Delegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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
)
}
Expand Down
129 changes: 126 additions & 3 deletions ListableUI/Sources/ListView/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions ListableUI/Tests/ListScrollPositionInfoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading