João Gabriel
João GabrielDecember 1, 2022

Building a fluid gradient with CoreAnimation & SwiftUI: Part 2

In this post, we'll animate the fluid gradient view we built in the last tutorial using CoreAnimation to achieve a high-performance, reusable view.
Building a fluid gradient with CoreAnimation: part 2

Doubling up on our Fluid Gradient tutorial article series, welcome to part two! This time around, we will work on how to animate the blobs in our (currently static) view. If you haven't read part one, you can find it here

This is what the end result should look like:

Animated fluid gradient

The final code for this project was made available as a Swift Package in this GitHub repository.

It's quite simple

Our Fluid Gradient can easily be animated because we have an array of blobs whose locations can be animated independently around the canvas – and that's all we're gonna do. Let's start by playing a bit with CoreAnimation.

Implementing the animations

Since we want each blob to independently animate to a new location, let's create a method in the BlobLayer that will do the work for us. We will call this method animate(speed: CGFloat).

BlobLayer.swift
/// Animate the blob to a random point and size on screen at set speed
func animate(speed: CGFloat) {
    guard speed > 0 else { return }
 
    self.removeAllAnimations()
    let currentLayer = self.presentation() ?? self
 
    let animation = CASpringAnimation()
    animation.mass = 10/speed
    animation.damping = 50
    animation.duration = 1/speed
 
    animation.isRemovedOnCompletion = false
    animation.fillMode = CAMediaTimingFillMode.forwards
}

Let's start our method with some necessary setup calls that make a CASpringAnimation instance. The physics coefficients are completely adjustable to your liking.

Now let's write our animations as copies of that instance. We'll have three specific attributes we'll want to animate:

  • startPoint
  • endPoint
  • opacity

To make our job cleaner, let's write some helper functions for generating new positions and radii.

BlobLayer.swift
/// Generate a random point on the canvas
func newPosition() -> CGPoint {
    return CGPoint(x: CGFloat.random(in: 0.0...1.0),
                    y: CGFloat.random(in: 0.0...1.0)).capped()
}
 
/// Generate a random radius for the blob
func newRadius() -> CGPoint {
    let size = CGFloat.random(in: 0.15...0.75)
    let viewRatio = frame.width/frame.height
    let safeRatio = max(viewRatio.isNaN ? 1 : viewRatio, 1)
    let ratio = safeRatio*CGFloat.random(in: 0.25...1.75)
    return CGPoint(x: size,
                    y: size*ratio)
}

Our new radius function is a bit more complex than the position function. We want to make sure that the radius is always proportional to the view, so we'll use the frame property to get the view's width and height and calculate the view's aspect ratio.


With our helper functions, let's replace our old code in the initializer:

BlobLayer.swift
// Center point
let position = newPosition()
self.startPoint = position
 
// Radius
let radius = newRadius()
self.endPoint = position.displace(by: radius)

And now, let's write our animations.

BlobLayer.swift
let position = newPosition()
let radius = newRadius()
 
// Center point
let start = animation.copy() as! CASpringAnimation
start.keyPath = "startPoint"
start.fromValue = currentLayer.startPoint
start.toValue = position
 
// Radius
let end = animation.copy() as! CASpringAnimation
end.keyPath = "endPoint"
end.fromValue = currentLayer.endPoint
end.toValue = position.displace(by: radius)
 
self.startPoint = position
self.endPoint = position.displace(by: radius)

Because CASpringAnimation is a class, and therefore a reference type, we need to explicitly make a copy of it to reuse it. Notice how, upon creating the animations, we also need to update the blob's startPoint and endPoint properties. This is so that once the animation finishes, the properties stay correct.

Now, let's animate the blob's opacity.

BlobLayer.swift
// Opacity
let value = Float.random(in: 0.5...1)
let opacity = animation.copy() as! CASpringAnimation
opacity.fromValue = self.opacity
opacity.toValue = value
 
self.opacity = value

And finally, let's add these animations to the layer.

BlobLayer.swift
self.add(opacity, forKey: "opacity")
self.add(start, forKey: "startPoint")
self.add(end, forKey: "endPoint")

Scheduling timers

Now that we have our animations, all we gotta do is to schedule them to run at a set interval. We'll do this in the FluidGradientView class using Combine, so make sure to import it first:

FluidGradientView.swift
import Combine

Next, modify the FluidGradientView so that it accepts a speed parameter, and also add a new attribute that contains a set of AnyCancellable instances.

FluidGradientView.swift
struct FluidGradientView: SystemView {
    var speed: CGFloat
    var cancellables = Set<AnyCancellable>()
 
    init(...,
         speed: CGFloat = 1.0) {
        self.speed = speed
 
        // Rest of the initializer
    }
}

...and then write a public update method:

