Creating Delightful Weather Animations in SwiftUI
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:
- 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
)
- Wind effect
// Raindrops move diagonally in wind
raindrops[i].position.x += windSpeed * 0.5
- 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.7for playful interactionsdampingFraction: 0.8-0.9for serious, professional feeldampingFraction: 1.0for 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.