commit e921111ecb1e8674e6c4848acf10e21b1202603e
parent 5ca5314401000e57dadd5534b93b62a8ba0d2db5
Author: Michael Camilleri <[email protected]>
Date: Fri, 22 May 2026 15:27:35 +0900
Tweak animations to make sending more obvious
Diffstat:
1 file changed, 85 insertions(+), 9 deletions(-)
diff --git a/Crossmate/Views/FriendAvatarView.swift b/Crossmate/Views/FriendAvatarView.swift
@@ -5,7 +5,8 @@ struct FriendAvatarView: View {
/// avatar perfectly static, so call sites that merely identify a friend
/// (e.g. `GameShareItem`) are completely unaffected.
enum InvitePhase: Equatable {
- /// Invite is in flight: the player's own glyph wiggles in place.
+ /// Invite is in flight: the glyph spins over to a paper plane, then
+ /// rings broadcast outward from the avatar.
case sending
/// Invite delivered: the glyph spins once and becomes a checkmark.
case sent
@@ -15,42 +16,117 @@ struct FriendAvatarView: View {
var size: CGFloat = 34
var invitePhase: InvitePhase? = nil
+ /// Spin shared by the avatar → paper plane and paper plane → checkmark
+ /// transitions, so both glyph changes turn over identically.
+ private let glyphSpin = Animation.spring(response: 0.55, dampingFraction: 0.65)
+
@State private var revolutions = 0
+ @State private var ringsActive = false
+ @State private var ringsTask: Task<Void, Never>?
private var avatar: FriendAvatar {
FriendAvatar.avatar(for: authorID)
}
- /// On success the friend's identity glyph is *replaced* by a checkmark
- /// rather than a separate accessory appearing — the symbol the user tapped
- /// is the one that confirms.
+ /// The glyph shown in the avatar. While sending it is a paper plane; on
+ /// success it is *replaced* by a checkmark rather than a separate accessory
+ /// appearing — the symbol the user tapped is the one that confirms.
private var symbolName: String {
- invitePhase == .sent ? "checkmark" : avatar.symbolName
+ switch invitePhase {
+ case .sent:
+ return "checkmark"
+ case .sending:
+ return "paperplane.fill"
+ case nil:
+ return avatar.symbolName
+ }
+ }
+
+ /// Stroke width of the broadcast rings, scaled so it reads at both a small
+ /// list-row avatar and a large share-grid tile.
+ private var ringWidth: CGFloat {
+ max(1.5, size * 0.045)
}
var body: some View {
ZStack {
+ // Behind the avatar so the rings appear to emanate from its edge.
+ if ringsActive {
+ BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0)
+ BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0.7)
+ }
+
Circle()
.fill(avatar.background)
Image(systemName: symbolName)
.font(.system(size: size * 0.46, weight: .semibold))
.foregroundStyle(.white)
- .symbolEffect(.wiggle, options: .repeating, isActive: invitePhase == .sending)
.contentTransition(.symbolEffect(.replace))
.rotationEffect(.degrees(Double(revolutions) * 360))
+ // Animate the glyph swap itself so the friend symbol → paper
+ // plane → checkmark changes morph rather than hard-cut.
+ .animation(.default, value: symbolName)
}
.frame(width: size, height: size)
+ .onAppear {
+ if invitePhase == .sending { ringsActive = true }
+ }
.onChange(of: invitePhase) { _, phase in
- guard phase == .sent else { return }
- withAnimation(.spring(response: 0.55, dampingFraction: 0.65)) {
- revolutions += 1
+ ringsTask?.cancel()
+ switch phase {
+ case .sending:
+ // The same spin as the checkmark turns the avatar over to the
+ // paper plane; the broadcast then starts once it has settled.
+ withAnimation(glyphSpin) { revolutions += 1 }
+ ringsTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(0.5))
+ guard !Task.isCancelled else { return }
+ ringsActive = true
+ }
+ case .sent:
+ ringsActive = false
+ withAnimation(glyphSpin) { revolutions += 1 }
+ case nil:
+ ringsActive = false
}
}
+ .onDisappear {
+ ringsTask?.cancel()
+ }
.accessibilityHidden(true)
}
}
+/// A single ring that grows out of the avatar and fades, looping forever. Each
+/// ring owns its animation and starts it in `onAppear`, so simply inserting
+/// the ring (when sending begins) is enough to set it going — no external
+/// trigger to keep in sync.
+private struct BroadcastRing: View {
+ let color: Color
+ let lineWidth: CGFloat
+ /// Staggers this ring against its siblings so the broadcast is continuous.
+ let delay: Double
+
+ @State private var expanded = false
+
+ var body: some View {
+ Circle()
+ .stroke(color, lineWidth: lineWidth)
+ .scaleEffect(expanded ? 1.55 : 1.0)
+ .opacity(expanded ? 0 : 0.5)
+ .onAppear {
+ withAnimation(
+ .easeOut(duration: 1.4)
+ .repeatForever(autoreverses: false)
+ .delay(delay)
+ ) {
+ expanded = true
+ }
+ }
+ }
+}
+
private struct FriendAvatar {
let symbolName: String
let background: Color