Reacting to a new notification by nudging a bell icon, or swapping it for a filled variant, used to mean animating generic view modifiers around an Image — SwiftUI had no idea it was looking at a symbol, so it couldn't animate strokes or draw-in transitions.
#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 — no state variable dedicated to tracking "is it bumping right now"..contentTransition(.symbolEffect(.replace))morphs one SF Symbol into another using the symbol's own paths, instead of a generic crossfade, so multi-layer symbols likebell.badge.filltransition layer by layer.- Effects like
.pulseand.variableColorcome free for things like recording indicators or signal-strength icons, with zero manual timer-driven animation code.
#Gotcha
Not every effect is available on every symbol — .variableColor only works on symbols Apple has annotated for variable rendering, and applying it to one that isn't does nothing silently, no crash, no warning. Separately, indefinite effects like .pulse or .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's scrolled off screen.