FriendAvatarView.swift (8807B)
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 is durable locally and waiting for CloudKit acknowledgement. 12 /// The clock keeps the broadcast rings active while delivery is queued. 13 case queued 14 /// CloudKit confirmed the Ping exists: the glyph spins once and becomes 15 /// a checkmark. This does not claim the recipient has fetched it. 16 case sent 17 } 18 19 let authorID: String 20 var size: CGFloat = 34 21 var invitePhase: InvitePhase? = nil 22 23 /// Spin shared by the avatar → paper plane and paper plane → checkmark 24 /// transitions, so both glyph changes turn over identically. 25 private let glyphSpin = Animation.spring(response: 0.55, dampingFraction: 0.65) 26 27 @State private var revolutions = 0 28 @State private var ringsActive = false 29 @State private var ringsTask: Task<Void, Never>? 30 31 private var avatar: FriendAvatar { 32 FriendAvatar.avatar(for: authorID) 33 } 34 35 /// The glyph shown in the avatar. While sending it is a paper plane; on 36 /// success it is *replaced* by a checkmark rather than a separate accessory 37 /// appearing — the symbol the user tapped is the one that confirms. 38 private var symbolName: String { 39 switch invitePhase { 40 case .sent: 41 return "checkmark" 42 case .queued: 43 return "clock.fill" 44 case .sending: 45 return "paperplane.fill" 46 case nil: 47 return avatar.symbolName 48 } 49 } 50 51 /// Stroke width of the broadcast rings, scaled so it reads at both a small 52 /// list-row avatar and a large share-grid tile. 53 private var ringWidth: CGFloat { 54 max(1.5, size * 0.045) 55 } 56 57 var body: some View { 58 ZStack { 59 // Behind the avatar so the rings appear to emanate from its edge. 60 if ringsActive { 61 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0) 62 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0.7) 63 } 64 65 Circle() 66 .fill(avatar.background) 67 // A soft radial wash from a lighter centre out to the base 68 // grey, so the medallion reads as gently domed rather than 69 // flat. Disabled for now — swap the flat `.fill` above for this 70 // to turn it back on. 71 // .fill( 72 // RadialGradient( 73 // colors: [Color(.systemGray3), avatar.background], 74 // center: .center, 75 // startRadius: 0, 76 // endRadius: size / 2 77 // ) 78 // ) 79 80 // A faint, tiled watermark of the friend's own symbol — just enough 81 // texture to lift the flat medallion, sitting under the main glyph. 82 SymbolPattern(symbolName: avatar.symbolName, size: size) 83 84 Image(systemName: symbolName) 85 .font(.system(size: size * 0.46, weight: .semibold)) 86 .foregroundStyle(.white) 87 .contentTransition(.symbolEffect(.replace)) 88 .rotationEffect(.degrees(Double(revolutions) * 360)) 89 // Animate the glyph swap itself so the friend symbol → paper 90 // plane → checkmark changes morph rather than hard-cut. 91 .animation(.default, value: symbolName) 92 } 93 .frame(width: size, height: size) 94 .onAppear { 95 if invitePhase == .sending || invitePhase == .queued { 96 ringsActive = true 97 } 98 } 99 .onChange(of: invitePhase) { _, phase in 100 ringsTask?.cancel() 101 switch phase { 102 case .sending: 103 // The same spin as the checkmark turns the avatar over to the 104 // paper plane; the broadcast then starts once it has settled. 105 withAnimation(glyphSpin) { revolutions += 1 } 106 ringsTask = Task { @MainActor in 107 try? await Task.sleep(for: .seconds(0.5)) 108 guard !Task.isCancelled else { return } 109 ringsActive = true 110 } 111 case .queued: 112 ringsActive = true 113 withAnimation(glyphSpin) { revolutions += 1 } 114 case .sent: 115 ringsActive = false 116 withAnimation(glyphSpin) { revolutions += 1 } 117 case nil: 118 ringsActive = false 119 } 120 } 121 .onDisappear { 122 ringsTask?.cancel() 123 } 124 .accessibilityHidden(true) 125 } 126 } 127 128 /// A single ring that grows out of the avatar and fades, looping forever. Each 129 /// ring owns its animation and starts it in `onAppear`, so simply inserting 130 /// the ring (when sending begins) is enough to set it going — no external 131 /// trigger to keep in sync. 132 private struct BroadcastRing: View { 133 let color: Color 134 let lineWidth: CGFloat 135 /// Staggers this ring against its siblings so the broadcast is continuous. 136 let delay: Double 137 138 @State private var expanded = false 139 140 var body: some View { 141 Circle() 142 .stroke(color, lineWidth: lineWidth) 143 .scaleEffect(expanded ? 1.55 : 1.0) 144 .opacity(expanded ? 0 : 0.5) 145 .onAppear { 146 withAnimation( 147 .easeOut(duration: 1.4) 148 .repeatForever(autoreverses: false) 149 .delay(delay) 150 ) { 151 expanded = true 152 } 153 } 154 } 155 } 156 157 /// A faint, tiled wallpaper of an SF Symbol, clipped to the avatar circle, used 158 /// as a subtle background texture behind the main glyph. Tiles are staggered 159 /// row-to-row for a diaper pattern and drawn at low opacity in a single 160 /// `Canvas`. Everything scales off `size`, so it reads the same on a list-row 161 /// avatar and a larger share-grid tile. 162 private struct SymbolPattern: View { 163 let symbolName: String 164 let size: CGFloat 165 var tint: Color = .white 166 var opacity: Double = 0.16 167 168 /// Tile glyph size as a fraction of the avatar size. 169 private static let tileRatio: CGFloat = 0.26 170 /// Centre-to-centre tile spacing as a fraction of the avatar size. 171 private static let spacingRatio: CGFloat = 0.30 172 173 var body: some View { 174 Canvas { context, canvasSize in 175 guard let glyph = context.resolveSymbol(id: 0) else { return } 176 context.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: canvasSize))) 177 context.opacity = opacity 178 179 let spacing = size * Self.spacingRatio 180 guard spacing > 0 else { return } 181 var row = 0 182 var y = spacing / 2 183 while y < canvasSize.height + spacing { 184 // Offset alternate rows by half a step for the staggered look. 185 let stagger = row.isMultiple(of: 2) ? 0 : spacing / 2 186 var x = spacing / 2 + stagger - spacing 187 while x < canvasSize.width + spacing { 188 context.draw(glyph, at: CGPoint(x: x, y: y)) 189 x += spacing 190 } 191 y += spacing 192 row += 1 193 } 194 } symbols: { 195 Image(systemName: symbolName) 196 .font(.system(size: size * Self.tileRatio, weight: .semibold)) 197 .foregroundStyle(tint) 198 .tag(0) 199 } 200 .frame(width: size, height: size) 201 } 202 } 203 204 private struct FriendAvatar { 205 let symbolName: String 206 let background: Color 207 208 static func avatar(for authorID: String) -> FriendAvatar { 209 // Friend avatars are intentionally colourless. A player's colour now 210 // lives only inside a game and varies per game, so outside one a friend 211 // is identified by their symbol alone on a neutral grey — never a fixed 212 // colour badge. The symbol is a stable, deterministic choice per author. 213 let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)] 214 return FriendAvatar(symbolName: symbol, background: Color(.darkGray)) 215 } 216 217 private static let symbols: [String] = [ 218 "star.fill", 219 "sparkles", 220 "bolt.fill", 221 "moon.fill", 222 "heart.fill", 223 "crown.fill", 224 "leaf.fill", 225 "flame.fill" 226 ] 227 }