crossmate

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

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 }