Chapter 15
Radial Menu
Building the heart of the app - a radial menu with embedded animation that reacts to different gestures.
Our main goal is to have two states for the menu, so as we go along we’re going to build out a few structures so that it’s easier for us to transition between them. For example, instead of just creating a thick line then another and animating, we’re going to create an array of target frames that we will use to update the size of the rings during the animation. We’ll build these little helper structures as we go along.
The lines are the backbone of the menu, so we’re going to start by handling this component in a few parts: build all the rings, set up the start state, set up the end state, then animate out.
We’re going to create thick lines, thin lines, dashed lines and the dividing lines.
Let’s layout the thick rings. There are two: a small one, 28pt diameter, and a large one, 450pt.
Open MenuRings.swift
and create a variable array to store the targets for the thick rings sizes:
1
var thickRingFrames : [Rect]!
Then, create a variable for the ring:
1
var thickRing : Circle!
Next, build a method to create the thick rings and set up the targets by building the inner and outer shapes, then adding their frames to the array:
1
2
3
4
5
6
7
func createThickRing() {
//create 2 shapes
let inner = Circle(center: canvas.center, radius: 14)
let outer = Circle(center: canvas.center, radius: 225)
//store the frames of each position
thickRingFrames = [inner.frame,outer.frame]
}
Later, we’re going to use this frame array to make our animations happen. For now, we’re going to style the starting ring. Add the following to the end of the createThickRing
function:
1
2
3
4
5
6
7
inner.fillColor = clear
inner.lineWidth = 3
inner.strokeColor = COSMOSblue
inner.interactionEnabled = false
thickRing = inner
canvas.add(thickRing)
To see what this looks like you can add the following your project’s WorkSpace setup()
:
1
canvas.add(MenuRings().canvas)
Running your app should look like this:
Now, to see what the ring looks like in its outer state, add the following line in MenuRings.swift
, change:
1
thickRing = inner
to:
1
2
thickRing = inner
thickRing.frame = outer.frame
And, this is what you should see:
Undo that last change before moving on.
The process for building the thin rings is identical, save that it has more rings. Start by creating two arrays:
1
2
var thinRings : [Circle]!
var thinRingFrames : [Rect]!
We’re using an array for the thin rings because, unlike the thick one, there are 5 that we need to track.
The design file specifies that the inner ring for the starting position is 8pt radius, and the outer rings are: 56, 78, 98, 102 and 156pt diameters.
Start setting them all up by building a create method and constructing all of the rings including the inner and outer ones like so:
1
2
3
4
5
6
7
8
9
func createThinRings() {
thinRings = [Circle]()
thinRings.append(Circle(center: canvas.center, radius: 8))
thinRings.append(Circle(center: canvas.center, radius: 56))
thinRings.append(Circle(center: canvas.center, radius: 78))
thinRings.append(Circle(center: canvas.center, radius: 98))
thinRings.append(Circle(center: canvas.center, radius: 102))
thinRings.append(Circle(center: canvas.center, radius: 156))
}
This creates a bunch of shapes and adds them to an array so they can be referenced later.
Next, create a loop that will run through all the shapes and style them. Add the following to the createThinRings
method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
thinRingFrames = [Rect]()
for i in 0..<self.thinRings.count {
let ring = self.thinRings[i]
ring.fillColor = clear
ring.lineWidth = 1
ring.strokeColor = COSMOSblue
ring.interactionEnabled = false
if i > 0 {
ring.opacity = 0.0
}
self.thinRingFrames.append(ring.frame)
}
for ring in thinRings {
canvas.add(ring)
}
The loop is fairly straightforward: it iterates through all the thing rings, executing a bunch of styling code as it goes along. The rings are added in order from smallest to biggest, so the first one ends up being the size of the inner ring (i.e. when the menu is in its default state). To start we only want to see the inner ring, so for every other one where i > 0
we set their opacity to 0
. As we go along we add the frame size of each ring to thinRingFrames
, and finish by adding all the rings to the canvas.
Update the setup()
to look like this:
1
2
3
4
public override func setup() {
createThickRing()
createThinRings()
}
Running your app should look like this:
To see the rings in their outer state, in createThinRings()
, you need to change from:
1
if i > 0
… to:
1
if i == 0
Now, your app should look like:
Undo that last change before moving on.
Just like we’ve already done twice, we’re going to create an array to store the dashed rings. However, the dashed rings are eventually going to “fill” in, so we don’t need to store targets for them.
Add the following variable to your class:
1
var dashedRings : [Circle]!
Now, create three methods:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func createShortDashedRing(){
}
func createLongDashedRing(){
}
func createDashedRings() {
dashedRings = [Circle]()
createShortDashedRing()
createLongDashedRing()
for ring in self.dashedRings {
ring.strokeColor = COSMOSblue
ring.fillColor = clear
ring.interactionEnabled = false
ring.lineCap = .Butt
self.canvas.add(ring)
}
}
We’re going to create the short / long dashed with two separate lines whose thicknesses are different, and whose dash patterns are lined up to give the illusion of a short/long dashed line.
This is what the setup for the short dashed rings looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
func createShortDashedRing() {
let shortDashedRing = Circle(center: canvas.center, radius: 82+2)
let pattern = [1.465,1.465,1.465,1.465,1.465,1.465,1.465,1.465*3.0] as [NSNumber]
shortDashedRing.lineDashPattern = pattern
shortDashedRing.strokeEnd = 0.995
let angle = degToRad(-1.5)
let rotation = Transform.makeRotation(angle)
shortDashedRing.transform = rotation
shortDashedRing.lineWidth = 0.0
dashedRings.append(shortDashedRing)
}
There’s a lot going on here, some of which is contingent on other design factors, so I’ll break it down as much as possible.
82+2
, I could have written 84
but that +2
actually refers to half of the lineWidth
.1pattern
is 1.465,....
This number took some tuning to find for a couple reasons: a) the circle is supposed to be divided into 36 sections 10 degrees each, b) The 1.465*3.0
represents the gap (e.g. 2 spaces plus an additional space where the long dash should be), c) the as [NSNumber]
is a required cast because thats what the underlying property requires (e.g. not [Double]
)-1.5
degrees, so we convert that to radians, create a transform and apply that to the shape.The longDashedRing
method should look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func createLongDashedRing() {
let longDashedRing = Circle(center: canvas.center, radius: 82+2)
longDashedRing.lineWidth = 0.0
let pattern = [1.465,1.465*9.0] as [NSNumber]
longDashedRing.lineDashPattern = pattern
longDashedRing.strokeEnd = 0.995
let angle = degToRad(0.5)
let rotation = Transform.makeRotation(angle)
longDashedRing.transform = rotation
let mask = Circle(center: longDashedRing.bounds.center, radius: 82+4)
mask.fillColor = clear
mask.lineWidth = 8
longDashedRing.layer?.mask = mask.layer
dashedRings.append(longDashedRing)
}
It’s pretty much the same process as the previous method, with a couple of tweaks:
[1.465,1.465*9.0]
meaning there will be a single dash followed by a gap that is 9x wider than the dash.0.5
degrees to properly center the starting point of the long dashed in the gaps of the short dashesstrokeEnd
from 1.0
to 0.995
to hide it.Update setup()
to look like:
1
2
3
4
5
public override func setup() {
createThickRing()
createThinRings()
createDashedRings()
}
To see what the rings will look like, do the following:
Change:
1
shortDashedRing.lineWidth = 0.0
to:
1
shortDashedRing.lineWidth = 4.0
And, change:
1
longDashedRing.lineWidth = 0.0
to:
1
longDashedRing.lineWidth = 12.0
Run it and you’ll see this:
Undo those two changes before moving on.
The mask component is a nice little trick that we use to make our lives a bit easier when it comes to shaping the lines. By default, a line is drawn from the center outward, so if you have a horizontal line with 12pt font there will be 6pt above and below.
The design shows both circles starting at the same inner diameter. We want it to look like the short and long dashes grow from the baseline outward… So, we cut off the extended part by masking the shape.
This is also why the line is 12pt thick, but only looks 6pt on screen… We’re effectively clipping 6pts
This is how masks work: wherever a mask has color the object that is being masked will show through. So, we create a mask with an 8pt solid line whose diameter is offset enough so that the edges of the mask’s line touch the baseline of the circles and extend to the top of the long dashes, like this:
Oh yeah, and when you apply a mask to an object it gets positioned inside the coordinate space of the object, which is why we do longDashedRing.bounds.center
for the center point of the mask.
Next step is to create the dividing lines that will eventually separate the spaces for the icons.
First, create this variable:
1
var menuDividingLines : [Line]!
Then, add this method to your class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func createMenuDividingLines() {
menuDividingLines = [Line]()
for i in 0...11 {
let line = Line((Point(),Point(54,0)))
line.anchorPoint = Point(-1.88888,0)
line.center = canvas.center
line.transform = Transform.makeRotation(M_PI / 6.0 * Double(i) , axis: Vector(x: 0, y: 0, z: -1))
line.lineCap = .Butt
line.strokeColor = COSMOSblue
line.lineWidth = 1.0
line.strokeEnd = 0.0
canvas.add(line)
menuDividingLines.append(line)
}
}
Fairly straightforward to set up the lines. We know the gap between the two lines for the inner and outer edges of the icon section is 54pt
so that’s how long we make our line. We style it and thennnnnn we change the anchorPoint
.
Every visible object has an anchor point whose default position is in the middle of the object’s view. It is around this point that any visible transforms happen. For example, if I simply apply a rotation to an object (like we did for the short / long dashed lines) then the entire object will rotate around its center. 2
The effect we want to create, lines angled evenly to create spaces between two rings, relies implicitly on our being able to play with the anchorPoint
property. We could calculate the rotated positions of a
and b
for each line, but that’s the less elegant way of creating the effect.
What we do is offset the position of the anchorPoint
so that we can rotate around a position outside the space of the line. Woah, pictures speak better than words, so look at this:
Another thing about anchor points is that they are measured relative to the space of the object’s view. Specifically, the center point of a view is {0.5,0.5}
. So, now we just need to figure out a which point we need to set the anchor so that our 54pt
line sits at the right place.
We know the inner radius of the circle is 102
(e.g. the second-last thin ring), and we know the width of the line is 54
, so all we need to do is translate that to relative coordinates:
102/54 = 1.888
And, since we want the point to be outside the view to the left of 0
we need that value to be negative, which is what this line means:
1
line.anchorPoint = CGPointMake(-1.88888,0)
The rest of the method is straightforward. We center the anchor point to the center of the canvas, then rotate the line into place. We do this for 12 lines then add them to both the canvas and an array (so we can toy with them later).
Wham. Those lines are done.
Oh, and for reference, this is what our layout would look like if we didn’t adjust the anchor point of the lines:
Your setup()
should look like this:
1
2
3
4
5
6
override func setup() {
self.createThickRing()
self.createThinRings()
self.createDashedRings()
self.createMenuDividingLines()
}
To see the dividing lines, in createMenuDividingLines change:
1
line.strokeEnd = 0.0
to:
1
line.strokeEnd = 1.0
Or comment it out.
And this is what you should see:
And, if you want to go back and change all the previous variables to see the entire state of outer rings and lines you should get this:
Undo any changes so that the lines set up in their inner state.
Wham.
The lines look good.
Let’s keep going.
As I’m writing this I thought “why did I write it this way” but then after reading the code again I realized I had left it like this as a signal for myself to remember that the center needs to be just a little bit more than the specified 82 pt diameter from Jake’s design file. ↩
Note that i’m implying a rotation around the z-axis, but rotations can also happen through x and y as well. ↩