commit e4decd765794323240461d37fe59d096c27b59a9
parent a631522fb2a7be0fdd1ccafe2b4f33ef63c0e2b4
Author: Michael Camilleri <[email protected]>
Date: Mon, 18 May 2026 19:55:22 +0900
Animate the friend's own glyph as invite feedback
The 'Invite a Crossmate' row signalled sending with a generic ProgressView and
then a static checkmark — feedback unconnected to the friend being invited.
FriendAvatarView gains an opt-in invitePhase (default nil, so the GameShareItem
caller is untouched): while the invite is in flight the friend's own identity
symbol wiggles in place, and on success it spins a full turn while
.symbolEffect(.replace) morphs that same glyph into a checkmark — the symbol
you tapped is the one that confirms. Sending is checked before sent so the
wiggle holds until the work finishes, and invite()'s state changes are wrapped
in withAnimation so the replace transition actually fires.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 47 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Views/FriendAvatarView.swift b/Crossmate/Views/FriendAvatarView.swift
@@ -1,23 +1,52 @@
import SwiftUI
struct FriendAvatarView: View {
+ /// Drives the in-place invite animation. `nil` (the default) keeps the
+ /// avatar perfectly static, so call sites that merely identify a friend
+ /// (e.g. `GameShareItem`) are completely unaffected.
+ enum InvitePhase: Equatable {
+ /// Invite is in flight: the player's own glyph wiggles in place.
+ case sending
+ /// Invite delivered: the glyph spins once and becomes a checkmark.
+ case sent
+ }
+
let authorID: String
var size: CGFloat = 34
+ var invitePhase: InvitePhase? = nil
+
+ @State private var revolutions = 0
private var avatar: FriendAvatar {
FriendAvatar.avatar(for: authorID)
}
+ /// On success the friend's identity glyph is *replaced* by a checkmark
+ /// rather than a separate accessory appearing — the symbol the user tapped
+ /// is the one that confirms.
+ private var symbolName: String {
+ invitePhase == .sent ? "checkmark" : avatar.symbolName
+ }
+
var body: some View {
ZStack {
Circle()
.fill(avatar.background)
- Image(systemName: avatar.symbolName)
+ Image(systemName: symbolName)
.font(.system(size: size * 0.46, weight: .semibold))
.foregroundStyle(.white)
+ .symbolEffect(.wiggle, options: .repeating, isActive: invitePhase == .sending)
+ .contentTransition(.symbolEffect(.replace))
+ .rotationEffect(.degrees(Double(revolutions) * 360))
}
.frame(width: size, height: size)
+ .onChange(of: invitePhase) { _, phase in
+ guard phase == .sent else { return }
+ withAnimation(.spring(response: 0.55, dampingFraction: 0.65)) {
+ revolutions += 1
+ }
+ }
.accessibilityHidden(true)
}
}
diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift
@@ -63,21 +63,27 @@ struct FriendPickerView: View {
Task { await invite(authorID) }
} label: {
HStack {
- FriendAvatarView(authorID: authorID)
- .padding(.trailing, 8)
+ FriendAvatarView(
+ authorID: authorID,
+ invitePhase: invitePhase(authorID: authorID, invited: invited)
+ )
+ .padding(.trailing, 8)
Text(displayName(for: friend))
Spacer()
- if invitingAuthorID == authorID {
- ProgressView()
- } else if invited {
- Image(systemName: "checkmark")
- .foregroundStyle(.secondary)
- }
}
}
.disabled(authorID.isEmpty || invitingAuthorID != nil || invited)
}
+ /// Maps the row's invite state to the avatar's animation phase. Sending
+ /// is checked first so the glyph keeps wiggling right up until the work
+ /// finishes, then resolves straight into the spin-to-checkmark.
+ private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? {
+ if invitingAuthorID == authorID { return .sending }
+ if invited { return .sent }
+ return nil
+ }
+
private func displayName(for friend: FriendEntity) -> String {
if let name = friend.displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
@@ -88,12 +94,12 @@ struct FriendPickerView: View {
private func invite(_ authorID: String) async {
guard !authorID.isEmpty, let inviteFriend else { return }
- invitingAuthorID = authorID
+ withAnimation(.snappy) { invitingAuthorID = authorID }
errorMessage = nil
- defer { invitingAuthorID = nil }
+ defer { withAnimation(.snappy) { invitingAuthorID = nil } }
do {
try await inviteFriend(gameID, authorID)
- invitedAuthorIDs.insert(authorID)
+ withAnimation(.snappy) { invitedAuthorIDs.insert(authorID) }
} catch {
errorMessage = String(describing: error)
}