eleven / ocho Public

core: typing dots keep their wave — clock-driven, not delay-staggered (#296)

The three dots ran delay-staggered repeatForever animations, which
re-sync into a lockstep pogo whenever the row re-renders (any feed
publish collapses the per-dot delays). Dot positions now derive from
TimelineView's clock — a phase-offset sine that can't drift or sync by
construction. Both platforms (shared core).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ad31b891d5ac329adeb74dae3a66ae0b1ccb9b1b parent 7da4716
1 file changed, +14 −11
  • ios/LChatCore/Sources/LChatCore/TypingBubble.swift +14 −11
diff --git a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
index 8086f9d..a3cdee1 100644
--- a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift
@@ -9,7 +9,6 @@ public struct TypingBubble: View {
public let label: String?
@Environment(\.colorScheme) private var scheme
- @State private var bouncing = false
public init(theme: ChatTheme, label: String? = nil) {
self.theme = theme
@@ -24,15 +23,20 @@ public struct TypingBubble: View {
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
}
- HStack(spacing: 5) {
- ForEach(0..<3, id: \.self) { i in
- Circle()
- .fill(.secondary)
- .frame(width: 7, height: 7)
- .offset(y: bouncing ? -3 : 1)
- .animation(.easeInOut(duration: 0.55)
- .repeatForever(autoreverses: true)
- .delay(Double(i) * 0.18), value: bouncing)
+ // Positions come from the CLOCK, not delay-staggered repeatForever
+ // animations: those re-sync into a lockstep pogo whenever the row
+ // re-renders (any feed publish collapses the per-dot delays). A
+ // phase-offset sine can't drift or sync by construction.
+ TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
+ let t = context.date.timeIntervalSinceReferenceDate
+ HStack(spacing: 5) {
+ ForEach(0..<3, id: \.self) { i in
+ let phase = sin((t / 0.55 - Double(i) * 0.33) * .pi)
+ Circle()
+ .fill(.secondary)
+ .frame(width: 7, height: 7)
+ .offset(y: -1 + phase * -2)
+ }
}
}
.padding(.horizontal, 14)
@@ -40,6 +44,5 @@ public struct TypingBubble: View {
.background(theme.bubbleThem.resolved(scheme))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
- .onAppear { bouncing = true }
}
}