Files
Luma/Sources/Shared/UI/Components/MessageBubbleEntrance.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

88 lines
2.7 KiB
Swift

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