A bell icon should nudge when a notification lands, then swap to its filled variant. Before iOS 17 you got that by animating generic view modifiers around an Image and hoping the timing looked deliberate. SwiftUI had no idea it was looking at a symbol. Strokes and draw-in transitions were off the table.
#The old way
struct NotificationBell: View {
@State private var notificationCount = 0
@State private var bump = false
var body: some View {
Button {
notificationCount += 1
withAnimation(.easeInOut(duration: 0.15)) { bump = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeInOut(duration: 0.15)) { bump = false }
}
} label: {
Image(systemName: notificationCount > 0 ? "bell.badge.fill" : "bell")
.font(.system(size: 28))
.scaleEffect(bump ? 1.2 : 1.0)
}
}
}
#The new way
import SwiftUI
struct NotificationBell: View {
@State private var notificationCount = 0
var body: some View {
Button {
notificationCount += 1
} label: {
Image(systemName: notificationCount > 0 ? "bell.badge.fill" : "bell")
.font(.system(size: 28))
.symbolEffect(.bounce, value: notificationCount)
.contentTransition(.symbolEffect(.replace))
}
}
}
#Why it matters
.symbolEffect(.bounce, value:)fires once, automatically, whenever the observed value changes. Thebumpstate and itsasyncAfterreset both disappear..contentTransition(.symbolEffect(.replace))morphs one SF Symbol into another along the symbol's own paths instead of a generic crossfade, so multi-layer symbols likebell.badge.filltransition layer by layer..pulseand.variableColorcome free. Recording indicators, signal-strength icons, none of it needs manual timer-driven animation code.
#Gotcha
Not every effect works on every symbol. .variableColor only works on symbols Apple has annotated for variable rendering. Apply it to one that isn't and nothing happens. No crash, no warning.
Indefinite effects are the other trap. .pulse and .variableColor.iterative keep animating forever once applied; they don't stop on their own. Gate them with the isActive: parameter (.symbolEffect(.pulse, isActive: isRecording)) or you'll burn CPU animating an icon that scrolled off screen.