crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate/Views/FriendAvatarView.swift | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
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