How to animate SF Symbols in SwiftUI
Improve your app’s UX by animating SF Symbols
Having a remarkable user experience in our app could be the main factor in user retention.
Animations play a fundamental role in that area. By adding animations to certain actions, we will be providing a more engaging experience to our users.
If we use SF Symbols, we can get pretty cool animations, basically for free. From iOS 17, Swift introduced a new modifier symbolEffect
to perform pre-defined animations over SF Symbols.
Here is how we can use this cool feature.
We have seven built-in animations available to apply to any SF Symbol: Bounce, Pulse, Variable Color, Scale, Appear, Disappear, Replace
All animations are grouped into four types of behaviors
- Discrete: the animation runs one time and ends.
- Indefinite: the animation will continue until we remove it or disable it.
- Transition: animate a symbol in or out of the view.
- Content Transition: for animating a replacement of one symbol with another.
Discrete
We need to use symbolEffect(_:options:value:)
function wherevalue
could be any Equatable
type. The effect will be applied every timevalue
changes.
struct DiscreteDemoAnimationsView: View {
@State private var animationCount = 0
var body: some View {
VStack {
VStack {
Text("Bounce")
Image(systemName: "wifi.router")
.symbolEffect(
.bounce,
value: animationCount
)
}
VStack {
Text("Pulse")
Image(systemName: "wifi.router")
.symbolEffect(
.pulse,
value: animationCount
)
}
VStack {
Text("Variable Color")
Image(systemName: "wifi.router")
.symbolEffect(
.variableColor,
value: animationCount
)
}
Button("Animate") {
animationCount += 1
}
}
}
}
Indefinite
We control when the animation starts and when the animation stops using symbolEffect(_:options:isActive:)
.
struct IndefiniteAnimationsView: View {
@State private var animationIsActive = false
private var buttonTitle: String {
return animationIsActive ? "Stop animations" : "Start animations"
}
var body: some View {
VStack {
VStack {
Text("Pulse")
Image(systemName: "wifi.router")
.symbolEffect(
.pulse,
isActive: animationIsActive
)
}
VStack {
Text("Variable Color")
Image(systemName: "wifi.router")
.symbolEffect(
.variableColor,
isActive: animationIsActive
)
}
VStack {
Text("Scale")
Image(systemName: "wifi.router")
.symbolEffect(
.scale.up,
isActive: animationIsActive
)
}
VStack {
Text("Appear")
Image(systemName: "wifi.router")
.symbolEffect(
.appear,
isActive: !animationIsActive
)
}
VStack {
Text("Disappear")
Image(systemName: "wifi.router")
.symbolEffect(
.disappear,
isActive: animationIsActive
)
}
Button(buttonTitle) {
animationIsActive.toggle()
}
}
}
}
Transition
The difference between Appear/Disappear with Discrete behavior is that in a Transition behavior, the symbol is actually removed from the View. So we need to handle the rendering ourselves.
We need to use .transition
modifier together with symbolEffect
.
struct TransitionAnimationsView: View {
@State private var symbolIsHidden = true
private var buttonTitle: String {
return symbolIsHidden ? "Show" : "Hide"
}
var body: some View {
VStack {
if !symbolIsHidden {
Image(systemName: "wifi.router")
.transition(.symbolEffect(.appear))
}
Button(buttonTitle) {
symbolIsHidden.toggle()
}
}
}
}
Content Transition
To animate a replacement, we must use contentTransition
modifier together with symbolEffect
.
struct ContentTransitionAnimationsView: View {
@State private var muteOn = false
private var buttonTitle: String {
return muteOn ? "Unmute" : "Mute"
}
var body: some View {
VStack {
Button {
muteOn.toggle()
} label: {
Label(buttonTitle, systemImage: !muteOn ? "speaker.wave.3.fill" : "speaker.slash.fill")
}
.contentTransition(.symbolEffect(.replace))
}
.font(.largeTitle)
}
}
Customization
For both Discrete and Indefinite behavior, we have available the following options to use:
- repeating: the effect will be repeated indefinitely.
- nonRepeating: the effect will execute once.
- repeat(_:): it's up to us to pick how many times we want the effect to be repeated
- speed(_:): to manipulate the speed of the effect
In addition, every effect has a set of options that we can use to customize a little bit more the animation
Bounce, Scale, Pulse*, Appear, and Disappear
- up & down: to control the effect direction
- byLayer & wholeSymbol: to control the effect scope. This applies to the symbols that have more than one layer (like WiFi, Speaker, etc.)
* For Pulse effect, only option 1 is available
Variable Color
For multi-layer symbols
- cumulative: each layer will be animated in sequence, and each layer will remain until the animation is complete.
- iterative: animate one layer at a time and disable the layer until the animation is done.
- nonReversing: the animation won't reverse every time it executes.
- reversing: every time the animation repeats, it will reverse.
- dimInactiveLayers: inactive layers will be dimmed until they become active.
- hideInactiveLayers: inactive layers will be hidden until they become active.
Replace
- downUp, offUp & upUp: to control the direction of the effect
- byLayer & wholeSymbol: same as for bounce, scale, appear, and disappear
How can we use it?
Just chain the options that we want together with the effect.
Image(systemName: "tv.badge.wifi")
.symbolEffect(
.variableColor.cumulative.dimInactiveLayers.reversing,
options: .speed(1.5).repeat(2),
value: animationCount
)
Bonus
SF Symbols 5 App
The best way to test different combinations is using SF Symbols App. We can even copy the configuration code.
Demo App
I've put together a simple demo app showcasing a bunch of different configurations you can use.
If you're interested, here is the code.