FluidGradientView.swift
/// Update sublayers and set speed and blur levels
public func update(speed: CGFloat) {
    cancellables.removeAll()
    self.speed = speed
    guard speed > 0 else { return }
 
    let layers = (baseLayer.sublayers ?? []) + (highlightLayer.sublayers ?? [])
    for layer in layers {
        if let layer = layer as? BlobLayer {
            // Schedule timer
        }
    }
}

Here, we're first removing all of the previously-created timers – if there are any – and updating the speed property of the view. Then we check that the speed is greater than 0, and if it is, we iterate through all of the sublayers of both the base and highlight layers and schedule a repeatable timer for each one.

Here's how you can schedule a timer and store it in your view's lifecycle:

FluidGradientView.swift
Timer.publish(every: .random(in: 0.8/speed...1.2/speed), on: .main, in: .common)
    .autoconnect()
    .sink { _ in
        layer.animate(speed: speed)
    }
    .store(in: &cancellables)

Great! Now we have a way of animating the blobs over time. To make our algorithm more efficient, we can make sure that, on macOS, the window is visible to the user before calling {:swift}layer.animate(). We'll do this using the window's occlusionState.

FluidGradientView.swift
#if os(OSX)
let visible = self.window?.occlusionState.contains(.visible)
guard visible == true else { return }
#endif

Final steps

Now, we gotta call the update() method in the right places: our initializer and the view coordinator. We'll wrap it in an async context to make sure that it runs only when the blobs can be animated.

FluidGradientView.swift
init(...) {
    // Rest of the initializer
 
    DispatchQueue.main.async {
        self.update(speed: speed)
    }
}

Lastly, all that's left is binding the actual speed of the gradient to the SwiftUI view. That means we'll pass it through both the coordinator and the view representable as well.

Back in the view coordinator, let's add a new speed property:

FluidGradient.swift
class Coordinator: FluidGradientDelegate {
    ...
    var speed: CGFloat
 
    init(...
         speed: CGFloat) {
        ...
        self.speed = speed
 
        // Rest of the initializer
    }
}

...and then create a new method that encapsulates the view's update method and also updates the speed property, but only if it changes (this is good practice to reduce overload in view updates):

FluidGradient.swift
/// Update speed
func update(speed: CGFloat) {
    guard speed != self.speed else { return }
    self.speed = speed
    view.update(speed: speed)
}

To make sure that blobs will be animated right after they are created (for example, when the color array changes), make sure to call view.update() in the create() method as well.

FluidGradient.swift
/// Create blobs and highlights
func create(blobs: [Color], highlights: [Color]) {
    ...
    view.update(speed: speed)
}

Even though both methods are called when the view state is modified, we need that extra call in create() because of the guard condition in update().


In the view's representable, also add a speed parameter:

FluidGradient.swift
extension FluidGradient {
    struct Representable: SystemRepresentable {
        ...
        var speed: CGFloat
        ...
    }
}

and then update both the updateView() and makeCoordinator() methods.

FluidGradient.swift
func updateView(_ view: FluidGradientView, context: Context) {
    context.coordinator.create(blobs: blobs, highlights: highlights)
    DispatchQueue.main.async {
        context.coordinator.update(speed: speed)
    }
}
 
...
 
func makeCoordinator() -> Coordinator {
    Coordinator(blobs: blobs,
                highlights: highlights,
                speed: speed,
                blurValue: $blurValue)
}

Finally, just add a speed parameter to the actual SwiftUI view initializer, so we can specify a speed from the SwiftUI lifecycle.

FluidGradient.swift
public struct FluidGradient: View {
    ...
    private var speed: CGFloat
 
    public init(...
                speed: CGFloat = 1.0) {
        self.speed = speed
        // Rest of the initializer
    }
 
    public var body: some View {
        Representable(blobs: blobs,
                      highlights: highlights,
                      speed: speed,
                      blurValue: $blurValue)
        // Rest of the body
    }
}

Here we're setting the default speed to 1.0, which means that the blobs will animate at a normal speed. You can change this value to make the animation faster or slower.

Like a hare. Or a turtle!

Results

Ta-da! Now, you can run your target app and see what we've accomplished!

What the gradient looks like in an app

Doesn't it look... ethereal? Or maybe even a little mythical. Still, the GIF compression doesn't do this beaut enough justice so make sure to test it out yourself.

If you followed all steps correctly, the blobs should be beautifully swirling around. If they're not working correctly, try debugging your code to see find out what's wrong. Or, for a quick solution, try using our Swift Package. This package contains all of the code you need to add a gradient to your app.

What's next

In the next and final tutorial, we'll use this gradient to build a few SwiftUI views and make them more visually appealing.

Thanks for hanging around, and stay tuned.