fix(ios): reduce back-to-latest button sensitivity

This commit is contained in:
weishu
2026-09-13 11:46:10 +08:00
parent 051dbbd7da
commit 2e37419a7d
9 changed files with 308 additions and 8 deletions
+86 -1
View File
@@ -21,6 +21,7 @@ final class TranscriptLayoutTests: XCTestCase {
var jump = 0
var layouts: [Int] = []
var viewport: TranscriptViewport?
@ObservationIgnored var viewports: [TranscriptViewport] = []
let presentation = ChatPresentationState()
init(_ rows: [Row]) { self.rows = rows }
}
@@ -32,7 +33,7 @@ final class TranscriptLayoutTests: XCTestCase {
items: driver.rows, historyVersion: driver.version, jumpToken: driver.jump,
historyControlID: "history",
isInspectionPresented: driver.inspecting,
onViewport: { driver.viewport = $0 },
onViewport: { driver.viewport = $0; driver.viewports.append($0) },
onLayout: { version, _ in driver.layouts.append(version) }
) { row in
AnyView(StatefulRow(row: row).environment(\.chatPresentationState, driver.presentation))
@@ -101,6 +102,20 @@ final class TranscriptLayoutTests: XCTestCase {
.first.map { ($0.accessibilityIdentifier!, $0.frame.minY - view.contentOffset.y) }
}
private func bottomDistance(_ view: UICollectionView) -> CGFloat {
max(-view.adjustedContentInset.top,
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom) - view.contentOffset.y
}
private func browseBottom(_ view: UICollectionView, distance: CGFloat) async {
view.delegate?.scrollViewWillBeginDragging?(view)
view.contentOffset.y += bottomDistance(view) - distance
view.layoutIfNeeded()
view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false)
await settle()
XCTAssertEqual(bottomDistance(view), distance, accuracy: 1)
}
private func anchorY(_ id: String, in view: UICollectionView) -> CGFloat? {
view.visibleCells.first { $0.accessibilityIdentifier == id }.map { $0.frame.minY - view.contentOffset.y }
}
@@ -110,6 +125,72 @@ final class TranscriptLayoutTests: XCTestCase {
return "offset=\(view.contentOffset.y) height=\(view.contentSize.height) follows=\(String(describing: driver.viewport?.followsTail)) cells=\(cells)"
}
func testLatestAffordanceHysteresisNeverResumesFollowingAtSmallOffsets() async {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
view.delegate?.scrollViewWillBeginDragging?(view)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Starting a drag must not reveal the button")
view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false)
await settle()
XCTAssertEqual(driver.viewport?.followsTail, true, "A zero-distance drag still ends at the actual bottom")
await browseBottom(view, distance: 30)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
let reports = driver.viewports.count
await browseBottom(view, distance: 50)
XCTAssertEqual(driver.viewports.count, reports, "Offsets inside the same band must not republish the viewport")
for (distance, away): (CGFloat, Bool) in [(81, true), (60, true), (23, false), (40, false)] {
await browseBottom(view, distance: distance)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, away)
XCTAssertEqual(driver.viewport?.followsTail, false, "Hiding the affordance must not snap back to latest")
}
await browseBottom(view, distance: 0)
XCTAssertEqual(driver.viewport?.followsTail, true)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
}
func testStreamingAndViewportResizeRevealLatestWithoutMovingTheSmallOffsetAnchor() async throws {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browseBottom(view, distance: 30)
let anchor = try XCTUnwrap(readingAnchor(view))
driver.rows[49].height += 20 // Streaming / a small media resize.
await settle { abs(self.bottomDistance(view) - 50) <= 1 }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
driver.rows[49].height += 40
await settle { driver.viewport?.isAwayFromBottom == true }
XCTAssertEqual(bottomDistance(view), 90, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
for height: CGFloat in [600, 844] { // Keyboard / composer viewport changes.
window.frame.size.height = height
window.layoutIfNeeded()
await settle()
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, true)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
}
driver.jump += 1
await settle { driver.viewport?.followsTail == true }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
await browseBottom(view, distance: 40)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Explicit latest resets the hysteresis latch")
}
func testInspectorPausesTailAndPreservesAnchorUntilExplicitJump() async throws {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
@@ -120,6 +201,7 @@ final class TranscriptLayoutTests: XCTestCase {
driver.inspecting = true
await settle { driver.viewport?.followsTail == false }
XCTAssertEqual(driver.viewport?.needsOlder, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
driver.rows.append(contentsOf: rows(50..<65))
driver.version += 1
await settle { driver.layouts.contains(1) }
@@ -131,6 +213,7 @@ final class TranscriptLayoutTests: XCTestCase {
driver.jump += 1
await settle { driver.viewport?.followsTail == true }
XCTAssertEqual(driver.viewport?.isAtBottom, true)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
}
func testInspectorPausesShortTranscriptHistoryDemand() async {
@@ -197,6 +280,8 @@ final class TranscriptLayoutTests: XCTestCase {
await settle()
XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, true)
XCTAssertTrue(driver.viewports.allSatisfy { !$0.isAwayFromBottom },
"Following must hide the affordance even while self-sizing/viewport corrections are pending")
}
func testShortAndHiddenOnlyPagesStillAcknowledgeLayout() async {