Files
denlvovich 9329ca0893 Fixed double infinite-scroll jump in a pager
commit_hash:3e5fee6a8380cd7a93a68f6cf3308c17e362f407
2026-05-28 12:44:01 +03:00

670 lines
18 KiB
Swift

#if os(iOS)
import CoreGraphics
@testable import LayoutKit
import Testing
import UIKit
import VGSL
@MainActor
@Suite
struct ScrollHandlerTests {
private let layout = MockLayout.horizontal(pageCount: 5, pageSize: 100)
private let delegate = MockScrollHandlerDelegate()
// MARK: - State transitions
@Test
func initialState_finishScrolling_noStartOffset() {
let handler = makeHandler()
handler.onDidEndDecelerating(UIScrollView())
#expect(delegate.finishScrollingCalls.count == 1)
#expect(delegate.finishScrollingCalls.first! == nil)
}
@Test
func dragging_thenEndWithoutDeceleration_finishes() {
let handler = makeHandler()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
handler.onDidEndDragging(scrollView, willDecelerate: false)
#expect(delegate.finishScrollingCalls == [0])
}
@Test
func dragging_thenDecelerate_thenEndDecelerating_finishes() {
let handler = makeHandler()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
handler.onDidEndDragging(scrollView, willDecelerate: true)
#expect(delegate.finishScrollingCalls.isEmpty)
handler.onDidEndDecelerating(scrollView)
#expect(delegate.finishScrollingCalls.count == 1)
}
@Test
func endScrollingAnimation_finishes() {
let handler = makeHandler()
let scrollView = UIScrollView()
handler.onDidEndScrollingAnimation(scrollView)
#expect(delegate.finishScrollingCalls.count == 1)
}
// MARK: - Free mode (no offset modification)
@Test
func freeMode_doesNotModifyTargetOffset() {
let handler = makeHandler(mode: .free)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 150, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 1, y: 0),
targetContentOffset: &target
)
#expect(target == CGPoint(x: 150, y: 0))
}
// MARK: - Fixed size paging
@Test
func fixedPaging_snapsToPageOrigin() {
let handler = makeHandler(mode: .fixedSizePaging)
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 130, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 0.5, y: 0),
targetContentOffset: &target
)
#expect(target.x == 100)
}
@Test
func fixedPaging_velocityNudgeAfterOffsetJump_requiresSyncOnDragStart() {
let handler = makeHandler(mode: .fixedSizePaging)
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 300, y: 0)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 310, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 800, y: 0),
targetContentOffset: &target
)
#expect(target.x == 400)
}
// MARK: - Layout paging (single page)
@Test
func layoutPagingSinglePage_snapsToNearestPage() {
let handler = makeHandler(mode: .layoutPaging(step: .singlePage))
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 250, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 0.5, y: 0),
targetContentOffset: &target
)
#expect(target.x == 100)
}
@Test
func layoutPagingSinglePage_respectsVelocityDirection() {
let handler = makeHandler(mode: .layoutPaging(step: .singlePage))
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 200, y: 0)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 150, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: -0.5, y: 0),
targetContentOffset: &target
)
#expect(target.x == 100)
}
// MARK: - Layout paging (multiple pages)
@Test
func layoutPagingMultiplePages_snapsToPageOrigin() {
let handler = makeHandler(mode: .layoutPaging(step: .multiplePages))
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 250, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 0.5, y: 0),
targetContentOffset: &target
)
#expect(target.x == 300)
}
// MARK: - Vertical direction
@Test
func verticalDirection_usesYAxis() {
let verticalLayout = MockLayout.vertical(pageCount: 5, pageSize: 100)
let handler = ScrollHandler(layout: verticalLayout)
handler.delegate = delegate
handler.direction = .vertical
handler.mode = .fixedSizePaging
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: false
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 100, height: 500)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 0, y: 130)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 0, y: 0.5),
targetContentOffset: &target
)
#expect(target.y == 100)
}
// MARK: - handlePositionChange
@Test
func handlePositionChange_callsDelegateForNewPosition() {
let handler = makeHandler()
handler.handlePositionChange(
oldPosition: .paging(index: 0),
newPosition: .paging(index: 2),
newLayout: false,
animated: true
)
#expect(delegate.updateOffsetToPositionCalls.count == 1)
let call = delegate.updateOffsetToPositionCalls.first
#expect(call?.position == .paging(index: 2))
#expect(call?.animated == true)
}
@Test
func handlePositionChange_skipsWhenPositionUnchanged() {
let handler = makeHandler()
handler.handlePositionChange(
oldPosition: .paging(index: 1),
newPosition: .paging(index: 1),
newLayout: false,
animated: true
)
#expect(delegate.updateOffsetToPositionCalls.isEmpty)
}
@Test
func handlePositionChange_skipsDuringUserDrag() {
let handler = makeHandler()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
handler.handlePositionChange(
oldPosition: .paging(index: 0),
newPosition: .paging(index: 2),
newLayout: false,
animated: true
)
#expect(delegate.updateOffsetToPositionCalls.isEmpty)
}
@Test
func handlePositionChange_usesDetachedOffsetForNewLayout() {
let handler = makeHandler()
handler.handlePositionChange(
oldPosition: .paging(index: 0),
newPosition: .offset(200, firstVisibleItemIndex: 2),
newLayout: true,
animated: false
)
#expect(delegate.updateOffsetToPositionCalls == [
MockScrollHandlerDelegate.PositionCall(
position: .offset(200, firstVisibleItemIndex: 2), animated: false
),
])
}
@Test
func handlePositionChange_newLayout_resolvesPagingOffsetThroughLayout() {
let handler = makeHandler()
handler.handlePositionChange(
oldPosition: .paging(index: 0),
newPosition: .paging(index: 2),
newLayout: true,
animated: false
)
#expect(delegate.updateOffsetToPositionCalls == [
MockScrollHandlerDelegate.PositionCall(
position: .paging(index: 2), animated: false
),
])
}
// MARK: - updateState (onDidScroll)
@Test
func onDidScroll_inPagingMode_reportsPagePosition() {
let handler = makeHandler(mode: .fixedSizePaging)
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 200, y: 0)
handler.onDidScroll(scrollView)
#expect(delegate.updateGalleryStateCalls.count == 1)
let call = delegate.updateGalleryStateCalls.first
#expect(call?.position == .paging(index: 2))
}
@Test
func onDidScroll_inFreeMode_reportsOffsetPosition() {
let handler = makeHandler(mode: .free)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 150, y: 0)
handler.onDidScroll(scrollView)
#expect(delegate.updateGalleryStateCalls.count == 1)
if case let .offset(value, _) = delegate.updateGalleryStateCalls.first?.position {
#expect(value == 150)
} else {
Issue.record("Expected .offset position")
}
}
// MARK: - configurePager / clearPager
@Test
func clearPager_disablesPagingBehavior() {
let handler = makeHandler(mode: .fixedSizePaging)
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
handler.clearPager()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 130, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 0.5, y: 0),
targetContentOffset: &target
)
#expect(target == CGPoint(x: 130, y: 0))
}
// MARK: - configureInfiniteScroll
@Test
func configureInfiniteScroll_disabled_noLoop() {
let handler = makeHandler()
handler.configureInfiniteScroll(
enabled: false,
bufferSize: 1,
boundsSize: 100,
alignment: .center,
insetMode: .fixed(values: .zero)
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 100, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 1, y: 0),
targetContentOffset: &target
)
handler.onDidScroll(scrollView)
#expect(delegate.updateOffsetDetachedCalls.isEmpty)
}
@Test
func infiniteScrollLoopPerformed_preservedByConfigurationCalls() {
let handler = makeArmedHandler()
handler.configurePager(pageOrigins: [0, 100, 200], isHorizontal: true)
handler.clearPager()
handler.configureInfiniteScroll(
enabled: true,
bufferSize: 1,
boundsSize: 0,
alignment: .leading,
insetMode: .fixed(values: .zero)
)
#expect(handler.infiniteScrollLoopPerformed)
}
@Test
func infiniteScrollLoopPerformed_preservedByProgrammaticPositionChange() {
let handler = makeArmedHandler()
handler.handlePositionChange(
oldPosition: .paging(index: 0),
newPosition: .paging(index: 1),
newLayout: false,
animated: false
)
#expect(handler.infiniteScrollLoopPerformed)
}
@Test
func infiniteScrollLoopPerformed_preservedByDragSettleEntry() {
let handler = makeArmedHandler()
let scrollView = UIScrollView()
handler.onWillBeginDragging(scrollView)
#expect(handler.infiniteScrollLoopPerformed)
}
@Test
func infiniteScrollLoopPerformed_resetByFinishDragging() {
let handler = makeArmedHandler()
let scrollView = UIScrollView()
var target = CGPoint.zero
handler.onWillBeginDragging(scrollView)
handler.onWillEndDragging(
scrollView,
withVelocity: .zero,
targetContentOffset: &target
)
#expect(!handler.infiniteScrollLoopPerformed)
}
@Test
func infiniteScrollLoopPerformed_resetByFinishScrolling() {
let scrollView = UIScrollView()
let endedWithoutDeceleration = makeArmedHandler()
endedWithoutDeceleration.onWillBeginDragging(scrollView)
endedWithoutDeceleration.onDidEndDragging(scrollView, willDecelerate: false)
#expect(!endedWithoutDeceleration.infiniteScrollLoopPerformed)
let endedDecelerating = makeArmedHandler()
endedDecelerating.onDidEndDecelerating(scrollView)
#expect(!endedDecelerating.infiniteScrollLoopPerformed)
let endedAnimation = makeArmedHandler()
endedAnimation.onDidEndScrollingAnimation(scrollView)
#expect(!endedAnimation.infiniteScrollLoopPerformed)
}
// MARK: - finishScrolling passes startOffset
@Test
func finishScrolling_passesStartOffsetFromDragging() {
let handler = makeHandler()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 50, y: 0)
handler.onWillBeginDragging(scrollView)
handler.onDidEndDragging(scrollView, willDecelerate: false)
#expect(delegate.finishScrollingCalls == [50])
}
@Test
func finishScrolling_passesStartOffsetFromSettling() {
let handler = makeHandler()
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
scrollView.contentOffset = CGPoint(x: 75, y: 0)
handler.onWillBeginDragging(scrollView)
var target = CGPoint(x: 200, y: 0)
handler.onWillEndDragging(
scrollView,
withVelocity: CGPoint(x: 1, y: 0),
targetContentOffset: &target
)
handler.onDidEndDecelerating(scrollView)
#expect(delegate.finishScrollingCalls == [75])
}
// MARK: - evaluatePagingDuringDrag (interrupts scroll on page change)
@Test
func dragging_interruptsOnFullPageChange_inSinglePageMode() {
let handler = makeHandler(mode: .layoutPaging(step: .singlePage))
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
scrollView.contentOffset = CGPoint(x: 150, y: 0)
handler.onDidScroll(scrollView)
#expect(delegate.interruptScrollingCalls == 1)
}
@Test
func dragging_doesNotInterruptInFreeModeWhenCrossingPageBoundary() {
let handler = makeHandler(mode: .free)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
scrollView.contentOffset = CGPoint(x: 150, y: 0)
handler.onDidScroll(scrollView)
#expect(delegate.interruptScrollingCalls == 0)
}
@Test
func dragging_doesNotInterruptWithinSamePage() {
let handler = makeHandler(mode: .fixedSizePaging)
handler.configurePager(
pageOrigins: [0, 100, 200, 300, 400],
isHorizontal: true
)
let scrollView = UIScrollView()
scrollView.contentSize = CGSize(width: 500, height: 100)
handler.onWillBeginDragging(scrollView)
scrollView.contentOffset = CGPoint(x: 40, y: 0)
handler.onDidScroll(scrollView)
#expect(delegate.interruptScrollingCalls == 0)
}
private func makeHandler(
mode: ScrollHandler.Mode = .free,
direction: ScrollDirection = .horizontal
) -> ScrollHandler {
let handler = ScrollHandler(layout: layout)
handler.delegate = delegate
handler.direction = direction
handler.mode = mode
return handler
}
private func makeArmedHandler() -> ScrollHandler {
let handler = makeHandler()
handler.infiniteScrollLoopPerformed = true
return handler
}
}
// MARK: - Mock Layout
private struct MockLayout: GalleryViewLayouting {
let pageOrigins: [CGFloat]
let blockFrames: [CGRect]
let contentSize: CGSize
let transformation: ElementsTransformation? = nil
let scrollDirection: ScrollDirection
static func horizontal(pageCount: Int, pageSize: CGFloat) -> MockLayout {
let origins = (0..<pageCount).map { CGFloat($0) * pageSize }
let frames = origins.map { CGRect(x: $0, y: 0, width: pageSize, height: 100) }
return MockLayout(
pageOrigins: origins,
blockFrames: frames,
contentSize: CGSize(width: CGFloat(pageCount) * pageSize, height: 100),
scrollDirection: .horizontal
)
}
static func vertical(pageCount: Int, pageSize: CGFloat) -> MockLayout {
let origins = (0..<pageCount).map { CGFloat($0) * pageSize }
let frames = origins.map { CGRect(x: 0, y: $0, width: 100, height: pageSize) }
return MockLayout(
pageOrigins: origins,
blockFrames: frames,
contentSize: CGSize(width: 100, height: CGFloat(pageCount) * pageSize),
scrollDirection: .vertical
)
}
func contentOffset(pageIndex: CGFloat) -> CGFloat {
let index = Int(pageIndex.rounded())
guard pageOrigins.indices.contains(index) else { return 0 }
return pageOrigins[index]
}
func pageIndex(forContentOffset contentOffset: CGFloat) -> CGFloat {
guard !pageOrigins.isEmpty else { return 0 }
let pageSize = pageOrigins.count > 1 ? pageOrigins[1] - pageOrigins[0] : 1
return contentOffset / pageSize
}
func isEqual(to _: GalleryViewModel, boundsSize _: CGSize) -> Bool {
false
}
}
// MARK: - Mock Delegate
private final class MockScrollHandlerDelegate: ScrollHandlerDelegate {
struct PositionCall: Equatable {
let position: GalleryViewState.Position
let animated: Bool
}
struct StateCall: Equatable {
let position: GalleryViewState.Position
let offset: CGFloat
}
var updateOffsetDetachedCalls: [CGFloat] = []
var updateOffsetToPositionCalls: [PositionCall] = []
var updateGalleryStateCalls: [StateCall] = []
var interruptScrollingCalls = 0
var finishScrollingCalls: [CGFloat?] = []
func updateOffsetDetached(_ offset: CGFloat) {
updateOffsetDetachedCalls.append(offset)
}
func updateOffsetDetached(_ pos: GalleryViewState.Position) {
updateOffsetToPositionCalls.append(PositionCall(position: pos, animated: false))
}
func updateOffsetToPosition(_ pos: GalleryViewState.Position, animated: Bool) {
updateOffsetToPositionCalls.append(PositionCall(position: pos, animated: animated))
}
func updateGalleryState(_ pos: GalleryViewState.Position, offset: CGFloat) {
updateGalleryStateCalls.append(StateCall(position: pos, offset: offset))
}
func interruptScrolling() {
interruptScrollingCalls += 1
}
func finishScrolling(scrollStartOffset: CGFloat?) {
finishScrollingCalls.append(scrollStartOffset)
}
}
#endif