← Back to all posts

Creating Delightful Weather Animations in SwiftUI

SwiftUI iOS Animation Design

Why Animation Matters

When I started building Mizzle, I had a simple goal: make checking the weather feel less like a chore and more like a moment of delight. Animation was central to that vision.

But animation in mobile apps is tricky. Too much, and it’s annoying. Too little, and it feels lifeless. Getting it right requires understanding both the technical constraints and the user psychology.

Here’s how I built Mizzle’s animation system, and what I learned along the way.

The Three Levels of Animation

I categorized Mizzle’s animations into three levels:

Level 1: Micro-interactions

Purpose: Provide immediate feedback Duration: 100-300ms Examples:

  • Button press states
  • Toggle switches
  • Pull-to-refresh indicators

These are the animations users barely notice consciously, but they feel their absence. Every tap, swipe, and interaction should have immediate visual feedback.

Button(action: { refreshWeather() }) {
    Text("Refresh")
}
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.spring(response: 0.2), value: isPressed)

Level 2: Transitions

Purpose: Show relationship between states Duration: 300-600ms Examples:

  • Screen transitions
  • Expanding cards
  • Sliding drawers

These animations help users understand how the app’s structure changes. They create a sense of spatial awareness.

.transition(.asymmetric(
    insertion: .move(edge: .trailing).combined(with: .opacity),
    removal: .move(edge: .leading).combined(with: .opacity)
))
.animation(.easeInOut(duration: 0.4), value: selectedDay)

Level 3: Ambient Animations

Purpose: Add personality and atmosphere Duration: Continuous or very long Examples:

  • Rain particle systems
  • Floating clouds
  • Pulsing temperature indicators

These animations run continuously in the background, creating ambiance without demanding attention.

The Rain Particle System

The most challenging animation in Mizzle is the rain effect. It needed to:

  • Render hundreds of raindrops smoothly
  • Maintain 60fps performance
  • Look realistic without being distracting
  • Adapt to different rain intensities

Attempt 1: Individual Views

My first approach was naive - create a SwiftUI View for each raindrop:

ForEach(raindrops) { drop in
    Rectangle()
        .fill(Color.blue.opacity(0.5))
        .frame(width: 2, height: 20)
        .position(drop.position)
}

Result: Performance was terrible. With 100+ raindrops, the app dropped to 15fps.

Why it failed: Each raindrop was its own View in the hierarchy. SwiftUI had to track, diff, and render each one separately. This is not what SwiftUI is optimized for.

Attempt 2: Canvas API

SwiftUI’s Canvas provides a way to draw directly, similar to Core Graphics:

struct RainEffect: View {
    @State private var raindrops: [Raindrop] = []

    var body: some View {
        TimelineView(.animation) { timeline in
            Canvas { context, size in
                // Update raindrop positions
                updateRaindrops(at: timeline.date, size: size)

                // Draw all raindrops in one pass
                for drop in raindrops {
                    var path = Path()
                    path.move(to: drop.position)
                    path.addLine(to: CGPoint(
                        x: drop.position.x,
                        y: drop.position.y + drop.length
                    ))

                    context.stroke(
                        path,
                        with: .color(.white.opacity(drop.opacity)),
                        lineWidth: drop.width
                    )
                }
            }
        }
    }

    func updateRaindrops(at date: Date, size: CGSize) {
        for i in raindrops.indices {
            // Move raindrop down
            raindrops[i].position.y += raindrops[i].velocity

            // Reset to top when it goes off screen
            if raindrops[i].position.y > size.height {
                raindrops[i].position.y = -20
                raindrops[i].position.x = CGFloat.random(in: 0...size.width)
            }
        }
    }
}

struct Raindrop {
    var position: CGPoint
    var velocity: CGFloat
    var length: CGFloat
    var width: CGFloat
    var opacity: Double
}

Result: 60fps with 200+ raindrops!

Why it worked: Canvas draws everything in a single render pass. No View hierarchy overhead. This is perfect for particle effects.

Adding Realism

To make the rain look realistic, I added:

  1. Variable drop properties
Raindrop(
    position: CGPoint(x: x, y: y),
    velocity: CGFloat.random(in: 15...25), // Different speeds
    length: CGFloat.random(in: 15...30),   // Different lengths
    width: CGFloat.random(in: 1...2),      // Different widths
    opacity: Double.random(in: 0.3...0.7)  // Different opacities
)
  1. Wind effect
// Raindrops move diagonally in wind
raindrops[i].position.x += windSpeed * 0.5
  1. Intensity scaling
// More raindrops for heavier rain
let raindropCount = Int(precipitationIntensity * 200)

Performance Optimization

Even with Canvas, I hit performance issues on older devices. Here’s how I optimized:

1. Reduce Raindrop Count on Older Devices

