Building a fluid gradient with CoreAnimation & SwiftUI: 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:
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)
.
/// 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.
/// 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:
// 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.
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.
// 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.
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:
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.
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:
/// 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:
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
.
#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.
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:
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):
/// 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.
/// 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:
extension FluidGradient {
struct Representable: SystemRepresentable {
...
var speed: CGFloat
...
}
}
and then update both the updateView()
and makeCoordinator()
methods.
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.
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!
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.