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
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Updating the Label

Next you’ll populate the label to the right of the knob with its current value. Open ViewController.swift and add this method below the two @IBAction methods:

func updateLabel() {
  valueLabel.text = String(format: "%.2f", knob.value)
}

This will show the current value selected by the knob control. Next, call this new method at the end of both handleValueChanged(_:) and handleRandomButtonPressed(_:) like this:

updateLabel()

Finally, update the initial value of the knob and the label to be the initial value of the slider so that all they are in sync when the app starts. Add the following code to the end of viewDidLoad():

knob.setValue(valueSlider.value)
updateLabel()

Build and run, and perform a few tests to make sure the label shows the correct value.

Responding to Touch Interaction

The knob control you’ve built responds only to programmatic interaction, but that alone isn’t terribly useful for a UI control. In this final section, you’ll see how to add touch interaction using a custom gesture recognizer.

Apple provides a set of pre-defined gesture recognizers, such as tap, pan and pinch. However, there’s nothing to handle the single-finger rotation you need for your control.

Add a new private class to the end of Knob.swift:

import UIKit.UIGestureRecognizerSubclass

private class RotationGestureRecognizer: UIPanGestureRecognizer {
}

This custom gesture recognizer will behave like a pan gesture recognizer. It will track a single finger dragging across the screen and update the location as required. For this reason, it subclasses UIPanGestureRecognizer.

The import is necessary so you can override some gesture recognizer methods later.

Note: You might be wondering why you’re adding all these private classes to Knob.swift rather than the usual one-class-per-file. For this project, it makes it easy to distribute just a single file to anyone who wants to use this simple control.

Add the following property to your RotationGestureRecognizer class:

private(set) var touchAngle: CGFloat = 0

touchAngle represents the touch angle of the line which joins the current touch point to the center of the view to which the gesture recognizer is attached, as demonstrated in the following diagram:

GestureRecogniserDiagram

There are three methods of interest when subclassing UIGestureRecognizer: they represent the time that the touches begin, the time they move and the time they end. You’re only interested when the gesture starts and when the user’s finger moves on the screen.

Add the following two methods to RotationGestureRecognizer:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
  super.touchesBegan(touches, with: event)
  updateAngle(with: touches)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
  super.touchesMoved(touches, with: event)
  updateAngle(with: touches)
}

Both of these methods call through to their super equivalent, and then call a utility function which you’ll add next:

private func updateAngle(with touches: Set<UITouch>) {
  guard 
    let touch = touches.first, 
    let view = view 
  else {
    return
  }
  let touchPoint = touch.location(in: view)
  touchAngle = angle(for: touchPoint, in: view)
}

private func angle(for point: CGPoint, in view: UIView) -> CGFloat {
  let centerOffset = CGPoint(x: point.x - view.bounds.midX, y: point.y - view.bounds.midY)
  return atan2(centerOffset.y, centerOffset.x)
}

updateAngle(with:) takes the set of touches and extracts the first one. It then uses location(in:) to translate the touch point into the coordinate system of the view associated with this gesture recognizer. It then updates the touchAngle property using angle(for:in:), which uses some simple geometry to find the angle as demonstrated below:

AngleCalculation

x and y represent the horizontal and vertical positions of the touch point within the control. The tangent of the rotation, that is the touch angle is equal to h / w. To calculate touchAngle all you need to do is establish the following lengths:

  • h = y - (view height) / 2.0 (since the angle should increase in a clockwise direction)
  • w = x - (view width) / 2.0

angle(for:in:) performs this calculation for you, and returns the angle required.

Note: If this math makes no sense, refer to our old friend, the Trigonometry for Game Programming tutorial.

Finally, your gesture recognizer should work with one touch at a time. Add the following initializer to the class:

override init(target: Any?, action: Selector?) {
  super.init(target: target, action: action)

  maximumNumberOfTouches = 1
  minimumNumberOfTouches = 1
}

Wiring Up the Custom Gesture Recognizer

Now that you’ve completed the custom gesture recognizer, you just need to wire it up to the knob control.

In Knob, add the following to the end of commonInit():

let gestureRecognizer = RotationGestureRecognizer(target: self, action: #selector(Knob.handleGesture(_:)))
addGestureRecognizer(gestureRecognizer)

This creates a recognizer, specifies it should call Knob.handleGesture(_:) when activated, then adds it to the view. Now you need to implement that action!

Add the following method to Knob:

@objc private func handleGesture(_ gesture: RotationGestureRecognizer) {
  // 1
  let midPointAngle = (2 * CGFloat(Double.pi) + startAngle - endAngle) / 2 + endAngle
  // 2
  var boundedAngle = gesture.touchAngle
  if boundedAngle > midPointAngle {
    boundedAngle -= 2 * CGFloat(Double.pi)
  } else if boundedAngle < (midPointAngle - 2 * CGFloat(Double.pi)) {
    boundedAngle -= 2 * CGFloat(Double.pi)
  }
  
  // 3
  boundedAngle = min(endAngle, max(startAngle, boundedAngle))

  // 4
  let angleRange = endAngle - startAngle
  let valueRange = maximumValue - minimumValue
  let angleValue = Float(boundedAngle - startAngle) / Float(angleRange) * valueRange + minimumValue

  // 5
  setValue(angleValue)
}

This method extracts the angle from the custom gesture recognizer, converts it to the value represented by this angle on the knob control, and then sets the value to trigger the UI updates.

Here’s what happening in the code above:

  1. You calculate the angle which represents the mid-point between the start and end angles. This is the angle which is not part of the knob track, and instead represents the angle at which the pointer should flip between the maximum and minimum values.
  2. The angle calculated by the gesture recognizer will be between -π and π, since it uses the inverse tangent function. However, the angle required for the track should be continuous between the startAngle and the endAngle. Therefore, create a new boundedAngle variable and adjust it to ensure that it remains within the allowed ranges.
  3. Update boundedAngle so that it sits inside the specified bounds of the angles.
  4. Convert the angle to a value, just as you converted it in setValue(_:animated:) earlier.
  5. Set the knob control's value to the calculated value.

Build and run your app. Play around with your knob control to see the gesture recognizer in action. The pointer will follow your finger as you move it around the control :]

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.