var raindropCount: Int {
    #if targetEnvironment(simulator)
    return 50 // Simulator is slow
    #else
    if ProcessInfo.processInfo.processorCount < 6 {
        return 100 // Older devices
    } else {
        return 200 // Newer devices
    }
    #endif
}

2. Use .drawingGroup()

For complex Canvas drawings, .drawingGroup() renders into an offscreen buffer, then composites it:

Canvas { context, size in
    // Draw rain
}
.drawingGroup() // Metal-accelerated rendering

This uses Metal to accelerate drawing. On newer devices, it’s faster. On older devices, the overhead isn’t worth it, so I apply it conditionally.

3. Reduce Update Frequency

Not every animation needs 60fps. For slower effects like floating clouds:

TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { timeline in
    // Update at 30fps instead of 60fps
}

Spring Physics for Natural Motion

The best animations in SwiftUI use spring physics. Unlike linear or easing curves, springs feel natural because they’re how real objects move.

// ❌ Linear - feels robotic
.animation(.linear(duration: 0.3), value: isExpanded)

// ❌ Ease - better, but still artificial
.animation(.easeInOut(duration: 0.3), value: isExpanded)

// ✅ Spring - feels natural
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isExpanded)

Parameters:

  • response: How long the spring takes to settle (lower = faster)
  • dampingFraction: How much bounce (0 = infinite bounce, 1 = no bounce)

I use:

  • dampingFraction: 0.6-0.7 for playful interactions
  • dampingFraction: 0.8-0.9 for serious, professional feel
  • dampingFraction: 1.0 for smooth with no bounce

The Temperature Pulsing Animation

One subtle animation in Mizzle is the temperature display pulsing when it’s updating:

struct TemperatureView: View {
    @State private var isUpdating = false

    var body: some View {
        Text("\(temperature)°")
            .font(.system(size: 72, weight: .thin))
            .scaleEffect(isUpdating ? 1.05 : 1.0)
            .opacity(isUpdating ? 0.7 : 1.0)
            .animation(
                .easeInOut(duration: 0.8)
                .repeatWhile(isUpdating),
                value: isUpdating
            )
    }
}

extension Animation {
    func repeatWhile(_ condition: Bool) -> Animation {
        condition ? self.repeatForever(autoreverses: true) : self
    }
}

This provides visual feedback during network requests without an ugly loading spinner.

Lessons Learned

1. Performance First

Beautiful animations mean nothing if they tank performance. Profile on real devices, especially older ones. Use Instruments to find bottlenecks.

2. Subtle Beats Flashy

The best animations are ones users don’t consciously notice. They should enhance the experience, not demand attention.

3. Respect Accessibility

Always check if “Reduce Motion” is enabled:

@Environment(\.accessibilityReduceMotion) var reduceMotion

var body: some View {
    // Skip complex animations if reduce motion is on
    if reduceMotion {
        staticView
    } else {
        animatedView
    }
}

4. Spring > Easing

When in doubt, use spring animations. They feel more natural than any easing curve.

5. Animation is a Design Tool

Use animation to:

  • Provide feedback (button press states)
  • Show relationships (parent-child expansions)
  • Direct attention (pulsing for updates)
  • Add personality (playful bounces)

Tools I Use

  • SF Symbols animations: Built-in animated icons (SF Symbols 4+)
  • Lottie: For complex designer-created animations
  • Canvas + TimelineView: For custom particle effects
  • Instruments: For performance profiling
  • Real devices: Simulator doesn’t reflect real performance

Try It Yourself

Here’s a simple starter for creating your own weather particle effect:

struct SimpleRainView: View {
    @State private var drops: [CGPoint] = []

    var body: some View {
        TimelineView(.animation) { timeline in
            Canvas { context, size in
                // Create drops if needed
                if drops.isEmpty {
                    drops = (0..<100).map { _ in
                        CGPoint(
                            x: CGFloat.random(in: 0...size.width),
                            y: CGFloat.random(in: 0...size.height)
                        )
                    }
                }

                // Update and draw
                for i in drops.indices {
                    drops[i].y += 5 // Move down

                    // Reset at top
                    if drops[i].y > size.height {
                        drops[i].y = 0
                        drops[i].x = CGFloat.random(in: 0...size.width)
                    }

                    // Draw
                    context.fill(
                        Path(ellipseIn: CGRect(
                            x: drops[i].x,
                            y: drops[i].y,
                            width: 2,
                            height: 2
                        )),
                        with: .color(.blue)
                    )
                }
            }
        }
    }
}

Conclusion

Animation is what separates good apps from great ones. It’s the difference between a weather app that’s functional and one that’s delightful.

SwiftUI makes animation easier than ever, but there’s still an art to doing it well. Start subtle, test on real devices, and always ask: “Does this animation make the experience better, or just different?”

If you’re building animations in SwiftUI, I’d love to see what you create. Share on Twitter and tag me @lucasilverentand.


Mizzle is available free on the App Store. The animation code is open source on GitHub.