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 provides powerful tools for creating smooth, engaging animations that enhance user experience. In this comprehensive guide, we'll explore various animation techniques from basic to advanced, with practical examples you can implement in your own projects.
Understanding how SwiftUI animations work internally helps you create more effective animations. SwiftUI's animation system is built on top of Core Animation and uses a declarative approach.
When you apply an animation in SwiftUI, here's what happens under the hood:
@State variables and other observable propertiesSwiftUI distinguishes between implicit and explicit animations:
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 foundation of SwiftUI animations lies in the .animation() modifier. Here's a simple 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)
}
You can animate multiple properties simultaneously:
@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 is crucial for performance and control. It tells SwiftUI exactly when to trigger the animation:
// 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 provides several animation modifiers that control how animations behave. For comprehensive details, see View Animation Documentation.
.animation() ModifierThe most common way to add animations:
// Animate all changes
.animation(.easeInOut, value: someState)
// Animate specific properties
.animation(.spring(), value: scale)
.animation(.linear(duration: 2), value: rotation)
withAnimation BlocksFor programmatic animations with precise control:
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.
Control when animations occur:
@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 execute code when animations complete using withAnimation with a completion handler:
withAnimation(.easeInOut(duration: 1.0)) {
// Animated changes
} completion: {
// This runs when animation completes
print("Animation finished")
}
Spring animations create natural, bouncy effects that feel more organic. They're based on real-world physics and provide excellent user feedback.
.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.
SwiftUI provides various timing curves for different animation feels. These curves control how the 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:
For precise control, you can define custom timing curves using Bézier curves:
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 1.0), value: someState)
The parameters represent control points of a cubic Bézier curve: (x1, y1, x2, y2).
You can also control animation speed relative to system settings:
// 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.
Animations that respond to state changes provide excellent user feedback and make interfaces feel responsive.
@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 appear and disappear. They're essential for creating smooth navigation and content changes.
@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 provides 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)
))
You can combine multiple transitions for complex effects:
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide.combined(with: .opacity)
))
For unique effects, create 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)
)
}
}
You can control transition timing independently:
if showContent {
ContentView()
.transition(.opacity.animation(.easeInOut(duration: 0.3)))
}
See Transition Documentation for more details.
Animations that respond to user gestures create highly interactive and engaging experiences.
@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
}
)
}
Create smooth transitions between views that share the same identity. This is one of SwiftUI's most powerful features for creating fluid interfaces.
@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 statesControl animation timing with delays and sequencing:
@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
}
}
Create reusable animation modifiers for consistent animations 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())
}
}
Create complex animations with multiple elements:
@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
}
}
Coordinate multiple animations using shared state:
@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
}
}
}
}
}
Create engaging card flip animations for games or 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()
}
}
}
Create a modern floating action button with expandable menu:
@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)
}
}
}
}
Create a multi-step progress indicator with smooth transitions:
@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()
}
Create an animated search bar that expands and contracts:
@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
}
}
}
}
Create an animated notification badge with bounce effects:
@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
}
}
Create an animated weather card with dynamic content:
@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)
}
}
}
Create animated music player controls with smooth transitions:
@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)
}
Understanding how SwiftUI animations work internally helps you create more efficient and predictable animations.
When SwiftUI creates an animation, it establishes an animation context that includes:
SwiftUI interpolates between values using the animation's 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 single animations for performance:
// These are batched into one animation
withAnimation(.spring()) {
scale = 2.0
opacity = 0.5
rotation = 360
}
When a new animation starts, SwiftUI cancels the previous one and interpolates to 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
}
}
}
SwiftUI's animation inheritance 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 ParameterAlways specify 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 changesFor 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)
}
}
Always 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 might cause memory leaks:
// 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)
Maintain consistent animation durations throughout 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)
Always 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
}
Always test animations on various devices to ensure smooth performance.
Make animations meaningful and purposeful:
// 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 contexts require 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 to debug animations:
// 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 logging to understand animation timing:
withAnimation(.spring()) {
print("Animation started at: \(Date())")
someState = newValue
} completion: {
print("Animation completed at: \(Date())")
}
Track animation state for debugging:
@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 animation performance:
import os.log
let animationLogger = Logger(subsystem: "com.yourapp", category: "animation")
withAnimation(.spring()) {
animationLogger.debug("Starting spring animation")
someState = newValue
}
SwiftUI's animation system is powerful and flexible, allowing you to create engaging user experiences with minimal code. By understanding the different animation types, timing curves, and best practices, you can create smooth, performant animations that enhance your app's usability.
Remember to:
With these techniques, you'll be able to create professional-quality animations that delight your users and improve the overall experience of your SwiftUI applications.
While SwiftUI provides excellent built-in animation capabilities, there are many third-party libraries and tools that can enhance your animation development experience. Here are some of the most popular and 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 and libraries can significantly enhance your SwiftUI animation capabilities, providing everything from simple utility functions to complex animation systems. Choose the ones that best fit your project's needs and complexity requirements.
Happy animating! 🎨