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.

Leave a rating/review
Download materials
Save for later
Share
Update note: Lorenzo Boaro updated this tutorial for iOS 11, Xcode 9, and Swift 4. Sam Davies wrote the original tutorial.

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:

sound_desk_knob

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 and value 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. Because value can only be set between the limits of minimum and maximum you make its setter private with the private (set) qualifiers.
  • If isContinuous is true, the control calls back repeatedly as the value changes. If it’s false, 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:

CALayerDiagram

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:).

Note: If you need a referesher on drawing angles and other related concepts, check out our Trigonometry for Game Programming tutorial.

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:

Lorenzo Boaro

Contributors

Lorenzo Boaro

Author

Vladyslav Mytskaniuk

Illustrator

Jeff Rames

Final Pass Editor

Richard Critz

Team Lead

Over 300 content creators. Join our team.