88 lines
2.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|