added more anomations
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt
2026-09-18 14:07:29 +07:00
parent 0d6c562abd
commit 3e990e7af9
4 changed files with 435 additions and 1 deletions
@@ -0,0 +1,82 @@
import Foundation
import SwiftUI
/// Timing and geometry for the Messages-style chat animations.
///
/// Two effects live here: the staggered rise of the first visible page when a
/// conversation opens, and the transition a freshly appended row uses while the
/// user already watches the timeline (an outgoing bubble launches from the
/// composer's send control, an incoming one springs from its own edge).
enum ChatMessageEntrancePolicy {
/// A row waits this long behind every newer row below it, so the visible
/// page unfolds bottom-up the way Messages does.
static let entranceStagger: TimeInterval = 0.035
/// Rows further from the bottom than this are left alone: past a screenful
/// the cascade is invisible and the row may only appear on a later scroll.
static let entranceCascadeLimit = 12
static let entranceRise: CGFloat = 16
static let entranceScale: CGFloat = 0.94
static let sendScale: CGFloat = 0.2
/// Launch point sits next to the composer's send control so the bubble
/// reads as thrown out of the field instead of fading in place.
static let sendRise: CGFloat = 12
static let receiveScale: CGFloat = 0.4
static let receiveRise: CGFloat = 10
/// Upper bound of the spring used by both effects. The owning view keeps a
/// row's entrance state alive for this long so a recycled row that reappears
/// while scrolling plays nothing.
static let entranceAnimationDuration: TimeInterval = 0.75
/// A burst larger than this is a history page arriving, not a conversation:
/// animating every row of a page would flash the whole timeline.
static let appendAnimationLimit = 4
/// Total time the opening cascade can still be pending for the last row.
static var entranceCascadeDuration: TimeInterval {
TimeInterval(entranceCascadeLimit - 1) * entranceStagger
+ entranceAnimationDuration
}
static var appendAnimation: Animation {
.spring(response: 0.44, dampingFraction: 0.68)
}
/// Per-row delays keyed by client ID. The newest row leads the cascade and
/// every older row follows one stagger step behind it; rows outside the
/// cascade get no delay and therefore no entrance at all.
static func entranceDelays(forIDs ids: [String]) -> [String: TimeInterval] {
let last = ids.count - 1
guard last >= 0 else { return [:] }
let firstAnimated = max(0, ids.count - entranceCascadeLimit)
var delays: [String: TimeInterval] = [:]
delays.reserveCapacity(ids.count - firstAnimated)
for index in firstAnimated...last {
delays[ids[index]] = TimeInterval(last - index) * entranceStagger
}
return delays
}
/// Rows a query refresh added below everything already on screen.
///
/// A MAM page paged in above the viewport is deliberately ignored: replaying
/// the entrance mid-scroll would yank the timeline the user is reading. A
/// burst of rows is treated as the same kind of archive delivery.
static func appendedLaunchIDs(
in ids: [String],
after previous: Set<String>
) -> [String] {
guard !previous.isEmpty else { return [] }
guard let lastKnownIndex = ids.lastIndex(where: previous.contains)
else { return [] }
let appended = ids.dropFirst(lastKnownIndex + 1).filter {
!previous.contains($0)
}
guard appended.count <= appendAnimationLimit else { return [] }
return Array(appended)
}
}
+106 -1
View File
@@ -75,6 +75,13 @@ struct ChatView: View {
@State private var captureSuspendsArchiveSync = false
@State private var pickerResetToken = UUID()
@State private var timelineEntries: [ChatTimelineEntry] = []
/// Messages-style entrance state: which rows currently animate, which IDs
/// the timeline already showed, and whether the opening cascade of this
/// conversation has played.
@State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:]
@State private var knownEntranceIDs: Set<String> = []
@State private var hasPlayedOpeningEntrance = false
@State private var entranceReleaseToken = UUID()
/// Messages of this conversation straight from the SwiftData store,
/// ordered like `AppModel.selectedMessages`: timestamp, then clientID.
@@ -143,6 +150,13 @@ struct ChatView: View {
_captureSuspendsArchiveSync = State(initialValue: false)
_pickerResetToken = State(initialValue: UUID())
_timelineEntries = State<[ChatTimelineEntry]>(initialValue: [])
_entranceModes = State<[String: MessageBubbleEntrance.Mode]>(
initialValue: [:]
)
_knownEntranceIDs = State<Set<String>>(initialValue: [])
_hasPlayedOpeningEntrance = State(initialValue: false)
_entranceConversationID = State<String?>(initialValue: nil)
_entranceReleaseToken = State(initialValue: UUID())
}
var body: some View {
@@ -376,6 +390,10 @@ struct ChatView: View {
historyTopTriggerVisible = false
historyAutoContinueCount = 0
emptyHistoryRetryCount = 0
entranceModes = [:]
knownEntranceIDs = []
hasPlayedOpeningEntrance = false
entranceReleaseToken = UUID()
}
.onDisappear {
model.endConversationViewing(jid: conversation.jid)
@@ -478,7 +496,87 @@ struct ChatView: View {
private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil)
{
timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages)
let source = newMessages ?? messages
timelineEntries = ChatTimelineEntry.make(from: source)
updateEntranceModes(for: source)
}
/// Decides which rows play a Messages-style entrance after a rebuild.
/// The first page of an opened conversation cascades bottom-up; rows that
/// appear later (a send, an incoming message) launch from the composer's
/// edge. Modes are released once the animation window closes so a row
/// recycled by the lazy stack during scrolling reappears statically.
private func updateEntranceModes(for newMessages: [ChatMessage]) {
let ids = newMessages.map(\.clientID)
let known = knownEntranceIDs
knownEntranceIDs = Set(ids)
guard hasPlayedOpeningEntrance else {
guard !ids.isEmpty else { return }
hasPlayedOpeningEntrance = true
// The first message ever in an empty chat should launch out of
// the composer like any other send, not cascade in.
if ids.count == 1, known.isEmpty,
newMessages.first?.direction == .outgoing
{
applyLaunch(for: newMessages, ids: ids)
return
}
let delays = ChatMessageEntrancePolicy.entranceDelays(
forIDs: ids
)
for (id, delay) in delays {
entranceModes[id] = .cascade(delay: delay)
}
scheduleEntranceRelease(
ids: Set(delays.keys),
after: ChatMessageEntrancePolicy.entranceCascadeDuration
)
return
}
applyLaunch(
for: newMessages,
ids: ChatMessageEntrancePolicy.appendedLaunchIDs(
in: ids,
after: known
)
)
}
private func applyLaunch(for messages: [ChatMessage], ids: [String]) {
guard !ids.isEmpty else { return }
let launched = Set(ids)
var applied: Set<String> = []
for message in messages where launched.contains(message.clientID) {
entranceModes[message.clientID] = .launch(
outgoing: message.direction == .outgoing
)
applied.insert(message.clientID)
}
scheduleEntranceRelease(
ids: applied,
after: ChatMessageEntrancePolicy.entranceAnimationDuration
)
}
private func scheduleEntranceRelease(
ids: Set<String>,
after delay: TimeInterval
) {
guard !ids.isEmpty else { return }
let token = entranceReleaseToken
Task { @MainActor in
try? await Task.sleep(
nanoseconds: UInt64(delay * 1_000_000_000)
)
guard !Task.isCancelled, token == entranceReleaseToken else {
return
}
for id in ids {
entranceModes[id] = nil
}
}
}
private var isSelectingMessages: Bool {
@@ -606,6 +704,13 @@ struct ChatView: View {
)
}
)
.modifier(
MessageBubbleEntrance(
mode: entranceModes[
message.clientID
] ?? .settled
)
)
.id(message.clientID)
}
}
@@ -0,0 +1,87 @@
import SwiftUI
/// Messages-style entrance for chat rows.
///
/// Opening a conversation reveals its first page as a bottom-up cascade
/// (`.cascade`), and a row appended while the timeline is already rendered
/// springs out of the composer's send control when outgoing (`.launch`).
/// Everything else stays `.settled`, which keeps history scrolling, reactions
/// and selection updates free of animation.
struct MessageBubbleEntrance: ViewModifier {
enum Mode: Equatable {
case settled
case cascade(delay: TimeInterval)
case launch(outgoing: Bool)
var isSettled: Bool {
if case .settled = self { return true }
return false
}
}
let mode: Mode
// A settled row must never pass through an invisible frame, so only the
// rows that actually animate start hidden.
@State private var isRevealed: Bool
@State private var hasStarted = false
init(mode: Mode) {
self.mode = mode
_isRevealed = State(initialValue: mode.isSettled)
}
func body(content: Content) -> some View {
content
.opacity(isRevealed ? 1 : 0)
.scaleEffect(isRevealed ? 1 : displacedScale, anchor: scaleAnchor)
.offset(y: isRevealed ? 0 : displacedRise)
.onAppear(perform: reveal)
}
/// Outgoing bubbles shrink toward the trailing bottom corner, where the
/// composer's send control sits; incoming ones grow from their own edge.
private var displacedScale: CGFloat {
switch mode {
case .settled, .cascade:
return ChatMessageEntrancePolicy.entranceScale
case .launch(let outgoing):
return outgoing
? ChatMessageEntrancePolicy.sendScale
: ChatMessageEntrancePolicy.receiveScale
}
}
private var scaleAnchor: UnitPoint {
if case .launch(let outgoing) = mode, outgoing { return .bottomTrailing }
return .bottom
}
private var displacedRise: CGFloat {
switch mode {
case .settled, .cascade:
return ChatMessageEntrancePolicy.entranceRise
case .launch(let outgoing):
return outgoing
? ChatMessageEntrancePolicy.sendRise
: ChatMessageEntrancePolicy.receiveRise
}
}
private func reveal() {
guard !hasStarted else { return }
hasStarted = true
guard !isRevealed else { return }
let delay: TimeInterval
if case .cascade(let cascadeDelay) = mode {
delay = cascadeDelay
} else {
delay = 0
}
withAnimation(
ChatMessageEntrancePolicy.appendAnimation.delay(delay)
) {
isRevealed = true
}
}
}
+160
View File
@@ -0,0 +1,160 @@
Sources/Shared/UI/ChatView.swift
────────────────────────────────────────────────────────────────────────────────────────────
────────────────────────────┐
75: struct ChatView: View { │
────────────────────────────┘
 @State private var captureSuspendsArchiveSync = false
 @State private var pickerResetToken = UUID()
 @State private var timelineEntries: [ChatTimelineEntry] = []
 /// Messages-style entrance state: which rows currently animate, which IDs
 /// the timeline already showed, and whether the opening cascade of this
 /// conversation has played.
 @State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:]
 @State private var knownEntranceIDs: Set<String> = []
 @State private var hasPlayedOpeningEntrance = false
 @State private var entranceReleaseToken = UUID()
 /// Messages of this conversation straight from the SwiftData store,
 /// ordered like `AppModel.selectedMessages`: timestamp, then clientID.
