Chapter 18
Menu Icons
Time to flesh out the radial menu with some animated icons.
Chapter 19
Menu Selector
Tags
cosmos,
interaction,
audio
Estimated Time
Now we need to work on the selector. The easiest way to see if we’re getting things right is to make sure the menu is OUT while we’re testing. To do this, modify your project’s WorkSpace
to have the following setup()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
override func setup() {
canvas.backgroundColor = COSMOSbkgd
let rings = MenuRings()
rings.canvas.center = canvas.center
canvas.add(rings.canvas)
rings.revealDashedRings?.animate()
rings.revealHideDividingLines(1.0)
rings.thickRingOut?.animate()
rings.thinRingsOut?.animate()
let icons = MenuIcons()
icons.canvas.center = canvas.center
canvas.add(icons.canvas)
icons.signIconsOut?.animate()
icons.revealSignIcons?.animate()
}
When you run the app it should look like this:
Now, the goal of this chapter is to make sure that when the user drags around the menu a selector will appear for whichever element their thumb is over top of. It should look like:
We’re going to need to combine the following things to get it to work:
a long press gesture that tracks the user’s touch a method that converts the position of the touch to an angle / position in the menu a shape that acts as a highlight allow the gesture to trigger the in/out animations
The first step is to attach a gesture to the canvas and to start tracking the position of the user’s touch.
Open MenuSelector.swift
and add the following two methods to your class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func createGesture() {
canvas.addLongPressGestureRecognizer { (locations, center, state) -> () in
switch state {
case .Changed:
self.update(center)
default:
_ = ""
}
}
}
func update(location: Point) {
print(location)
}
Then, add modify setup()
to look like this:
1
2
3
4
5
public override func setup() {
canvas.frame = Rect(0,0,80,80)
canvas.backgroundColor = C4Pink
createGesture()
}
Then, go back to your project’s WorkSpace
and add the following to the setup()
there:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class WorkSpace: CanvasController {
override func setup() {
canvas.backgroundColor = COSMOSbkgd
let rings = MenuRings()
rings.canvas.center = canvas.center
canvas.add(rings.canvas)
rings.revealDashedRings?.animate()
rings.revealHideDividingLines(1.0)
rings.thickRingOut?.animate()
rings.thinRingsOut?.animate()
let icons = MenuIcons()
icons.canvas.center = canvas.center
canvas.add(icons.canvas)
icons.signIconsOut?.animate()
icons.revealSignIcons?.animate()
let selector = MenuSelector()
selector.canvas.center = canvas.center
canvas.add(selector.canvas)
}
}
Run it, and you should see this:
And when you long press then drag, starting on the pink box, you should see the current position of the touch update constantly in the console.
We’re using the switch
statement now to check when the touch is updated, but by default the method does nothing. For now we are testing the drag / changed component of the gesture, eventually we’re going to track the beginning and end states as well.
We need to know the angle from the center of the canvas to wherever the user’s touch is, and when calculating the angles between points, it’s important to remember three things.
First, calculating an angle always defaults to the smallest positive angle (so we’ll need to adjust for this). The following two states return equivalent values:
Second, we’re going to work with a coordinate system that starts on the right and rotates clockwise.
Third, we need 3 points to calculate the angle between the user’s touch and the center of the screen. The points are: a) an arbitrary point on the x-axis to the right of the center, b) the center point, c) the touch point. With these three points we can calculate the angle:
Replace the contents of update(location:)
with:
1
2
3
4
5
6
let a = Vector(x:self.canvas.width / 2.0+1.0, y:self.canvas.height/2.0)
let b = Vector(x:self.canvas.width / 2.0, y:self.canvas.height/2.0)
let c = Vector(x:location.x, y:location.y)
var ϴ = c.angleTo(a, basedOn: b)
print(ϴ)
Run it. Notice that when the touch drags across the x-axis on the left-hand side of the screen that the values to up to 𝜋 then starts going back down to zero. Here’s how we adjust for that:
1
2
3
if c.y < a.y {
ϴ = 2*M_PI - ϴ
}
Place this right before
print(ϴ)
.
We’re going to want to convert ϴ to an index based on the number of divisions in our menu. There are 12 divisions and the menu is 360∘ (a full circle) so we will modify the value like this:
1
let index = Int(radToDeg(ϴ)) / 30
Now we know in which section of the menu our point is generally sitting.
Finally, change the print statement to:
1
print(index)
Run it, and you’ll see that you get a number from 0 to 11 instead of radian values.
The highlight is supposed to pop up in the area surrounding an icon. So… It has a weird shape.
We could do some tricky thing were where figure out the exact arcs and dimension of a shape, then rotate that thing around, but… I prefer to do things a little simpler.
First, we’re going to use a wedge to define the highlight. The wedge is a pie slice out of a circle, so it’s outer edge is already rounded. Let’s build a wedge and add it to the menu.
Add the following variable to your class:
1
var highlight : Shape!
Then, add this method:
1
2
3
4
5
6
7
8
9
10
11
func createHighlight() {
highlight = Wedge(center: canvas.center, radius: 156, start: M_PI/6.0, end: 0.0, clockwise: false)
highlight.fillColor = COSMOSblue
highlight.lineWidth = 0.0
highlight.opacity = 0.8
highlight.interactionEnabled = false
highlight.anchorPoint = Point()
highlight.center = canvas.center
canvas.add(highlight)
}
Finally, call that method in setup()
:
1
2
3
4
5
6
public override func setup() {
canvas.frame = Rect(0,0,80,80)
canvas.backgroundColor = C4Pink
createGesture()
createHighlight()
}
Run it, and you should see:
What we’ve done is created a wedge that sits at the first position (e.g. 0) of our menu. It’s anchorPoint is set to the top-left corner of its view and is the point around which we will rotate the shape. The rest of the code is essentially layout.
We want to get rid of the part of the wedge that is outside of the “container” of the icon. The easiest way to do this is to mask the wedge.
Add the following to createHighlight()
right before adding the wedge to the canvas:
1
2
3
4
let donut = Circle(center: highlight.center, radius: 156-54/2.0)
donut.fillColor = clear
donut.lineWidth = 54
highlight.mask = donut
Run it!
Wham!
This creates a circle whose diameter goes to the mid-point of the icon’s container, and whose lineWidth fills the entire container. It then uses that shape as a mask where anything opaque is revealed, and anything clear is hidden.
This is what the donut looks like if it is added to the wedge:
Now, we’re going to attach the position of the highlight to the gesture we’ve already made.
Add the following at the end of update(location:)
:
1
2
let rotation = Transform.makeRotation(degToRad(Double(index) * 30.0), axis: Vector(x: 0,y: 0,z: -1))
highlight.transform = rotation
Run it:
We want the behaviour of the wedge to follow these rules:
Trigger the change in position only once as the user’s touch moves into a new icon Highlight only when the user’s touch is over top of one of the icons Hide the highlight when the user’s touch is not over an icon Create a class variable to mark the current selection, like so:
1
var currentSelection = -1
Then, modify update()
with some logic to check if the current selection is the same as the index that was just calculated:
1
2
3
4
5
if currentSelection != index {
currentSelection = index
let rotation = Transform.makeRotation(degToRad(Double(currentSelection) * 30.0), axis: Vector(x: 0,y: 0,z: -1))
highlight.transform = rotation
}
This won’t look like much on screen, but trust me: it’s preventing updating every time the user’s touch moves, and only updating when they’ve rolled over into a new section of the menu.
Then, wrap the logic in an if-else
that checks the distance of the user’s touch from the center of the canvas. Since we’re dealing with a circle, any time the user’s touch is between 102
and 156
points from the center of the canvas we know it’s in the area of the icons.
The end of update()
should now look like this:
1
2
3
4
5
6
7
8
9
let dist = distance(location, rhs: self.canvas.bounds.center)
if dist > 102 && dist < 156 {
if currentSelection != index {
currentSelection = index
let rotation = Transform.makeRotation(degToRad(Double(currentSelection) * 30.0), axis: Vector(x: 0,y: 0,z: -1))
highlight.transform = rotation
}
}
And, when you run it you should see that the highlight only updates whenever the touch is within the bounds of the icon ring:
Finally, add the following to createHighlight()
:
1
highlight.hidden = true
And, turn the previous if statement into an if-else where that toggles the hiding / revealing of the highlight:
1
2
3
4
5
6
7
8
9
10
if dist > 102 && dist < 156 {
highlight.hidden = false
if currentSelection != index {
currentSelection = index
let rotation = Transform.makeRotation(degToRad(Double(currentSelection) * 30.0), axis: Vector(x: 0,y: 0,z: -1))
highlight.transform = rotation
}
} else {
highlight.hidden = true
}
If the user’s touch is in the icon area we reveal the highlight by setting its hidden property to false
, and if the touch falls outside that area we hide the highlight again.
Last, but not least, we want the highlight to hide when the user touch ends. To do this we add a bit of logic to our gesture:
1
2
3
case .Cancelled, .Ended, .Failed:
currentSelection = -1
highlight.hidden = true
currentSelection = -1
is included here because we want the selection to be reset whenever the gesture ends.
This logic states that if the gesture is canceled, or failed (states that are governed by the device) or if it is ended (based on the user’s action) then we check to see if the highlight is visible, and if so it hides it.
The whole gesture method should look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func createGesture() {
//add a long press gesture to the menu's canvas
canvas.addLongPressGestureRecognizer { (locations, center, state) -> () in
switch state {
case .Changed:
self.update(center)
case .Cancelled, .Ended, .Failed:
self.currentSelection = -1
self.highlight.hidden == true
default:
_ = ""
}
}
}
And, this is what things should look like:
The label will be centered in the menu and will show the name of the astrological sign the user is currently selecting.
Create a class variable like so:
1
var menuLabel : TextShape!
And, add the following method:
1
2
3
4
5
6
7
8
9
func createLabel() {
let f = Font(name: "Menlo-Regular", size: 13)!
menuLabel = TextShape(text: "COSMOS", font: f)!
menuLabel.center = canvas.center
menuLabel.fillColor = white
menuLabel.interactionEnabled = false
canvas.add(menuLabel)
menuLabel.hidden = true
}
Add a call to createLabel()
to setup()
.
Then, in update(location:)
, just above highlight.hidden = false
add the following:
1
menuLabel.hidden = false
And, inside the following if
statement, add this logic:
1
2
3
4
ShapeLayer.disableActions = true
menuLabel?.text = AstrologicalSignProvider.sharedInstance.order[index].capitalizedString
menuLabel?.center = canvas.bounds.center
ShapeLayer.disableActions = false
This updates the text of the label while disabling Core Animation’s implicit animation which happens because we’re changing the path of a shape.
Then, in the final else
of the same method, hide the menu label like this:
1
menuLabel.hidden = true
You also want to add the hidden = true
line to the .Cancelled
case of the long press gesture.
Your update(location:)
should now look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func update(location: Point) {
let a = Vector(x:self.canvas.width / 2.0+1.0, y:self.canvas.height/2.0)
let b = Vector(x:self.canvas.width / 2.0, y:self.canvas.height/2.0)
let c = Vector(x:location.x, y:location.y)
var ϴ = c.angleTo(a, basedOn: b)
if c.y < a.y {
ϴ = 2*M_PI - ϴ
}
let index = Int(radToDeg(ϴ)) / 30
let dist = distance(location, rhs: self.canvas.bounds.center)
if dist > 102 && dist < 156 {
menuLabel.hidden = false
highlight.hidden = false
if currentSelection != index {
ShapeLayer.disableActions = true
menuLabel?.text = AstrologicalSignProvider.sharedInstance.order[index].capitalizedString
menuLabel?.center = canvas.bounds.center
ShapeLayer.disableActions = false
currentSelection = index
let rotation = Transform.makeRotation(degToRad(Double(currentSelection) * 30.0), axis: Vector(x: 0,y: 0,z: -1))
highlight.transform = rotation
}
} else {
highlight.hidden = true
menuLabel.hidden = true
currentSelection = -1
}
}
Your setup()
should look like:
1
2
3
4
5
6
7
public override func setup() {
canvas.frame = Rect(0,0,80,80)
canvas.backgroundColor = C4Pink
createGesture()
createHighlight()
createLabel()
}
And, this is what your app should act like when you run it:
We chose to include in the app an info panel with a bit of text about the app and a link to the C4 site. Since we’ll access this panel from the menu we need to also include an info button.
Add a class-level variable to your project like so:
1
var infoButton : View!
We are going to use a view as our button because we want to do a little trick.
First, we’re going to use the standard info image from iOS which looks like this:
I’ve previously grabbed the image by running the following code in another project:
1
2
3
4
5
6
7
let button = UIButton(type: UIButtonType.InfoLight)
button.imageView?.tintColor = UIColor.whiteColor()
let img = button.imageForState(.Normal)
let fileManager = NSFileManager.defaultManager()
let data = UIImagePNGRepresentation(img!)
fileManager.createFileAtPath("/Users/travis/Desktop/info.png", contents: data, attributes: nil)
This gave me an image that I loaded into Photoshop, inverted its color to white, then exported all 3 size assets.
The standard size of the info button on iOS is 22 x 22 pts, which is quite small. If we made a button this small it would be difficult to hit. So…
Having our infoButton
be a View
allows us to make the hit area of our button larger than the image we’re going to show.
Copy the following method into your class:
1
2
3
4
5
6
7
8
9
10
func createInfoButton() {
infoButton = View(frame: Rect(0,0,44,44))
let buttonImage = Image("info")!
buttonImage.interactionEnabled = false
buttonImage.center = infoButton.center
infoButton.add(buttonImage)
infoButton.opacity = 1.0
infoButton.center = Point(canvas.center.x, canvas.center.y+190.0)
canvas.add(infoButton)
}
Fairly straightforward: make a small image, make a larger view, add the image to the view, add the view to the canvas and hide it.
Next, add two more variables and a method for creating the animations to reveal / hide the button:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var revealInfoButton : ViewAnimation?
var hideInfoButton : ViewAnimation?
func createInfoButtonAnimations() {
revealInfoButton = ViewAnimation(duration:0.33) {
self.infoButton?.opacity = 1.0
}
revealInfoButton?.curve = .EaseOut
hideInfoButton = ViewAnimation(duration:0.33) {
self.infoButton?.opacity = 0.0
}
hideInfoButton?.curve = .EaseOut
}
We won’t bother with these animations yet, they’ll come into play in a later chapter.
Finally, we want to check if the user’s touch is over top of the info button, and since the button is outside of the icon menu, add the following to the else
at the end of update
:
1
2
3
4
5
6
7
8
9
if let l = infoButton {
if l.hitTest(location, from:canvas) {
menuLabel.hidden = false
ShapeLayer.disableActions = true
menuLabel.text = "Info"
menuLabel.center = canvas.bounds.center
ShapeLayer.disableActions = false
}
}
We want the selector to also trigger some sounds. Eventually, the selector will be synced with the opening and closing of the menu, so we’ll handle three things here:
Add the following three variables to you class:
1
let tick = AudioPlayer("tick.mp3")!
Then, in setup
add this:
1
tick.volume = 0.4
Then, right inside of the if currentSelection != index {...}
add this:
1
2
tick.stop()
tick.play()
The reason why we call stop()
is because Core Audio defaults to waiting for a sound to finish playing before playing it again. Stopping it right before playing removes the potential delay in playing if the user is moving their thumb around the menu very quickly.
Run it, and you’ll hear some nice little sounds play out.
Now, change setup()
to:
1
2
3
4
5
6
7
8
9
10
public override func setup() {
//create the frame, small like the other classes
canvas.frame = Rect(0,0,80,80)
canvas.backgroundColor = clear
createHighlight()
createLabel()
createInfoButton()
createInfoButtonAnimations()
tick.volume = 0.4
}
Also, remove all the junk we put into setup()
in your project’s WorkSpace
.
Eventually, we’re going to create the gesture somewhere else and remove it from this class.
You can grab the current state of MenuSelector.swift.
Breathe.
You’re doing good.
We’re almost done.