crossmate

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

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 }