SwiftUI Animation Techniques
A comprehensive guide to creating smooth and engaging animations in SwiftUI
A comprehensive guide to creating smooth and engaging animations in SwiftUI
SwiftUI gives you a lot of animation power out of the box. This guide walks through techniques I use most often, starting with fundamentals and moving into more advanced patterns you can drop into real projects.
SwiftUI sits on top of Core Animation and follows SwiftUI's declarative model. Once you see how a change becomes motion on screen, picking modifiers and timing gets easier.
When you animate a change, SwiftUI runs through a short sequence:
@State variables and other observable propertiesMost SwiftUI work falls into two animation styles:
Implicit Animations (using .animation() modifier):
// SwiftUI automatically animates any state changes
.animation(.easeInOut, value: someState)
Explicit Animations (using withAnimation):
// You control exactly when and how the animation occurs
withAnimation(.spring()) {
// State changes here are animated
}
Animations in SwiftUI follow a hierarchical inheritance pattern. Child views inherit animations from their parent views unless explicitly overridden:
VStack {
// This inherits the parent's animation
Text("Inherited Animation")
// This overrides the parent's animation
Text("Custom Animation")
.animation(.spring(), value: someState)
}
.animation(.easeInOut, value: parentState)
For more details, see Apple's Animation Documentation.
The .animation() modifier is usually the first place to start. A minimal example:
@State private var scale: CGFloat = 1.0
var body: some View {
Button("Tap me") {
scale = scale == 1.0 ? 1.5 : 1.0
}
.scaleEffect(scale)
.animation(.easeInOut(duration: 0.5), value: scale)
}
Multiple properties can animate together with one state change:
@State private var isExpanded = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: isExpanded ? 200 : 100, height: isExpanded ? 200 : 100)
.scaleEffect(isExpanded ? 1.2 : 1.0)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
.opacity(isExpanded ? 0.8 : 1.0)
.animation(.easeInOut(duration: 0.8), value: isExpanded)
.onTapGesture {
isExpanded.toggle()
}
}
The value parameter does most of the heavy lifting for performance. It tells SwiftUI exactly when to run animation work:
// Only animates when 'scale' changes
.animation(.spring(), value: scale)
// Only animates when 'isVisible' changes
.animation(.easeInOut, value: isVisible)
// Animates when ANY state changes (avoid this)
.animation(.spring()) // ❌ Performance issue
SwiftUI includes a small set of animation APIs that cover most day to day UI work. For full reference details, see View Animation Documentation.
.animation() ModifierThis is the most common animation entry point:
// Animate all changes
.animation(.easeInOut, value: someState)
// Animate specific properties
.animation(.spring(), value: scale)
.animation(.linear(duration: 2), value: rotation)
withAnimation BlocksUse this when you want tighter control in code:
Button("Animate") {
withAnimation(.easeInOut(duration: 1.0)) {
// All state changes inside this block will be animated
scale = 2.0
opacity = 0.5
rotation = 360
}
}
The withAnimation function is defined in SwiftUI's Animation Namespace.
Gate animation so it only runs when you want it:
@State private var shouldAnimate = false
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.animation(shouldAnimate ? .spring() : nil, value: scale)
}
You can run follow up logic after animation completes with a completion handler:
withAnimation(.easeInOut(duration: 1.0)) {
// Animated changes
} completion: {
// This runs when animation completes
print("Animation finished")
}
Spring animations feel natural and responsive. They are based on physics, so they map nicely to taps, drags, and other touch interactions.
.animation(.spring(), value: someState)
Spring animations have several parameters that control their behavior:
.animation(.spring(
response: 0.5, // How quickly the animation responds (seconds)
dampingFraction: 0.6, // How much the spring bounces (0-1, lower = more bounce)
blendDuration: 0.25 // How long to blend between animations (seconds)
), value: someState)
Understanding Spring Parameters:
Interactive springs are optimized for user interactions like dragging:
.animation(.interactiveSpring(
response: 0.6,
dampingFraction: 0.8,
blendDuration: 0
), value: someState)
Interactive springs have different default values that feel more responsive to touch.
@State private var isPressed = false
var body: some View {
Button(action: {
isPressed.toggle()
}) {
Text("Spring Button")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed)
}
For detailed spring animation documentation, see Spring Animation Reference.
Timing curves shape the personality of your motion. They control how animation progresses over time.
Linear animations progress at a constant rate:
.animation(.linear(duration: 1.0), value: someState)
Linear animations are best for:
Ease animations provide natural acceleration and deceleration:
// Ease in - starts slow, ends fast
.animation(.easeIn(duration: 1.0), value: someState)
// Ease out - starts fast, ends slow
.animation(.easeOut(duration: 1.0), value: someState)
// Ease in out - smooth acceleration and deceleration
.animation(.easeInOut(duration: 1.0), value: someState)
When to use each:
If you want exact motion timing, define a custom Bézier curve:
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 1.0), value: someState)
These parameters are control points for a cubic Bézier curve: (x1, y1, x2, y2).
You can also tune animation speed relative to system behavior:
// Respects user's "Reduce Motion" setting
.animation(.easeInOut(duration: 1.0).speed(0.5), value: someState))
// Ignores "Reduce Motion" setting
.animation(.easeInOut(duration: 1.0).speed(0.5).repeatCount(3), value: someState)
See Timing Curve Documentation for more details.
State driven animation gives immediate feedback and makes your UI feel alive.
@State private var isLoading = false
var body: some View {
VStack {
Circle()
.trim(from: 0, to: isLoading ? 1 : 0)
.stroke(Color.blue, lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
Button("Toggle Loading") {
isLoading.toggle()
}
}
}
@State private var email = ""
@State private var isValidEmail = false
var body: some View {
VStack {
TextField("Email", text: $email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isValidEmail ? Color.green : Color.red, lineWidth: 2)
)
.animation(.easeInOut(duration: 0.3), value: isValidEmail)
if !isValidEmail && !email.isEmpty {
Text("Please enter a valid email")
.foregroundColor(.red)
.font(.caption)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.onChange(of: email) { newValue in
isValidEmail = newValue.contains("@") && newValue.contains(".")
}
}
@State private var networkStatus: NetworkStatus = .idle
enum NetworkStatus {
case idle, loading, success, error
}
var body: some View {
VStack {
Circle()
.fill(networkStatus == .success ? Color.green :
networkStatus == .error ? Color.red : Color.blue)
.frame(width: 20, height: 20)
.scaleEffect(networkStatus == .loading ? 1.2 : 1.0)
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
value: networkStatus == .loading)
Text(statusText)
.animation(.easeInOut(duration: 0.3), value: networkStatus)
}
}
private var statusText: String {
switch networkStatus {
case .idle: return "Ready"
case .loading: return "Loading..."
case .success: return "Success!"
case .error: return "Error"
}
}
Transitions control how views enter and leave the screen. They are a core part of polished navigation and content updates.
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.opacity)
}
Button("Toggle Detail") {
withAnimation(.easeInOut(duration: 0.5)) {
showDetail.toggle()
}
}
}
}
SwiftUI ships with several built in transitions:
// Fade in/out
.transition(.opacity)
// Slide from edge
.transition(.slide)
// Scale in/out
.transition(.scale)
// Move from edge
.transition(.move(edge: .leading))
// Asymmetric transitions
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide.combined(with: .opacity)
))
Combine transitions when you want richer motion:
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide.combined(with: .opacity)
))
For app specific effects, build custom transitions:
struct SlideTransition: ViewModifier {
let edge: Edge
let isActive: Bool
func body(content: Content) -> some View {
content
.offset(x: isActive ? 0 : (edge == .leading ? -UIScreen.main.bounds.width : UIScreen.main.bounds.width))
.opacity(isActive ? 1 : 0)
}
}
extension AnyTransition {
static func slide(edge: Edge) -> AnyTransition {
.modifier(
active: SlideTransition(edge: edge, isActive: false),
identity: SlideTransition(edge: edge, isActive: true)
)
}
}
Transition timing can be controlled independently:
if showContent {
ContentView()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
}
See Transition Documentation for more details.
Gesture based animation makes interactions feel direct and tactile.
@State private var dragOffset = CGSize.zero
@State private var isDragging = false
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(dragOffset)
.scaleEffect(isDragging ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
isDragging = true
}
.onEnded { _ in
withAnimation(.spring()) {
dragOffset = .zero
isDragging = false
}
}
)
}
@State private var isPressed = false
var body: some View {
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 100)
.scaleEffect(isPressed ? 0.9 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isPressed)
.onLongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity) {
// Action on release
} onPressingChanged: { pressing in
isPressed = pressing
}
}
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
var body: some View {
Image("photo")
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
scale = scale * delta
}
.onEnded { _ in
withAnimation(.spring()) {
scale = max(1.0, min(scale, 3.0)) // Clamp between 1x and 3x
}
lastScale = 1.0
}
)
}
@State private var rotation: Angle = .zero
@State private var lastRotation: Angle = .zero
var body: some View {
Rectangle()
.fill(Color.orange)
.frame(width: 100, height: 100)
.rotationEffect(rotation)
.gesture(
RotationGesture()
.onChanged { value in
let delta = value - lastRotation
lastRotation = value
rotation += delta
}
.onEnded { _ in
withAnimation(.spring()) {
rotation = .zero // Snap back to original rotation
}
lastRotation = .zero
}
)
}
Use this to transition between views that share identity. It is one of the strongest tools in SwiftUI for fluid interface changes.
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 300, height: 400)
.matchedGeometryEffect(id: "shape", in: animation)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(width: 100, height: 100)
.matchedGeometryEffect(id: "shape", in: animation)
}
Button("Toggle") {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}
}
Key Points:
@Namespace creates a unique namespace for the matched geometry effectid will animate between their different statesUse delays and sequencing when one motion should lead another:
@State private var showElements = false
var body: some View {
VStack(spacing: 20) {
ForEach(0..<3) { index in
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.opacity(showElements ? 1 : 0)
.animation(.easeInOut(duration: 0.5).delay(Double(index) * 0.2), value: showElements)
}
}
.onAppear {
showElements = true
}
}
Reusable animation modifiers help keep motion consistent across your app:
struct PulseAnimation: ViewModifier {
@State private var isPulsing = false
func body(content: Content) -> some View {
content
.scaleEffect(isPulsing ? 1.1 : 1.0)
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: isPulsing)
.onAppear {
isPulsing = true
}
}
}
struct ShakeAnimation: ViewModifier {
@State private var isShaking = false
func body(content: Content) -> some View {
content
.offset(x: isShaking ? 10 : 0)
.animation(.easeInOut(duration: 0.1).repeatCount(3, autoreverses: true), value: isShaking)
.onAppear {
isShaking = true
}
}
}
extension View {
func pulseAnimation() -> some View {
modifier(PulseAnimation())
}
func shakeAnimation() -> some View {
modifier(ShakeAnimation())
}
}
Staggered animations are useful when several elements move together:
@State private var animateItems = false
var body: some View {
HStack(spacing: 10) {
ForEach(0..<5) { index in
Rectangle()
.fill(Color.blue)
.frame(width: 20, height: 60)
.scaleEffect(y: animateItems ? 1.0 : 0.3)
.animation(
.easeInOut(duration: 0.6)
.delay(Double(index) * 0.1)
.repeatForever(autoreverses: true),
value: animateItems
)
}
}
.onAppear {
animateItems = true
}
}
Shared state makes it easier to coordinate separate animated elements:
@State private var animationPhase: AnimationPhase = .initial
enum AnimationPhase {
case initial, expanding, final
}
var body: some View {
VStack {
Circle()
.fill(Color.blue)
.frame(width: animationPhase == .expanding ? 200 : 100)
.opacity(animationPhase == .final ? 0.5 : 1.0)
.animation(.easeInOut(duration: 0.5), value: animationPhase)
Rectangle()
.fill(Color.green)
.frame(width: 100, height: animationPhase == .expanding ? 200 : 100)
.animation(.easeInOut(duration: 0.5).delay(0.2), value: animationPhase)
Button("Animate") {
withAnimation {
animationPhase = .expanding
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
animationPhase = .final
}
}
}
}
}
Card flips work well for game UIs and interactive content:
@State private var isFlipped = false
@State private var rotation: Double = 0
var body: some View {
ZStack {
// Front of card
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 200, height: 300)
.overlay(
Text("Front")
.font(.title)
.foregroundColor(.white)
)
.opacity(isFlipped ? 0 : 1)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0)
)
// Back of card
RoundedRectangle(cornerRadius: 20)
.fill(Color.red)
.frame(width: 200, height: 300)
.overlay(
Text("Back")
.font(.title)
.foregroundColor(.white)
)
.opacity(isFlipped ? 1 : 0)
.rotation3DEffect(
.degrees(rotation - 180),
axis: (x: 0, y: 1, z: 0)
)
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.6)) {
rotation += 180
isFlipped.toggle()
}
}
}
A floating action button with an expanding menu is a solid pattern for quick actions:
@State private var isExpanded = false
@State private var selectedAction: String?
var body: some View {
ZStack {
// Background overlay
if isExpanded {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring()) {
isExpanded = false
}
}
}
VStack {
Spacer()
HStack {
Spacer()
VStack(spacing: 20) {
// Menu items
if isExpanded {
ForEach(["Add Photo", "Add Note", "Add Contact"], id: \.self) { action in
Button(action: {
selectedAction = action
withAnimation(.spring()) {
isExpanded = false
}
}) {
Text(action)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.white)
.foregroundColor(.blue)
.cornerRadius(20)
.shadow(radius: 4)
}
.transition(.scale.combined(with: .opacity))
}
}
// Main FAB
Button(action: {
withAnimation(.spring()) {
isExpanded.toggle()
}
}) {
Image(systemName: isExpanded ? "xmark" : "plus")
.font(.title2)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.blue)
.clipShape(Circle())
.shadow(radius: 8)
}
.rotationEffect(.degrees(isExpanded ? 45 : 0))
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
}
}
Multi step progress indicators feel better when each step transition is animated:
@State private var currentStep = 0
let totalSteps = 4
var body: some View {
VStack(spacing: 30) {
// Progress bar
HStack(spacing: 0) {
ForEach(0..<totalSteps, id: \.self) { step in
Rectangle()
.fill(step <= currentStep ? Color.green : Color.gray.opacity(0.3))
.frame(height: 4)
.animation(.easeInOut(duration: 0.5), value: currentStep)
}
}
.cornerRadius(2)
// Step indicators
HStack(spacing: 20) {
ForEach(0..<totalSteps, id: \.self) { step in
Circle()
.fill(step <= currentStep ? Color.green : Color.gray.opacity(0.3))
.frame(width: 30, height: 30)
.overlay(
Text("\(step + 1)")
.font(.caption)
.foregroundColor(.white)
.fontWeight(.bold)
)
.scaleEffect(step == currentStep ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: currentStep)
}
}
// Step content
VStack {
Text("Step \(currentStep + 1)")
.font(.title2)
.fontWeight(.bold)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
Text("This is the content for step \(currentStep + 1)")
.multilineTextAlignment(.center)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
.animation(.easeInOut(duration: 0.3), value: currentStep)
// Navigation buttons
HStack(spacing: 20) {
Button("Previous") {
if currentStep > 0 {
withAnimation(.easeInOut(duration: 0.3)) {
currentStep -= 1
}
}
}
.disabled(currentStep == 0)
.opacity(currentStep == 0 ? 0.5 : 1.0)
Button("Next") {
if currentStep < totalSteps - 1 {
withAnimation(.easeInOut(duration: 0.3)) {
currentStep += 1
}
}
}
.disabled(currentStep == totalSteps - 1)
.opacity(currentStep == totalSteps - 1 ? 0.5 : 1.0)
}
}
.padding()
}
An expanding search bar is a good example of compact to focused UI motion:
@State private var isSearching = false
@State private var searchText = ""
var body: some View {
HStack {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.scaleEffect(isSearching ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isSearching)
TextField("Search...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.frame(width: isSearching ? 200 : 0)
.opacity(isSearching ? 1 : 0)
.animation(.easeInOut(duration: 0.3), value: isSearching)
if isSearching && !searchText.isEmpty {
Button(action: {
searchText = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(20)
.scaleEffect(isSearching ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSearching)
if isSearching {
Button("Cancel") {
withAnimation(.easeInOut(duration: 0.3)) {
isSearching = false
searchText = ""
}
}
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.onTapGesture {
if !isSearching {
withAnimation(.easeInOut(duration: 0.3)) {
isSearching = true
}
}
}
}
A badge bounce can make new notifications easier to notice:
@State private var notificationCount = 0
@State private var isAnimating = false
var body: some View {
VStack(spacing: 30) {
// Notification icon with badge
ZStack {
Image(systemName: "bell.fill")
.font(.title)
.foregroundColor(.blue)
if notificationCount > 0 {
Text("\(notificationCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.frame(width: 20, height: 20)
.background(Color.red)
.clipShape(Circle())
.offset(x: 10, y: -10)
.scaleEffect(isAnimating ? 1.3 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.5), value: isAnimating)
}
}
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isAnimating)
// Control buttons
HStack(spacing: 20) {
Button("Add Notification") {
notificationCount += 1
triggerAnimation()
}
Button("Clear All") {
withAnimation(.easeInOut(duration: 0.3)) {
notificationCount = 0
}
}
.disabled(notificationCount == 0)
}
}
.padding()
}
private func triggerAnimation() {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isAnimating = false
}
}
Weather cards are a nice place to combine state changes and micro interactions:
@State private var isExpanded = false
@State private var temperature = 72
@State private var weatherCondition = "sun.max.fill"
var body: some View {
VStack(spacing: 0) {
// Main weather display
VStack(spacing: 15) {
Image(systemName: weatherCondition)
.font(.system(size: isExpanded ? 80 : 60))
.foregroundColor(.orange)
.rotationEffect(.degrees(isExpanded ? 360 : 0))
.animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: false), value: isExpanded)
Text("\(temperature)°")
.font(.system(size: isExpanded ? 48 : 36, weight: .bold))
.foregroundColor(.primary)
Text("Sunny")
.font(.title3)
.foregroundColor(.secondary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue.opacity(0.1))
)
.scaleEffect(isExpanded ? 1.05 : 1.0)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: isExpanded)
// Expanded details
if isExpanded {
VStack(spacing: 15) {
HStack {
WeatherDetail(icon: "humidity", value: "65%", label: "Humidity")
Spacer()
WeatherDetail(icon: "wind", value: "12 mph", label: "Wind")
}
HStack {
WeatherDetail(icon: "thermometer", value: "78°", label: "High")
Spacer()
WeatherDetail(icon: "thermometer.low", value: "65°", label: "Low")
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(15)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding()
.background(Color.white)
.cornerRadius(25)
.shadow(radius: 10)
.onTapGesture {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}
struct WeatherDetail: View {
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 5) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(.blue)
Text(value)
.font(.headline)
.fontWeight(.semibold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Music controls are another great surface for subtle motion cues:
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 180 // 3 minutes
@State private var volume: Double = 0.7
var body: some View {
VStack(spacing: 30) {
// Album artwork
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.frame(width: 200, height: 200)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundColor(.white)
)
.rotationEffect(.degrees(isPlaying ? 360 : 0))
.animation(.linear(duration: 3).repeatForever(autoreverses: false), value: isPlaying)
// Song info
VStack(spacing: 5) {
Text("Amazing Song")
.font(.title2)
.fontWeight(.bold)
Text("Amazing Artist")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Progress bar
VStack(spacing: 10) {
ProgressView(value: currentTime, total: duration)
.progressViewStyle(LinearProgressViewStyle(tint: .blue))
.scaleEffect(y: 2)
HStack {
Text(formatTime(currentTime))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(formatTime(duration))
.font(.caption)
.foregroundColor(.secondary)
}
}
// Control buttons
HStack(spacing: 40) {
Button(action: {}) {
Image(systemName: "backward.fill")
.font(.title)
.foregroundColor(.primary)
}
Button(action: {
withAnimation(.spring()) {
isPlaying.toggle()
}
}) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 60))
.foregroundColor(.blue)
.scaleEffect(isPlaying ? 1.1 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPlaying)
}
Button(action: {}) {
Image(systemName: "forward.fill")
.font(.title)
.foregroundColor(.primary)
}
}
// Volume control
HStack {
Image(systemName: "speaker.fill")
.foregroundColor(.secondary)
Slider(value: $volume, in: 0...1)
.accentColor(.blue)
Image(systemName: "speaker.wave.3.fill")
.foregroundColor(.secondary)
}
}
.padding()
}
private func formatTime(_ time: Double) -> String {
let minutes = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%d:%02d", minutes, seconds)
}
A deeper mental model helps you build animations that are predictable and efficient.
Each animation carries a context that includes:
SwiftUI interpolates values using the selected timing curve:
// SwiftUI internally does something like this:
let progress = timingCurve.evaluate(at: currentTime / duration)
let currentValue = startValue + (endValue - startValue) * progress
SwiftUI batches multiple state changes into one animation transaction for performance:
// These are batched into one animation
withAnimation(.spring()) {
scale = 2.0
opacity = 0.5
rotation = 360
}
Starting a new animation cancels any in-flight one; SwiftUI then interpolates toward the new target:
@State private var scale: CGFloat = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
.animation(.spring(), value: scale)
.onTapGesture {
// This cancels any ongoing animation
scale = 2.0
// This would also cancel the previous animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scale = 0.5
}
}
}
Animation inheritance in SwiftUI follows these rules:
withAnimation blocks create new animation contextsVStack {
// Inherits parent animation
Text("Inherited")
// Overrides parent animation
Text("Custom")
.animation(.spring(), value: someState)
}
.animation(.easeInOut, value: parentState)
For detailed technical information, see SwiftUI Animation Architecture.
value ParameterSpecify the value parameter to avoid unnecessary animations:
// Good - only animates when scale changes
.animation(.spring(), value: scale)
// Avoid - animates all changes
.animation(.spring())
Too many simultaneous animations can impact performance:
// Use delays to stagger animations so they don't all run at once
.animation(.easeInOut(duration: 0.3).delay(Double(index) * 0.05), value: showItems)
.linear for continuous animations (loading spinners).spring for interactive elements.easeInOut for state changesIn complex views with many animated elements:
// Use LazyVStack for large lists
LazyVStack {
ForEach(items) { item in
AnimatedItem(item)
.animation(.easeInOut.delay(Double(item.id) * 0.1), value: showItems)
}
}
Respect the user's "Reduce Motion" setting:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Circle()
.scaleEffect(scale)
.animation(reduceMotion ? nil : .spring(), value: scale)
}
Be careful with repeating animations that can outlive the view:
// Good - stops when view disappears
.onAppear {
startAnimation()
}
.onDisappear {
stopAnimation()
}
// Avoid - might continue after view disappears
.onAppear {
startInfiniteAnimation()
}
// Good - subtle scale change
.scaleEffect(isPressed ? 0.98 : 1.0)
// Avoid - dramatic scale change
.scaleEffect(isPressed ? 0.5 : 1.0)
Keep animation durations consistent across your app:
// Define constants for consistent timing
struct AnimationConstants {
static let quick = 0.2
static let standard = 0.3
static let slow = 0.5
}
// Use them consistently
.animation(.easeInOut(duration: AnimationConstants.standard), value: someState)
Provide visual feedback for user interactions:
Button(action: {
// Action
}) {
Text("Submit")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isPressed)
.onLongPressGesture(minimumDuration: 0) {
// Action
} onPressingChanged: { pressing in
isPressed = pressing
}
Test animations on multiple devices to confirm smooth performance.
Keep animations meaningful and intentional:
// Good - animation shows loading state
.rotationEffect(.degrees(isLoading ? 360 : 0))
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isLoading)
// Avoid - animation for animation's sake
.rotationEffect(.degrees(360))
.animation(.linear(duration: 1).repeatForever(autoreverses: false))
Different UI contexts need different animation approaches:
// Navigation transitions
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
// Modal presentations
.transition(.opacity.combined(with: .scale))
// List updates
.transition(.slide)
Use Xcode's animation inspector when debugging timing issues:
// Add this modifier to see animation timing
.animation(.easeInOut(duration: 1.0), value: someState)
.animation(.easeInOut(duration: 1.0), value: someState) // Duplicate for debugging
Add logs if you need to inspect animation timing:
withAnimation(.spring()) {
print("Animation started at: \(Date())")
someState = newValue
} completion: {
print("Animation completed at: \(Date())")
}
Track animation state when debugging complex flows:
@State private var animationPhase = "idle"
var body: some View {
VStack {
Text("Animation Phase: \(animationPhase)")
Circle()
.scaleEffect(scale)
.animation(.spring(), value: scale)
.onChange(of: scale) { newValue in
animationPhase = "scale changed to \(newValue)"
}
}
}
Monitor performance when animation starts feeling heavy:
import os.log
let animationLogger = Logger(subsystem: "com.yourapp", category: "animation")
withAnimation(.spring()) {
animationLogger.debug("Starting spring animation")
someState = newValue
}
SwiftUI animation is flexible enough for almost every app pattern. When you are comfortable with animation types, timing curves, and transitions, you can ship interactions that feel intentional and smooth.
Before I ship, I usually run through:
Use these techniques as building blocks and adapt them to your app's style. Small motion details add up fast and make the whole product feel better.
SwiftUI has strong built in animation support. Third party tools can still help when you need specific effects or a faster workflow. Here are some useful SwiftUI animation toolkits:
https://github.com/airbnb/lottie-ios.gitimport Lottie
struct LottieView: UIViewRepresentable {
let animationName: String
func makeUIView(context: Context) -> UIView {
let view = UIView()
let animationView = LottieAnimationView()
animationView.animation = LottieAnimation.named(animationName)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.play()
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.heightAnchor.constraint(equalTo: view.heightAnchor),
animationView.widthAnchor.constraint(equalTo: view.widthAnchor)
])
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
https://github.com/siteline/SwiftUI-Introspect.githttps://github.com/SwiftUI-AnimationKit/SwiftUI-AnimationKit.githttps://github.com/SwiftUI-Confetti/SwiftUI-Confetti.gitimport SwiftUI
import SwiftUIConfetti
struct ConfettiView: View {
@State private var showConfetti = false
var body: some View {
VStack {
Button("Celebrate!") {
showConfetti = true
}
ConfettiView(
counter: $showConfetti,
num: 50,
colors: [.red, .blue, .green, .yellow, .pink, .purple]
)
}
}
}
In Xcode:
In Package.swift:
dependencies: [
.package(url: "https://github.com/airbnb/lottie-ios.git", from: "4.0.0"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.0")
]
Performance Considerations:
Accessibility:
Maintenance:
Integration:
If you find yourself creating many custom animations, consider packaging them:
// MyAnimationKit.swift
import SwiftUI
public struct MyAnimationKit {
public static func springBounce() -> Animation {
.spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.25)
}
public static func customEase() -> Animation {
.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.8)
}
}
public extension View {
func springBounce() -> some View {
self.animation(MyAnimationKit.springBounce(), value: UUID())
}
}
These tools range from small helpers to full animation systems. Choose based on product needs and how your team likes to build.
Happy animating! 🎨