FriendAvatarView.swift (8315B)
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 // A soft radial wash from a lighter centre out to the base 62 // grey, so the medallion reads as gently domed rather than 63 // flat. Disabled for now — swap the flat `.fill` above for this 64 // to turn it back on. 65 // .fill( 66 // RadialGradient( 67 // colors: [Color(.systemGray3), avatar.background], 68 // center: .center, 69 // startRadius: 0, 70 // endRadius: size / 2 71 // ) 72 // ) 73 74 // A faint, tiled watermark of the friend's own symbol — just enough 75 // texture to lift the flat medallion, sitting under the main glyph. 76 SymbolPattern(symbolName: avatar.symbolName, size: size) 77 78 Image(systemName: symbolName) 79 .font(.system(size: size * 0.46, weight: .semibold)) 80 .foregroundStyle(.white) 81 .contentTransition(.symbolEffect(.replace)) 82 .rotationEffect(.degrees(Double(revolutions) * 360)) 83 // Animate the glyph swap itself so the friend symbol → paper 84 // plane → checkmark changes morph rather than hard-cut. 85 .animation(.default, value: symbolName) 86 } 87 .frame(width: size, height: size) 88 .onAppear { 89 if invitePhase == .sending { ringsActive = true } 90 } 91 .onChange(of: invitePhase) { _, phase in 92 ringsTask?.cancel() 93 switch phase { 94 case .sending: 95 // The same spin as the checkmark turns the avatar over to the 96 // paper plane; the broadcast then starts once it has settled. 97 withAnimation(glyphSpin) { revolutions += 1 } 98 ringsTask = Task { @MainActor in 99 try? await Task.sleep(for: .seconds(0.5)) 100 guard !Task.isCancelled else { return } 101 ringsActive = true 102 } 103 case .sent: 104 ringsActive = false 105 withAnimation(glyphSpin) { revolutions += 1 } 106 case nil: 107 ringsActive = false 108 } 109 } 110 .onDisappear { 111 ringsTask?.cancel() 112 } 113 .accessibilityHidden(true) 114 } 115 } 116 117 /// A single ring that grows out of the avatar and fades, looping forever. Each 118 /// ring owns its animation and starts it in `onAppear`, so simply inserting 119 /// the ring (when sending begins) is enough to set it going — no external 120 /// trigger to keep in sync. 121 private struct BroadcastRing: View { 122 let color: Color 123 let lineWidth: CGFloat 124 /// Staggers this ring against its siblings so the broadcast is continuous. 125 let delay: Double 126 127 @State private var expanded = false 128 129 var body: some View { 130 Circle() 131 .stroke(color, lineWidth: lineWidth) 132 .scaleEffect(expanded ? 1.55 : 1.0) 133 .opacity(expanded ? 0 : 0.5) 134 .onAppear { 135 withAnimation( 136 .easeOut(duration: 1.4) 137 .repeatForever(autoreverses: false) 138 .delay(delay) 139 ) { 140 expanded = true 141 } 142 } 143 } 144 } 145 146 /// A faint, tiled wallpaper of an SF Symbol, clipped to the avatar circle, used 147 /// as a subtle background texture behind the main glyph. Tiles are staggered 148 /// row-to-row for a diaper pattern and drawn at low opacity in a single 149 /// `Canvas`. Everything scales off `size`, so it reads the same on a list-row 150 /// avatar and a larger share-grid tile. 151 private struct SymbolPattern: View { 152 let symbolName: String 153 let size: CGFloat 154 var tint: Color = .white 155 var opacity: Double = 0.16 156 157 /// Tile glyph size as a fraction of the avatar size. 158 private static let tileRatio: CGFloat = 0.26 159 /// Centre-to-centre tile spacing as a fraction of the avatar size. 160 private static let spacingRatio: CGFloat = 0.30 161 162 var body: some View { 163 Canvas { context, canvasSize in 164 guard let glyph = context.resolveSymbol(id: 0) else { return } 165 context.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: canvasSize))) 166 context.opacity = opacity 167 168 let spacing = size * Self.spacingRatio 169 guard spacing > 0 else { return } 170 var row = 0 171 var y = spacing / 2 172 while y < canvasSize.height + spacing { 173 // Offset alternate rows by half a step for the staggered look. 174 let stagger = row.isMultiple(of: 2) ? 0 : spacing / 2 175 var x = spacing / 2 + stagger - spacing 176 while x < canvasSize.width + spacing { 177 context.draw(glyph, at: CGPoint(x: x, y: y)) 178 x += spacing 179 } 180 y += spacing 181 row += 1 182 } 183 } symbols: { 184 Image(systemName: symbolName) 185 .font(.system(size: size * Self.tileRatio, weight: .semibold)) 186 .foregroundStyle(tint) 187 .tag(0) 188 } 189 .frame(width: size, height: size) 190 } 191 } 192 193 private struct FriendAvatar { 194 let symbolName: String 195 let background: Color 196 197 static func avatar(for authorID: String) -> FriendAvatar { 198 // Friend avatars are intentionally colourless. A player's colour now 199 // lives only inside a game and varies per game, so outside one a friend 200 // is identified by their symbol alone on a neutral grey — never a fixed 201 // colour badge. The symbol is a stable, deterministic choice per author. 202 let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)] 203 return FriendAvatar(symbolName: symbol, background: Color(.darkGray)) 204 } 205 206 private static let symbols: [String] = [ 207 "star.fill", 208 "sparkles", 209 "bolt.fill", 210 "moon.fill", 211 "heart.fill", 212 "crown.fill", 213 "leaf.fill", 214 "flame.fill" 215 ] 216 }