─────────────────────────────┐
150: struct ChatView: View { │
─────────────────────────────┘
 _captureSuspendsArchiveSync = State(initialValue: false)
 _pickerResetToken = State(initialValue: UUID())
 _timelineEntries = State<[ChatTimelineEntry]>(initialValue: [])
 _entranceModes = State<[String: MessageBubbleEntrance.Mode]>(
 initialValue: [:]
 )
 _knownEntranceIDs = State<Set<String>>(initialValue: [])
 _hasPlayedOpeningEntrance = State(initialValue: false)
 _entranceConversationID = State<String?>(initialValue: nil)
 _entranceReleaseToken = State(initialValue: UUID())
 }
 var body: some View {
─────────────────────────────┐
390: struct ChatView: View { │
─────────────────────────────┘
 historyTopTriggerVisible = false
 historyAutoContinueCount = 0
 emptyHistoryRetryCount = 0
 entranceModes = [:]
 knownEntranceIDs = []
 hasPlayedOpeningEntrance = false
 entranceReleaseToken = UUID()
 }
 .onDisappear {
 model.endConversationViewing(jid: conversation.jid)
─────────────────────────────┐
496: struct ChatView: View { │
─────────────────────────────┘
 private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil)
 {
 timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages)
 let source = newMessages ?? messages
 timelineEntries = ChatTimelineEntry.make(from: source)
 updateEntranceModes(for: source)
 }

 /// Decides which rows play a Messages-style entrance after a rebuild.
 /// The first page of an opened conversation cascades bottom-up; rows that
 /// appear later (a send, an incoming message) launch from the composer's
 /// edge. Modes are released once the animation window closes so a row
 /// recycled by the lazy stack during scrolling reappears statically.
 private func updateEntranceModes(for newMessages: [ChatMessage]) {
 let ids = newMessages.map(\.clientID)
 let known = knownEntranceIDs
 knownEntranceIDs = Set(ids)

 guard hasPlayedOpeningEntrance else {
 guard !ids.isEmpty else { return }
 hasPlayedOpeningEntrance = true
 // The first message ever in an empty chat should launch out of
 // the composer like any other send, not cascade in.
 if ids.count == 1, known.isEmpty,
 newMessages.first?.direction == .outgoing
 {
 applyLaunch(for: newMessages, ids: ids)
 return
 }
 let delays = ChatMessageEntrancePolicy.entranceDelays(
 forIDs: ids
 )
 for (id, delay) in delays {
 entranceModes[id] = .cascade(delay: delay)
 }
 scheduleEntranceRelease(
 ids: Set(delays.keys),
 after: ChatMessageEntrancePolicy.entranceCascadeDuration
 )
 return
 }

 applyLaunch(
 for: newMessages,
 ids: ChatMessageEntrancePolicy.appendedLaunchIDs(
 in: ids,
 after: known
 )
 )
 }

 private func applyLaunch(for messages: [ChatMessage], ids: [String]) {
 guard !ids.isEmpty else { return }
 let launched = Set(ids)
 var applied: Set<String> = []
 for message in messages where launched.contains(message.clientID) {
 entranceModes[message.clientID] = .launch(
 outgoing: message.direction == .outgoing
 )
 applied.insert(message.clientID)
 }
 scheduleEntranceRelease(
 ids: applied,
 after: ChatMessageEntrancePolicy.entranceAnimationDuration
 )
 }

 private func scheduleEntranceRelease(
 ids: Set<String>,
 after delay: TimeInterval
 ) {
 guard !ids.isEmpty else { return }
 let token = entranceReleaseToken
 Task { @MainActor in
 try? await Task.sleep(
 nanoseconds: UInt64(delay * 1_000_000_000)
 )
 guard !Task.isCancelled, token == entranceReleaseToken else {
 return
 }
 for id in ids {
 entranceModes[id] = nil
 }
 }
 }
 private var isSelectingMessages: Bool {
─────────────────────────────┐
704: struct ChatView: View { │
─────────────────────────────┘
 )
 }
 )
 .modifier(
 MessageBubbleEntrance(
 mode: entranceModes[
 message.clientID
 ] ?? .settled
 )
 )
 .id(message.clientID)
 }
 }