ios: smooth Agent entrances and refresh app icon
TestFlight / Build and upload (push) Successful in 1m49s
TestFlight / Build and upload (push) Successful in 1m49s
Grow row height and spacing with the existing entrance animation, remove the Latest overlay while retaining scroll/read/send behavior, and reuse the current PWA icon artwork. Cover occupied-height interpolation and scroll-to-latest navigation in focused checks.
This commit is contained in:
@@ -44,7 +44,7 @@ private struct AgentTranscriptView: View {
|
||||
let displayed = transcript.displayed
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 15) {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
// The native scroll origin is the visual bottom. Keep model
|
||||
// and cursor order ascending; reverse only presentation.
|
||||
Color.clear.frame(height: 17).id(Self.bottomID)
|
||||
@@ -56,18 +56,22 @@ private struct AgentTranscriptView: View {
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
|
||||
AgentTranscriptRow(message: message, room: room,
|
||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||
.equatable()
|
||||
// Include the gap in the occupied-space entrance.
|
||||
.padding(.bottom, 15)
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
.id(message.id)
|
||||
}
|
||||
if room.isLoading {
|
||||
ProgressView("Loading messages…").frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
if allMessages.count > displayed.count {
|
||||
@@ -78,6 +82,7 @@ private struct AgentTranscriptView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
@@ -104,28 +109,12 @@ private struct AgentTranscriptView: View {
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.atBottom = atBottom
|
||||
// Only user scrolling changes follow intent. Animated Latest
|
||||
// navigation and native keyboard resizing must not cancel it.
|
||||
// Only user scrolling changes follow intent. Send navigation
|
||||
// and native keyboard resizing must not cancel it.
|
||||
if isScrolling && scrollPhase != .animating {
|
||||
atLatest = atBottom
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !atLatest {
|
||||
Button {
|
||||
atLatest = true
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .top) }
|
||||
} label: {
|
||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
.padding(12)
|
||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||
.overlay(Capsule().stroke(SybilTheme.border))
|
||||
}
|
||||
.padding(14)
|
||||
.accessibilityIdentifier("agent-jump-latest")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
appeared = true
|
||||
if !room.isLoading {
|
||||
@@ -306,17 +295,37 @@ private struct AgentMessageRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the row's measured height stable for lazy layout. Animate its appearance
|
||||
// without collapsing the scroll target while the composer or keyboard resizes.
|
||||
// Grow the occupied space with the bubble's entrance so neighboring rows move
|
||||
// smoothly. Measure at natural height; no geometry state or scroll correction.
|
||||
private struct AgentEntranceLayout: Layout {
|
||||
var progress: CGFloat
|
||||
var animatableData: CGFloat {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(.init(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: size.width, height: size.height * progress)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(at: bounds.origin, proposal: .init(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentEntrance: ViewModifier {
|
||||
var animate: Bool
|
||||
var reduceMotion: Bool
|
||||
@State private var entered = false
|
||||
func body(content: Content) -> some View {
|
||||
let preparing = animate && !entered && !reduceMotion
|
||||
content
|
||||
.opacity(preparing ? 0 : 1)
|
||||
.offset(y: preparing ? 16 : 0)
|
||||
AgentEntranceLayout(progress: preparing ? 0 : 1) {
|
||||
content
|
||||
.opacity(preparing ? 0 : 1)
|
||||
.offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user