Day 45
Day 45 êŽë š
Project 9, part 3
Today weâre going to push your drawing skills to the limit by looking at special effects and animation. As weâre right at the advanced edge of drawing itâs fair to say these skills are less likely to be used in everyday coding, but as Ralph Waldo Emerson once said, âwe aim above the mark to hit the mark.â
As youâre working through todayâs topics youâll learn how to animate shapes, and itâs another instance where SwiftUI can feel a bit like magic. As youâve seen previously, though, it really isnât magic â SwiftUI just responds to the way we configured our views. Itâs a bit like a Rube Goldberg machine: we set things up exactly right, put the whole machine in motion, then watch as the correct output comes out.
Controlling animation is no different: we donât want to have the body
property of a view re-invoked 60 or 120 times a second in order to get smooth animation, so instead we just provide the instructions for what should change as an animation progresses. Itâs not terribly discoverable â that is, you canât stumble upon the solution by accident â but I hope youâll agree that itâs straighforward to use.
Today you have three topics to work through, plus one bonus topic if youâre feeling brave. Youâll learn about blend modes, animatableData
, AnimatablePair
, and more.
Special effects in SwiftUI: blurs
, blending
, and more
Special effects in SwiftUI: blurs, blending, and more
SwiftUI gives us extraordinary control over how views are rendered, including the ability to apply real-time blurs, blend modes, saturation adjustment, and more.
Blend modes allow us to control the way one view is rendered on top of another. The default mode is .normal
, which just draws the pixels from the new view onto whatever is behind, but there are lots of options for controlling color and opacity.
As an example, we could draw an image inside a ZStack
, then add a red rectangle on top that is drawn with the multiply blend mode:
ZStack {
Image("PaulHudson")
Rectangle()
.fill(.red)
.blendMode(.multiply)
}
.frame(width: 400, height: 500)
.clipped()
âMultiplyâ is so named because it multiplies each source pixel color with the destination pixel color â in our case, each pixel of the image and each pixel of the rectangle on top. Each pixel has color values for RGBA, ranging from 0 (none of that color) through to 1 (all of that color), so the highest resulting color will be 1x1, and the lowest will be 0x0.
Using multiply with a solid color applies a really common tint effect: blacks stay black (because they have the color value of 0, so regardless of what you put on top multiplying by 0 will produce 0), whereas lighter colors become various shades of the tint.
In fact, multiply is so common that thereâs a shortcut modifier that means we can avoid using a ZStack
:
var body: some View {
Image("PaulHudson")
.colorMultiply(.red)
}
There are lots of other blend modes to choose from, and itâs worth spending some time experimenting to see how they work. Another popular effect is called screen, which does the opposite of multiply: it inverts the colors, performs a multiply, then inverts them again, resulting in a brighter image rather than a darker image.
As an example, we could render three circles at various positions inside a ZStack
, then use a slider to control their size and overlap:
struct ContentView: View {
@State private var amount = 0.0
var body: some View {
VStack {
ZStack {
Circle()
.fill(.red)
.frame(width: 200 * amount)
.offset(x: -50, y: -80)
.blendMode(.screen)
Circle()
.fill(.green)
.frame(width: 200 * amount)
.offset(x: 50, y: -80)
.blendMode(.screen)
Circle()
.fill(.blue)
.frame(width: 200 * amount)
.blendMode(.screen)
}
.frame(width: 300, height: 300)
Slider(value: $amount)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black)
.ignoresSafeArea()
}
}
If youâre particularly observant, you might notice that the fully blended color in the center isnât quite white â itâs a very pale lilac color. The reason for this is that Color.red
, Color.green
, and Color.blue
arenât fully those colors; youâre not seeing pure red when you use Color.red
. Instead, youâre seeing SwiftUIâs adaptive colors that are designed to look good in both dark mode and light mode, so they are a custom blend of red, green, and blue rather than pure shades.
If you want to see the full effect of blending red, green, and blue, you should use custom colors like these three:
.fill(Color(red: 1, green: 0, blue: 0))
.fill(Color(red: 0, green: 1, blue: 0))
.fill(Color(red: 0, green: 0, blue: 1))
There are a host of other real-time effects we can apply, and we already looked at blur()
back in project 3. So, letâs look at just one more before we move on: saturation()
, which adjusts how much color is used inside a view. Give this a value between 0 (no color, just grayscale) and 1 (full color).
We could write a little code to demonstrate both blur()
and saturation()
in the same view, like this:
Image("PaulHudson")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.saturation(amount)
.blur(radius: (1 - amount) * 20)
With that code, having the slider at 0 means the image is blurred and colorless, but as you move the slider to the right it gains color and becomes sharp â all rendered at lightning-fast speed.
Animating simple shapes with animatableData
Animating simple shapes with animatableData
Weâve now covered a variety of drawing-related tasks, and back in project 6 we looked at animation, so now I want to look at putting those two things together.
First, letâs build a custom shape we can use for an example â hereâs the code for a trapezoid shape, which is a four-sided shape with straight sides where one pair of opposite sides are parallel:
struct Trapezoid: Shape {
var insetAmount: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: rect.maxY))
path.addLine(to: CGPoint(x: insetAmount, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - insetAmount, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: 0, y: rect.maxY))
return path
}
}
We can now use that inside a view, passing in some local state for its inset amount so we can modify the value at runtime:
struct ContentView: View {
@State private var insetAmount = 50.0
var body: some View {
Trapezoid(insetAmount: insetAmount)
.frame(width: 200, height: 100)
.onTapGesture {
insetAmount = Double.random(in: 10...90)
}
}
}
Every time you tap the trapezoid, insetAmount
gets set to a new value, causing the shape to be redrawn.
Wouldnât it be nice if we could animate the change in inset? Sure it would â try changing the onTapGesture()
closure to this:
.onTapGesture {
withAnimation {
insetAmount = Double.random(in: 10...90)
}
}
Now run it again, and⊠nothing has changed. Weâve asked for animation, but we arenât getting animation â what gives?
When looking at animations previously, I asked you to add a call to print()
inside the body
property, then said this:
âWhat you should see is that it prints out 2.0, 3.0, 4.0, and so on. At the same time, the button is scaling up or down smoothly â it doesnât just jump straight to scale 2, 3, and 4. Whatâs actually happening here is that SwiftUI is examining the state of our view before the binding changes, examining the target state of our views after the binding changes, then applying an animation to get from point A to point B.â
So, as soon as insetAmount
is set to a new random value, it will immediately jump to that value and pass it directly into Trapezoid
â it wonât pass in lots of intermediate values as the animation happens. This is why our trapezoid jumps from inset to inset; it has no idea an animation is even happening.
We can fix this in only four lines of code, one of which is just a closing brace. However, even though this code is simple, the way it works might bend your brain.
First, the code â add this new computed property to the Trapezoid
struct now:
var animatableData: Double {
get { insetAmount }
set { insetAmount = newValue }
}
You can now run the app again and see our trapezoid changing shape with a smooth animation.
Whatâs happening here is quite complex: when we use withAnimation()
, SwiftUI immediately changes our state property to its new value, but behind the scenes itâs also keeping track of the changing value over time as part of the animation. As the animation progresses, SwiftUI will set the animatableData
property of our shape to the latest value, and itâs down to us to decide what that means â in our case we assign it directly to insetAmount
, because thatâs the thing we want to animate.
Remember, SwiftUI evaluates our view state before an animation was applied and then again after. It can see we originally had code that evaluated to Trapezoid(insetAmount: 50)
, but then after a random number was chosen we ended up with (for example) Trapezoid(insetAmount: 62)
. So, it will interpolate between 50 and 62 over the length of our animation, each time setting the animatableData
property of our shape to be that latest interpolated value â 51, 52, 53, and so on, until 62 is reached.
Animating complex shapes with AnimatablePair
Animating complex shapes with AnimatablePair
SwiftUI uses an animatableData
property to let us animate changes to shapes, but what happens when we want two, three, four, or more properties to animate? animatableData
is a property, which means it must always be one value, however we get to decide what type of value it is: it might be a single Double
, or it might be two values contained in a special wrapper called AnimatablePair
.
To try this out, letâs look at a new shape called Checkerboard
, which must be created with some number of rows and columns:
struct Checkerboard: Shape {
var rows: Int
var columns: Int
func path(in rect: CGRect) -> Path {
var path = Path()
// figure out how big each row/column needs to be
let rowSize = rect.height / Double(rows)
let columnSize = rect.width / Double(columns)
// loop over all rows and columns, making alternating squares colored
for row in 0..<rows {
for column in 0..<columns {
if (row + column).isMultiple(of: 2) {
// this square should be colored; add a rectangle here
let startX = columnSize * Double(column)
let startY = rowSize * Double(row)
let rect = CGRect(x: startX, y: startY, width: columnSize, height: rowSize)
path.addRect(rect)
}
}
}
return path
}
}
We can now create a 4x4 checkerboard in a SwiftUI view, using some state properties that we can change using a tap gesture:
struct ContentView: View {
@State private var rows = 4
@State private var columns = 4
var body: some View {
Checkerboard(rows: rows, columns: columns)
.onTapGesture {
withAnimation(.linear(duration: 3)) {
rows = 8
columns = 16
}
}
}
}
When that runs you should be able to tap on the black squares to see the checkerboard jump from being 4x4 to 8x16, without animation even though the change is inside a withAnimation()
block.
As with simpler shapes, the solution here is to implement an animatableData
property that will be set with intermediate values as the animation progresses. Here, though, there are two catches:
- We have two properties that we want to animate, not one.
- Our
row
andcolumn
properties are integers, and SwiftUI canât interpolate integers.
To resolve the first problem weâre going to use a new type called AnimatablePair
. As its name suggests, this contains a pair of animatable values, and because both its values can be animated the AnimatablePair
can itself be animated. We can read individual values from the pair using .first
and .second
.
To resolve the second problem weâre just going to do some type conversion: we can convert a Double
to an Int
just by using Int(someDouble)
, and go the other way by using Double(someInt)
.
So, to make our checkerboard animate changes in the number of rows and columns, add this property:
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(Double(rows), Double(columns))
}
set {
rows = Int(newValue.first)
columns = Int(newValue.second)
}
}
Now when you run the app you should find the change happens smoothly â or as smoothly as you would expect given that weâre rounding numbers to integers.
Of course, the next question is: how do we animate three properties? Or four?
To answer that, let me show you the animatableData
property for SwiftUIâs EdgeInsets
type:
AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>>
Yes, they use three separate animatable pairs, then just dig through them using code such as newValue.second.second.first
.
Iâm not going to claim this is the most elegant of solutions, but I hope you can understand why it exists: because SwiftUI can read and write the animatable data for a shape regardless of what that data is or what it means, it doesnât need to re-invoke the body
property of our views 60 or even 120 times a second during an animation â it just changes the parts that actually are changing.
And if youâre feeling brave:
Creating a spirograph with SwiftUI
Creating a spirograph with SwiftUI
To finish off with something that really goes to town with drawing, Iâm going to walk you through creating a simple spirograph with SwiftUI. âSpirographâ is the trademarked name for a toy where you place a pencil inside a circle and spin it around the circumference of another circle, creating various geometric patterns that are known as roulettes â like the casino game.
This code involves a very specific equation. Iâm going to explain it, but itâs totally OK to skip this chapter if youâre not interested â this is just for fun, and no new Swift or SwiftUI is covered here.
Our algorithm has four inputs:
- The radius of the inner circle.
- The radius of the outer circle.
- The distance of the virtual pen from the center of the outer circle.
- What amount of the roulette to draw. This is optional, but I think it really helps show whatâs happening as the algorithm works.
So, letâs start with that:
struct Spirograph: Shape {
let innerRadius: Int
let outerRadius: Int
let distance: Int
let amount: Double
}
We then prepare three values from that data, starting with the greatest common divisor (GCD) of the inner radius and outer radius. Calculating the GCD of two numbers is usually done with Euclid's algorithm, which in a slightly simplified form looks like this:
func gcd(_ a: Int, _ b: Int) -> Int {
var a = a
var b = b
while b != 0 {
let temp = b
b = a % b
a = temp
}
return a
}
Please add that method to the Spirograph
struct.
The other two values are the difference between the inner radius and outer radius, and how many steps we need to perform to draw the roulette â this is 360 degrees multiplied by the outer radius divided by the greatest common divisor, multiplied by our amount input. All our inputs work best when provided as integers, but when it comes to drawing the roulette we need to use Double
, so weâre also going to create Double
copies of our inputs.
Add this path(in:)
method to the Spirograph
struct now:
func path(in rect: CGRect) -> Path {
let divisor = gcd(innerRadius, outerRadius)
let outerRadius = Double(self.outerRadius)
let innerRadius = Double(self.innerRadius)
let distance = Double(self.distance)
let difference = innerRadius - outerRadius
let endPoint = ceil(2 * Double.pi * outerRadius / Double(divisor)) * amount
// more code to come
}
Finally we can draw the roulette itself by looping from 0 to our end point, and placing points at precise X/Y coordinates. Calculating the X/Y coordinates for a given point in that loop (known as âthetaâ) is where the real mathematics comes in, but honestly I just converted the standard equation to Swift from Wikipedia â this is not something I would dream of memorizing!
- X is equal to the radius difference multiplied by the cosine of theta, added to the distance multiplied by the cosine of the radius difference divided by the outer radius multiplied by theta.
- Y is equal to the radius difference multiplied by the sine of theta, subtracting the distance multiplied by the sine of the radius difference divided by the outer radius multiplied by theta.
Thatâs the core algorithm, but weâre going to make two small changes: weâre going to add to X and Y half the width or height of our drawing rectangle respectively so that itâs centered in our drawing space, and if theta is 0 â i.e., if this is the first point in our roulette being drawn â weâll call move(to:)
rather than addLine(to:)
for our path.
Hereâs the final code for the path(in:)
method â replace the // more code to come
comment with this:
var path = Path()
for theta in stride(from: 0, through: endPoint, by: 0.01) {
var x = difference * cos(theta) + distance * cos(difference / outerRadius * theta)
var y = difference * sin(theta) - distance * sin(difference / outerRadius * theta)
x += rect.width / 2
y += rect.height / 2
if theta == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
return path
I realize that was a lot of heavy mathematics, but the pay off is about to come: we can now use that shape in a view, adding various sliders to control the inner radius, outer radius, distance, amount, and even color:
struct ContentView: View {
@State private var innerRadius = 125.0
@State private var outerRadius = 75.0
@State private var distance = 25.0
@State private var amount = 1.0
@State private var hue = 0.6
var body: some View {
VStack(spacing: 0) {
Spacer()
Spirograph(innerRadius: Int(innerRadius), outerRadius: Int(outerRadius), distance: Int(distance), amount: amount)
.stroke(Color(hue: hue, saturation: 1, brightness: 1), lineWidth: 1)
.frame(width: 300, height: 300)
Spacer()
Group {
Text("Inner radius: \(Int(innerRadius))")
Slider(value: $innerRadius, in: 10...150, step: 1)
.padding([.horizontal, .bottom])
Text("Outer radius: \(Int(outerRadius))")
Slider(value: $outerRadius, in: 10...150, step: 1)
.padding([.horizontal, .bottom])
Text("Distance: \(Int(distance))")
Slider(value: $distance, in: 1...150, step: 1)
.padding([.horizontal, .bottom])
Text("Amount: \(amount, format: .number.precision(.fractionLength(2)))")
Slider(value: $amount)
.padding([.horizontal, .bottom])
Text("Color")
Slider(value: $hue)
.padding(.horizontal)
}
}
}
}
That was a lot of code, but I hope you take the time to run the app and appreciate just how beautiful roulettes are. What youâre seeing is actually only one form of a roulette, known as a hypotrochoid â with small adjustments to the algorithm you can generate epitrochoids and more, which are beautiful in different ways.
Before I finish, Iâd like to remind you that the parametric equations used here are mathematical standards rather than things I just invented â I literally went to Wikipediaâs page on hypotrochoids and converted them to Swift.
I hope youâre able to share some screenshots or videos with what you made today â if you use Twitter make sure you add @twostraws to your tweet so I can see!