nvrsion: Add, fix, and refactor iOS session detail view logic to prevent UI lock-ups and high CPU usage during scroll operations.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -284,15 +284,6 @@ struct SessionDetailView: View {
|
|||||||
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
|
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
|
||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
// The jump-to-bottom chevron rides here, immediately above the chat bar — not as a
|
|
||||||
// transcript overlay, which aligned to the scroll view's full-height bounds and so sat
|
|
||||||
// behind this floating bar at the screen's bottom edge. Shown only while scrolled up; a
|
|
||||||
// tap bumps the same scroll request the send button uses, so following resumes once the
|
|
||||||
// transcript reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
|
|
||||||
if !isScrolledToBottom {
|
|
||||||
JumpToBottomButton { scrollToBottomRequest += 1 }
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
|
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
|
||||||
// disabled composer reads as "offline / read-only history" rather than broken.
|
// disabled composer reads as "offline / read-only history" rather than broken.
|
||||||
if !store.connectivity.isLive { disconnectedBanner }
|
if !store.connectivity.isLive { disconnectedBanner }
|
||||||
@@ -300,6 +291,27 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
// The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately
|
||||||
|
// not a stack member. This whole action area is the transcript's bottom `safeAreaInset`,
|
||||||
|
// so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it
|
||||||
|
// appeared — and its visibility is *decided by* that same scroll geometry
|
||||||
|
// (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every
|
||||||
|
// frame near the follow threshold, re-layouting the whole eager transcript each time and
|
||||||
|
// pinning the main thread at 100% with the UI locked. An overlay contributes nothing to
|
||||||
|
// the inset height, so showing or hiding it can't move the scroll geometry at all.
|
||||||
|
// (It's not a transcript overlay either — that aligned to the scroll view's full-height
|
||||||
|
// bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the
|
||||||
|
// same scroll request the send button uses, so following resumes once the transcript
|
||||||
|
// reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if !isScrolledToBottom {
|
||||||
|
JumpToBottomButton { scrollToBottomRequest += 1 }
|
||||||
|
// Hang the button *above* the bar: report its bottom edge (plus a gap) as its
|
||||||
|
// top guide, so the overlay's top alignment places it outside the bar's bounds.
|
||||||
|
.alignmentGuide(.top) { $0[.bottom] + 8 }
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
|
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +588,26 @@ struct TranscriptList: View {
|
|||||||
/// the Mac's `bottomFollowThreshold`.
|
/// the Mac's `bottomFollowThreshold`.
|
||||||
private let bottomFollowThreshold: CGFloat = 24
|
private let bottomFollowThreshold: CGFloat = 24
|
||||||
|
|
||||||
|
/// How far a *user* scroll must move away from the bottom before following disengages. Wider
|
||||||
|
/// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't
|
||||||
|
/// constant (composer lines grow, the working row appears, the keyboard dismisses
|
||||||
|
/// interactively), and every inset change perturbs the scroll geometry by tens of points. A
|
||||||
|
/// single threshold read both ways let one such perturbation flip the gate, whose reactions
|
||||||
|
/// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that
|
||||||
|
/// re-layouted the whole eager transcript every frame and pinned the main thread at 100%.
|
||||||
|
/// The band is wider than any bar-height delta, so only a deliberate scroll crosses it.
|
||||||
|
private let bottomUnfollowThreshold: CGFloat = 64
|
||||||
|
|
||||||
|
/// The live scroll phase, used to tell *user* scrolling (finger down / flick decelerating)
|
||||||
|
/// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a
|
||||||
|
/// user-driven phase may disengage bottom-following — a programmatic perturbation can only
|
||||||
|
/// ever re-engage it — which structurally breaks every geometry→state→geometry feedback
|
||||||
|
/// cycle: no chain of layout reactions can take the gate false and sustain itself.
|
||||||
|
@State private var scrollPhase: ScrollPhase = .idle
|
||||||
|
private var isUserScrolling: Bool {
|
||||||
|
scrollPhase == .tracking || scrollPhase == .interacting || scrollPhase == .decelerating
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -613,22 +645,28 @@ struct TranscriptList: View {
|
|||||||
// they scroll away, which lets new content land off-screen below instead of dragging
|
// they scroll away, which lets new content land off-screen below instead of dragging
|
||||||
// the viewport, and restore `.bottom` once they're back at the end.
|
// the viewport, and restore `.bottom` once they're back at the end.
|
||||||
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
|
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
|
||||||
|
.onScrollPhaseChange { _, newPhase in scrollPhase = newPhase }
|
||||||
// Track the live scroll position straight from the scroll view's geometry: how much
|
// Track the live scroll position straight from the scroll view's geometry: how much
|
||||||
// content still sits below the viewport bottom. Within the slack threshold means the
|
// content still sits below the viewport bottom. Parked within the follow threshold
|
||||||
// user is parked at the end (live output keeps following); scrolling up flips this
|
// means live output keeps following; a *user* scroll past the (wider) unfollow
|
||||||
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron.
|
// threshold flips it false, which drops the anchor (above) and reveals the chevron.
|
||||||
.onScrollGeometryChange(for: Bool.self) { geo in
|
// Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
|
||||||
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
|
// programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
|
||||||
<= bottomFollowThreshold
|
// animation) may re-engage following but can never disengage it, so no layout
|
||||||
} action: { _, atBottom in
|
// feedback cycle through this gate can sustain itself. Tracking the rounded distance
|
||||||
// While the chat is opening the layout grows over a few passes and the scroll
|
// (not a Bool) also means every scroll emits fresh values, so the gate can't latch
|
||||||
// offset lags each growth by a frame — sampling that frame reads "not at bottom"
|
// against a stale reading (the old settle-window latch bug).
|
||||||
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the
|
.onScrollGeometryChange(for: CGFloat.self) { geo in
|
||||||
// open window accept only "at bottom" readings; honor real scroll-ups after.
|
(geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
|
||||||
if transcriptSettling {
|
} action: { _, distance in
|
||||||
if atBottom { isScrolledToBottom = true }
|
if distance <= bottomFollowThreshold {
|
||||||
} else {
|
isScrolledToBottom = true
|
||||||
isScrolledToBottom = atBottom
|
} else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling {
|
||||||
|
// While the chat is opening the layout grows over a few passes and the offset
|
||||||
|
// lags each growth by a frame — those frames read "not at bottom" even though
|
||||||
|
// `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts
|
||||||
|
// only re-engagement (the `!transcriptSettling` above).
|
||||||
|
isScrolledToBottom = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Detect when the opening layout has come to rest: track the content height while
|
// Detect when the opening layout has come to rest: track the content height while
|
||||||
@@ -657,9 +695,15 @@ struct TranscriptList: View {
|
|||||||
}
|
}
|
||||||
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
||||||
// so every streamed delta keeps the view pinned to the bottom — but only while the
|
// so every streamed delta keeps the view pinned to the bottom — but only while the
|
||||||
// user is already parked there. Scrolling up to read history is never yanked down.
|
// user is already parked there. Scrolling up to read history is never yanked down,
|
||||||
|
// and a finger actively on the transcript is never fought mid-drag (the drag that
|
||||||
|
// takes them past the unfollow threshold flips the gate; until then the native
|
||||||
|
// bottom anchor alone keeps content pinned, without an animated scroll grabbing the
|
||||||
|
// viewport back out of their hand).
|
||||||
.onChange(of: events.count) {
|
.onChange(of: events.count) {
|
||||||
if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) }
|
if isScrolledToBottom, !isUserScrolling {
|
||||||
|
scrollToEnd(proxy, animated: !transcriptSettling)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// An explicit jump — the chevron or sending a message — always wins. The chevron
|
// An explicit jump — the chevron or sending a message — always wins. The chevron
|
||||||
// itself lives in the parent's chat bar (above the composer), not as an overlay here,
|
// itself lives in the parent's chat bar (above the composer), not as an overlay here,
|
||||||
|
|||||||
Reference in New Issue
Block a user