How To Make a Custom Control Tutorial: A Reusable Knob
Custom UI controls are extremely useful when you need some new functionality in your app — especially when they’re generic enough to be reusable in other apps. This custom control tutorial covers the creation of a control kind of like a circular slider inspired by a control knob, such as those found on a mixer. By Lorenzo Boaro.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How To Make a Custom Control Tutorial: A Reusable Knob
30 mins
- Getting Started
- Designing Your Control’s API
- Setting the Appearance of Your Control
- Exposing Appearance Properties in the API
- Setting the Control’s Value Programmatically
- Animating Changes to the Control’s Value
- Updating the Label
- Responding to Touch Interaction
- Wiring Up the Custom Gesture Recognizer
- Sending Action Notifications
- Where to Go From Here?
Custom UI controls are extremely useful when you need some new functionality in your app — especially when they’re generic enough to be reusable in other apps.
We have an excellent tutorial providing an introduction to custom UI Controls in Swift. That tutorial walks you through the creation of a custom double-ended UISlider
that lets you select a range with start and end values.
This custom control tutorial takes that concept a bit further and covers the creation of a control kind of like a circular slider inspired by a control knob, such as those found on a mixer:
UIKit
provides the UISlider
control, which lets you set a floating point value within a specified range. If you’ve used any iOS device, then you’ve probably used a UISlider
to set volume, brightness, or any one of a multitude of other variables. Your project will have the same functionality, but in a circular form.
Getting Started
Use the Download Materials button at the top or bottom of this tutorial to download the starter project.
Go to ReusableKnob/Starter and open the starter project. It’s a simple single view application. The storyboard has a few controls that are wired up to the main view controller. You’ll use these controls later in the tutorial to demonstrate the different features of the knob control.
Build and run your project to get a sense of how everything looks before you dive into the code. It should look like this:
To create the class for the knob control, click File ▸ New ▸ File… and select iOS ▸ Source ▸ Cocoa Touch Class. On the next screen, specify the class name as Knob, subclass UIControl and make sure the language is Swift. Click Next, choose the ReusableKnob group and click Create.
Before you can write any code for the new control, you have to add it to your view controller.
Open Main.storyboard and select the view to the left of the label. In Identity Inspector, set the class to Knob like this:
Now create an outlet for your knob. In the storyboard, open the Assistant editor; it should display ViewController.swift.
To create the outlet, click the Knob and control-drag it right underneath the animateSwitch IBOutlet
. Release the drag and, in the pop-up window, name the outlet knob then click Connect. You’ll use it later in the tutorial.
Switch back to the Standard editor and, in Knob.swift, replace the boiler-plate class definition with the following code:
class Knob: UIControl {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
backgroundColor = .blue
}
}
This code defines the two initializers and sets the background color of the knob so that you can see it on the screen.
Build and run your app and you’ll see the following:
With the basic building blocks in place, it’s time to work on the API for your control!
Designing Your Control’s API
The main reason for creating a custom UI control is to create a reusable component. It’s worth taking a bit of time up-front to plan a good API for your control. Developers should understand how to use your component from looking at the API alone, without browsing the source code.
Your API consists of the public functions and properties of your custom control.
In Knob.swift, add the following code to the Knob
class above the initializers:
var minimumValue: Float = 0
var maximumValue: Float = 1
private (set) var value: Float = 0
func setValue(_ newValue: Float, animated: Bool = false) {
value = min(maximumValue, max(minimumValue, newValue))
}
var isContinuous = true
-
minimumValue
,maximumValue
andvalue
set the basic operating parameters for your control. -
setValue(_:animated:)
lets you set the value of the control programmatically, while the additional boolean parameter indicates whether or not the change in value should be animated. Becausevalue
can only be set between the limits ofminimum
andmaximum
you make its setter private with theprivate (set)
qualifiers. - If
isContinuous
istrue
, the control calls back repeatedly as the value changes. If it’sfalse
, the control calls back once after the user has finished interacting with it.
You’ll ensure that these properties behave appropriately later on in this tutorial.
Now, it’s time to get cracking on the visual design.
Setting the Appearance of Your Control
In this tutorial, you’ll use Core Animation layers.
A UIView
is backed by a CALayer
, which helps iOS optimize the rendering on the GPU. CALayer
objects manage visual content and are designed to be incredibly efficient for all types of animations.
Your knob control will be made up of two CALayer
objects: one for the track, and one for the pointer itself.
The diagram below illustrates the structure of your knob control:
The blue and red squares represent the two CALayer
objects. The blue layer contains the track of the knob control, and the red layer the pointer. When overlaid, the two layers create the desired appearance of a moving knob. The difference in coloring above is just for illustration purposes.
The reason to use two separate layers becomes obvious when the pointer moves to represent a new value. All you need to do is rotate the layer containing the pointer, which is represented by the red layer in the diagram above.
It’s cheap and easy to rotate layers in Core Animation. If you chose to implement this using Core Graphics and override drawRect(_:)
, the entire knob control would be re-rendered in every step of the animation. Since it’s a very expensive operation, it would likely result in sluggish animation.
To keep the appearance parts separate from the control parts, add a new private class to the end of Knob.swift:
private class KnobRenderer {
}
This class will keep track of the code associated with rendering the knob. That will add a clear separation between the control and its internals.
Next, add the following code inside the KnobRenderer
definition:
var color: UIColor = .blue {
didSet {
trackLayer.strokeColor = color.cgColor
pointerLayer.strokeColor = color.cgColor
}
}
var lineWidth: CGFloat = 2 {
didSet {
trackLayer.lineWidth = lineWidth
pointerLayer.lineWidth = lineWidth
updateTrackLayerPath()
updatePointerLayerPath()
}
}
var startAngle: CGFloat = CGFloat(-Double.pi) * 11 / 8 {
didSet {
updateTrackLayerPath()
}
}
var endAngle: CGFloat = CGFloat(Double.pi) * 3 / 8 {
didSet {
updateTrackLayerPath()
}
}
var pointerLength: CGFloat = 6 {
didSet {
updateTrackLayerPath()
updatePointerLayerPath()
}
}
private (set) var pointerAngle: CGFloat = CGFloat(-Double.pi) * 11 / 8
func setPointerAngle(_ newPointerAngle: CGFloat, animated: Bool = false) {
pointerAngle = newPointerAngle
}
let trackLayer = CAShapeLayer()
let pointerLayer = CAShapeLayer()
Most of these properties deal with the visual appearance of the knob. The two CAShapeLayer
properties represent the layers shown above. The color
and lineWidth
properties just delegate to the strokeColor
and lineWidth
of the two layers. You’ll see unresolved identifier compiler errors until you implement updateTrackLayerPath
and updatePointerLayerPath
in a moment.
Now add an initializer to the class right underneath the pointerLayer
property:
init() {
trackLayer.fillColor = UIColor.clear.cgColor
pointerLayer.fillColor = UIColor.clear.cgColor
}
Initially you set the appearance of the two layers as transparent.
You’ll create the two shapes that make up the overall knob as CAShapeLayer
objects. These are a special subclasses of CALayer
that draw a bezier path using anti-aliasing and some optimized rasterization. This makes CAShapeLayer
an extremely efficient way to draw arbitrary shapes.
Add the following two methods to the KnobRenderer
class:
private func updateTrackLayerPath() {
let bounds = trackLayer.bounds
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let offset = max(pointerLength, lineWidth / 2)
let radius = min(bounds.width, bounds.height) / 2 - offset
let ring = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle,
endAngle: endAngle, clockwise: true)
trackLayer.path = ring.cgPath
}
private func updatePointerLayerPath() {
let bounds = trackLayer.bounds
let pointer = UIBezierPath()
pointer.move(to: CGPoint(x: bounds.width - CGFloat(pointerLength)
- CGFloat(lineWidth) / 2, y: bounds.midY))
pointer.addLine(to: CGPoint(x: bounds.width, y: bounds.midY))
pointerLayer.path = pointer.cgPath
}
updateTrackLayerPath
creates an arc between the startAngle
and endAngle
values with a radius that ensures the pointer will fit within the layer, and positions it on the center of the trackLayer
. Once you create the UIBezierPath
, you use the cgPath
property to set the path
on the appropriate CAShapeLayer
.
Since UIBezierPath
has a more modern API, you use that to initially create the path, and then convert it to a CGPathRef
.
updatePointerLayerPath
creates the path for the pointer at the position where angle
is equal to zero. Again, you create a UIBezierPath
, convert it to a CGPathRef
and assign it to the path
property of your CAShapeLayer
. Since the pointer is a straight line, all you need to draw the pointer are move(to:)
and addLine(to:)
.
Calling these methods redraws the two layers. This must happen when you modify any of the properties used by these methods.
You may have noticed that the two methods for updating the shape layer paths rely on one more property which has never been set — namely, the bounds of each of the shape layers. Since you never set the CAShapeLayer
bounds, they currently have zero-sized bounds.
Add a new method to KnobRenderer
:
func updateBounds(_ bounds: CGRect) {
trackLayer.bounds = bounds
trackLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
updateTrackLayerPath()
pointerLayer.bounds = trackLayer.bounds
pointerLayer.position = trackLayer.position
updatePointerLayerPath()
}
The above method takes a bounds rectangle, resizes the layers to match and positions the layers in the center of the bounding rectangle. When you change a property that affects the paths, you must call the updateBounds(_:)
method manually.
Although the renderer isn’t quite complete, there’s enough here to demonstrate the progress of your control. Add a property to hold an instance of your renderer to the Knob
class:
private let renderer = KnobRenderer()
Replace the code of commonInit()
method of Knob
with:
private func commonInit() {
renderer.updateBounds(bounds)
renderer.color = tintColor
renderer.setPointerAngle(renderer.startAngle, animated: false)
layer.addSublayer(renderer.trackLayer)
layer.addSublayer(renderer.pointerLayer)
}
The above method sets the knob renderer’s size, then adds the two layers as sublayers of the control’s layer.
Build and run your app, and your control should look like the one below: