FriendAvatarView.swift (5713B)
1 import SwiftUI 2 3 struct FriendAvatarView: View { 4 /// Drives the in-place invite animation. `nil` (the default) keeps the 5 /// avatar perfectly static, so call sites that merely identify a friend 6 /// (e.g. `GameShareItem`) are completely unaffected. 7 enum InvitePhase: Equatable { 8 /// Invite is in flight: the glyph spins over to a paper plane, then 9 /// rings broadcast outward from the avatar. 10 case sending 11 /// Invite delivered: the glyph spins once and becomes a checkmark. 12 case sent 13 } 14 15 let authorID: String 16 var size: CGFloat = 34 17 var invitePhase: InvitePhase? = nil 18 19 /// Spin shared by the avatar → paper plane and paper plane → checkmark 20 /// transitions, so both glyph changes turn over identically. 21 private let glyphSpin = Animation.spring(response: 0.55, dampingFraction: 0.65) 22 23 @State private var revolutions = 0 24 @State private var ringsActive = false 25 @State private var ringsTask: Task<Void, Never>? 26 27 private var avatar: FriendAvatar { 28 FriendAvatar.avatar(for: authorID) 29 } 30 31 /// The glyph shown in the avatar. While sending it is a paper plane; on 32 /// success it is *replaced* by a checkmark rather than a separate accessory 33 /// appearing — the symbol the user tapped is the one that confirms. 34 private var symbolName: String { 35 switch invitePhase { 36 case .sent: 37 return "checkmark" 38 case .sending: 39 return "paperplane.fill" 40 case nil: 41 return avatar.symbolName 42 } 43 } 44 45 /// Stroke width of the broadcast rings, scaled so it reads at both a small 46 /// list-row avatar and a large share-grid tile. 47 private var ringWidth: CGFloat { 48 max(1.5, size * 0.045) 49 } 50 51 var body: some View { 52 ZStack { 53 // Behind the avatar so the rings appear to emanate from its edge. 54 if ringsActive { 55 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0) 56 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0.7) 57 } 58 59 Circle() 60 .fill(avatar.background) 61 62 Image(systemName: symbolName) 63 .font(.system(size: size * 0.46, weight: .semibold)) 64 .foregroundStyle(.white) 65 .contentTransition(.symbolEffect(.replace)) 66 .rotationEffect(.degrees(Double(revolutions) * 360)) 67 // Animate the glyph swap itself so the friend symbol → paper 68 // plane → checkmark changes morph rather than hard-cut. 69 .animation(.default, value: symbolName) 70 } 71 .frame(width: size, height: size) 72 .onAppear { 73 if invitePhase == .sending { ringsActive = true } 74 } 75 .onChange(of: invitePhase) { _, phase in 76 ringsTask?.cancel() 77 switch phase { 78 case .sending: 79 // The same spin as the checkmark turns the avatar over to the 80 // paper plane; the broadcast then starts once it has settled. 81 withAnimation(glyphSpin) { revolutions += 1 } 82 ringsTask = Task { @MainActor in 83 try? await Task.sleep(for: .seconds(0.5)) 84 guard !Task.isCancelled else { return } 85 ringsActive = true 86 } 87 case .sent: 88 ringsActive = false 89 withAnimation(glyphSpin) { revolutions += 1 } 90 case nil: 91 ringsActive = false 92 } 93 } 94 .onDisappear { 95 ringsTask?.cancel() 96 } 97 .accessibilityHidden(true) 98 } 99 } 100 101 /// A single ring that grows out of the avatar and fades, looping forever. Each 102 /// ring owns its animation and starts it in `onAppear`, so simply inserting 103 /// the ring (when sending begins) is enough to set it going — no external 104 /// trigger to keep in sync. 105 private struct BroadcastRing: View { 106 let color: Color 107 let lineWidth: CGFloat 108 /// Staggers this ring against its siblings so the broadcast is continuous. 109 let delay: Double 110 111 @State private var expanded = false 112 113 var body: some View { 114 Circle() 115 .stroke(color, lineWidth: lineWidth) 116 .scaleEffect(expanded ? 1.55 : 1.0) 117 .opacity(expanded ? 0 : 0.5) 118 .onAppear { 119 withAnimation( 120 .easeOut(duration: 1.4) 121 .repeatForever(autoreverses: false) 122 .delay(delay) 123 ) { 124 expanded = true 125 } 126 } 127 } 128 } 129 130 private struct FriendAvatar { 131 let symbolName: String 132 let background: Color 133 134 static func avatar(for authorID: String) -> FriendAvatar { 135 // The symbol is seeded with a distinct string from the colour (which 136 // `stableColor` hashes off the raw authorID) so a friend's symbol and 137 // colour vary independently rather than always pairing up. The avatar 138 // is global to the friend list, so no colours are reserved here — this 139 // is the friend's base colour, which a busy game may probe off. 140 let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)] 141 let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: []) 142 return FriendAvatar(symbolName: symbol, background: color.tint) 143 } 144 145 private static let symbols: [String] = [ 146 "star.fill", 147 "sparkles", 148 "bolt.fill", 149 "moon.fill", 150 "heart.fill", 151 "crown.fill", 152 "leaf.fill", 153 "flame.fill" 154 ] 155 }