Files
Luma/Sources/Shared/Models/ChatMessageEntrancePolicy.swift
T
wt 3e990e7af9
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
added more anomations
2026-09-18 14:07:29 +07:00

82 lines
3.5 KiB
Swift

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)
}
}