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