mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Constrain transcript rows to the viewport and replace competing scroll anchors with measured tail corrections. Preserve following through media resizing and reset scroll state when the transcript remounts. Gate history paging on reader gestures and cover tail-following behavior with seven regression tests.
69 lines
2.2 KiB
Swift
69 lines
2.2 KiB
Swift
/// Separates the reader's tail-following intent from the measured bottom
|
|
/// edge. Media can resize without changing messages; that must request a
|
|
/// correction, not turn the reader into a history reader.
|
|
///
|
|
/// A mounted transcript owns one instance. Its initial request positions a
|
|
/// fresh scroll view; a nil request cancels any queued correction.
|
|
public struct ChatTailFollowState: Equatable, Sendable {
|
|
public private(set) var isFollowingTail = true
|
|
public private(set) var isDragging = false
|
|
|
|
private var contentHeight: Double?
|
|
private var viewportHeight: Double?
|
|
private var awaitingBottom = true
|
|
private var revision = 0
|
|
|
|
public init() {}
|
|
|
|
/// Key for the view's deferred scroll task. Repeated offset observations
|
|
/// do not change it; reaching the bottom or starting a drag cancels it.
|
|
public var scrollRequest: Int? {
|
|
isFollowingTail && !isDragging && awaitingBottom ? revision : nil
|
|
}
|
|
|
|
public mutating func layoutChanged(
|
|
contentHeight: Double,
|
|
viewportHeight: Double,
|
|
isAtBottom: Bool
|
|
) {
|
|
let resized = self.contentHeight != contentHeight || self.viewportHeight != viewportHeight
|
|
self.contentHeight = contentHeight
|
|
self.viewportHeight = viewportHeight
|
|
|
|
guard !isDragging else { return }
|
|
if isAtBottom {
|
|
isFollowingTail = true
|
|
awaitingBottom = false
|
|
} else if resized && isFollowingTail {
|
|
requestBottom()
|
|
} else if !awaitingBottom {
|
|
// An offset-only departure also covers accessibility scrolling,
|
|
// which need not deliver the view's drag gesture callbacks.
|
|
isFollowingTail = false
|
|
}
|
|
}
|
|
|
|
public mutating func beginDragging() {
|
|
isDragging = true
|
|
isFollowingTail = false
|
|
awaitingBottom = false
|
|
}
|
|
|
|
public mutating func endDragging(isAtBottom: Bool) {
|
|
guard isDragging else { return }
|
|
isDragging = false
|
|
isFollowingTail = isAtBottom
|
|
awaitingBottom = false
|
|
}
|
|
|
|
public mutating func followTail() {
|
|
isFollowingTail = true
|
|
requestBottom()
|
|
}
|
|
|
|
private mutating func requestBottom() {
|
|
revision += 1
|
|
awaitingBottom = true
|
|
}
|
|
}
|