跳转至

18 Designing Custom Controls

Standard UIKit controls are native, intuitive, and they support out-the-box features for iOS users. However, as you develop your app, you might discover that the standard controls limit you to a specific set of features that don’t meet your app’s requirements. Whether you want to implement a different user interface layout or some non-standard user interaction, understanding how to create custom controls will help you achieve the desired results.

Depending on the level of customizations you want to make, creating custom controls can require a substantial amount of work. The more customizations or features you want to add, the more work you’ll need to do. But that’s not all you need to think about. Your approach to making a custom control also dictates the amount of work you have ahead of you.

To make a custom UIKit control, you have two options: You can subclass a standard control like a UIButton. Or, you can subclass a more generic class like UIView or UIControl. Each approach has its pros and cons, which you’ll discover as you build your first custom control.

In this chapter, you’ll create a custom DJ deck user interface. In doing so, you’ll learn the following topics:

  • Subclassing and making a custom UIView.
  • Visualizing XIB/NIB files in a storyboard.
  • Customizing user interfaces according to size classes.
  • Subclassing and making a custom UIButton.
  • Subclassing and making a custom UIControl.
  • Adopting accessibility features.

Getting started

You’ll start by subclassing a UIView to create a custom view. Open the starter project. To keep this chapter focused, you’ll use an existing view and NIB file, located inside the project.

For the custom DJ deck, the layout blueprint for landscape and portrait orientations is as follows:

img

For this control, you’ll implement the following custom user interfaces:

  • Backlit Button.
  • Disc Spinner View.
  • Pitch Control.

These three custom user interfaces make up the custom DJ deck interface.

Applying adaptive layout to a custom view

Open DJControllerView.xib in the Xibs group.

You have some standard UIKit views that are laid out without using any adaptive layout. It’s your job to make the view’s layout adaptive for the different screen sizes and size classes based on the layout blueprint.

You already learned how to work with size classes and adaptive layouts in Chapter 10, “Adaptive Layout”. Before moving on to the implementation guide — and to practice and help solidifying your understanding — you’ll first implement the adaptive layout on the custom view on your own.

The next section walks you through the custom view’s adaptive layout implementations. It assumes you remember how to perform the individual tasks. Refer back to the earlier chapters if you need a refresher.

Laying out the stack view

First, you’ll handle the layout of the backlit buttons (three top-left buttons). Implement the following layout changes:

  1. Select the three backlit buttons from the top-left corner of the container view. Embed the views into a stack view.
  2. Set the stack view’s alignment equal to Fill. Set its distribution to Fill Equally and the spacing to 16.
  3. Set the stack view’s leading edge and top edge 16 points from the superview.
  4. Set a width-to-height aspect ratio constraint on the red backlit button with a multiplier of 1:1.5.

img

Here, you’re embedding the backlit buttons into a stack view. You then configure the stack view’s subviews to fill the container space with equal size and spacing distributions. Finally, you apply Auto Layout constraints onto the stack view and the red backlit buttons.

Laying out the pitch control

Next, you’ll work on the pitch control (top-right view). Implement the following layout changes to the pitch control:

  1. Leading edge equals 16 points from the stack view’s trailing edge.
  2. Top edge aligns to the stack view’s top edge.
  3. Trailing edge equals 16 points from the superview’s trailing edge.
  4. Height equals to the red backlit button’s height with a 1:1 ratio.
  5. Width equals to the red backlit button’s width with a 1:1 ratio.

img

With these changes, you apply equality constraints to the pitch control’s leading, top and trailing edges. You also apply width and height aspect ratio constraints between the pitch control and the red backlit button.

Laying out the disc spinner view

Finally, you’ll set up the disc spinner view (bottom view). Implement the following layout changes to the disc spinner view:

  1. Leading, bottom and trailing edges equal to 16 spaces to its superview’s respective edges.
  2. Top edge equals 16 spaces from the stack view’s bottom edge. Make sure the relationship is with the stack view and not the pitch control.

With these changes, you apply equality constraints onto the edges of the disc spinner view.

The DJ controller view’s layout should now match its design blueprint in portrait orientation; however, the custom view currently looks like this in landscape:

img

You have some more adaptive layout work to do. Now might be a great time to call on size classes for assistance.

Making user interface variations

To begin implementing user interface variations, you’ll need to first set your Interface Builder’s preview orientation to landscape so you’ll be able to see how things look in landscape.

Now, apply the following changes to the red backlit button:

  1. Select width to height aspect ratio constraint. Add any width compact heightvariation. Uncheck the added variation’s Installed checkbox.
  2. Create another width to height aspect ratio constraint. Set the multiplier to 1.5:1. Uncheck the Installed checkbox. Add an any width compact height variation. This time, check the Installed checkbox.

The first step ensures Auto Layout knows to uninstall the first aspect ratio constraint when the height is compact. The second step ensures Auto Layout knows to install a different aspect ratio constraint when the height is compact.

You’ll see the following layout on an iPhone 8 in landscape orientation:

img

For devices that fall into the regular height category, the aspect ratio constraint that Auto Layout installs is the constraint with the 1:1.5 width-to-height aspect ratio.

Tip: Using Interface Builder’s device preview, you can conveniently verify your size class layout assumptions on larger and smaller devices.

Next, select disc spinner view and apply the following layout changes:

  1. Select trailing alignment constraint. Add an any width compact height variation. Uncheck the added variation’s Installed checkbox.
  2. Align the trailing edge to the stack view’s trailing edge. Uncheck the Installedcheckbox. Add an any width compact height variation. Check the added variation’s Installed checkbox.

Here, you variate the disc spinner’s trailing constraints based on the available vertical space on the device.

You’ll see the following layout on an iPhone 8 in landscape orientation:

img

Finally, to complete the user interface variations, you’ll need to configure the pitch control.

Select pitch control, and apply the following layout changes:

  1. Select the equal heights constraint. Add an any width compact height variation. Uncheck the added variation’s Installed checkbox.
  2. Align the bottom edge to the disc spinner’s bottom edge. In the newly added constraint, uncheck the default Installed checkbox. Add an any width compact height variation. Ensure the added variation’s Installed checkbox is checked.

Your view will now look amazing and as intended by design.

img

Next, you’ll learn how to integrate your custom view designed in NIBs to storyboards.

Integrating a custom view from NIB to storyboard

Now that you’ve configured your custom view in the NIB, you’ll integrate it into the main storyboard.

Open Main.storyboard. You’ll see a blank canvas view controller.

Drag a UIView from the Object Library onto the view controller. Then, pin the view to the edges of its container.

img

Select the view. In the Inspectors panel, click Show the Identity inspector. Then, set the custom class to DJControllerView.

img

At the moment, there are no visible changes in the storyboard. As you can imagine, this is a sub-optimal solution for when you want to see how a custom view looks in the Interface Builder. After all, one of Interface Builder’s greatest strengths is giving you the ability to see the user interface without needing to always build and run.

But don’t worry. You can get this superpower back — even when you create a custom view using a NIB.

Next, you’ll learn to prepare your custom view for Interface Builder visualization.

Visualizing XIB/NIB files in storyboards

Build and run, and you’ll see an empty-looking view controller. The reason for this is because you need to initialize the NIB file inside of your custom control.

Open UIView+UINib.swift located inside the Extensions group. Add the following extension code:

extension UIView {
  func instantiateNib<T: UIView>(view: T) -> UIView {
    // 1
    let type = T.self
    // 2
    let nibName = String(describing: type)
    // 3
    let bundle = Bundle(for: type)
    // 4
    let nib = UINib(nibName: nibName, bundle: bundle)
    // 5
    guard let view = nib.instantiate(
      withOwner: self,
      options: nil).first as? UIView
      else { fatalError("Failed to instantiate: \(nibName)") }
    // 6
    return view
  }
}

With the extension method, you:

  1. Create a simple and reusable method to instantiate views from NIBs.
  2. Take the view’s type and convert it into a string.
  3. Initialize a bundle object using the view’s type.
  4. Construct a NIB object by passing in the NIB name and bundle into the respective parameters.
  5. Instantiate the NIB object and set the caller as the NIB object’s owner. You also safely extract the first object from the instantiated NIB file as a UIView.
  6. Return the instantiated view.

In DJControllerView.swift, located inside the Views group, add the following property:

private lazy var view = instantiateNib(view: self)

To initialize the view from the NIB, you declare a lazy variable. This ensures the property initializer runs after the self initialization.

Now, add following code to commonInit():

addSubview(view)
view.fillSuperview(self)

Here, you add the view initiated from the NIB file. You then use an Auto Layout extension method to make view fill the edges of the custom view.

Open Main.storyboard. You’ll see the following on an iPhone 8:

img

This is cool. But, there’s more customization you can do with Interface Builder and custom views.

Preparing custom views for Interface Builder

You can segregate the user interface in Interface Builder from runtime. In other words, you can customize your custom view to different user interfaces in Interface Builder and at runtime. You can use this feature, for example, to help other developers better understand your custom view.

Next, you’ll customize your custom view to have a label that’s indicative of the custom view’s class name.

Open UIView+InterfaceBuilder.swift. Add the following extension code:

extension UIView {
  func addLabelDescribing<T: UIView>(
    view: T,
    insideSuperview superview: UIView
  ) {
    // 1
    let viewDescriptionLabel = ViewDescriptionLabel()
    // 2
    viewDescriptionLabel.text = String(describing: T.self)
    // 3
    superview.addSubview(viewDescriptionLabel)
    // 4
    viewDescriptionLabel.center(superview)
  }
}

With the extension method, you:

  1. Initialize a custom label with default properties.
  2. Set the label’s text to the class name.
  3. Add the label onto the superview.
  4. Center the label in the superview using a helper method.

In DJControllerView, add the following method override:

override func prepareForInterfaceBuilder() {
  super.prepareForInterfaceBuilder()
  addLabelDescribing(view: self, insideSuperview: view)
}

After the view loads in Interface Builder, you inject the code to add a label describing the custom view in the Interface Builder.

Open Main.storyboard, and you’ll see the following:

img

That’s pretty cool, right? Next, you’ll learn to make a custom control from subclassing a standard control.

Making a custom UIButton

In this section, you’ll create another custom control by subclassing a standard UIKitcontrol. In particular, you’ll work with a UIButton.

A standard UIButton can sometimes feel boring. You’ll implement some custom animation based on the button’s user interaction event to spice things up!

Open BacklitButton.swift. Notice that the custom class already has the standard UIButton as a subclass.

Add the following animation methods:

// 1
private func shrinkAnimation() {
  let scale: CGFloat = 0.9
  UIView.animate(withDuration: 0.2) { [weak self] in
    self?.transform = CGAffineTransform(
      scaleX: scale, 
      y: scale)
  }
}
// 2
private func resetAnimation() {
  UIView.animate(withDuration: 0.2) { [weak self] in
      self?.transform = .identity
  }
}

The first animation method scales the view to 90 percent of its original size. The second animation method resets to the view’s original size.

Next, you need to implement the animation inside the custom button. Add the method overrides:

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

// 2
override func touchesEnded(
  _ touches: Set<UITouch>,
  with event: UIEvent?
) {
  super.touchesEnded(touches, with: event)
  resetAnimation()
}

override func touchesCancelled(
  _ touches: Set<UITouch>,
  with event: UIEvent?
) {
  super.touchesCancelled(touches, with: event)
  resetAnimation()
}

UIResponder is the superclass of UIView. For creating a custom view, UIResponder’s primary purpose is to handle control events. Here’s how the method overrides work:

  1. Upon detecting a touches began event, you trigger the shrink animation.
  2. Upon detecting a touches cancelled or ended event, you trigger the reset animation.

Build and run. When you tap a backlit button, you’ll see the shrink animation in effect:

img

When you release the button, you’ll see the button animate smoothly to its original form.

Next, you’ll build out the pitch control.

Making a custom UIControl

Standard UIKit controls inherit from UIControl. Standard controls include UIButton, UISegmentedControl, UITextField, UISlider, UISwitch, UIPageControl and more.

UIControl subclasses from UIView. In addition to the built-in UIViewfunctionalities, UIControl also provides touch tracking, control state, touch events handling functionalities as application programming interfaces for developers. In this section, you’ll learn to use UIControl’s functionalities to create your custom controls.

Particularly, you’ll create a knob control and a slider control, and you’ll place them inside DJControllerView. Depending on the size class, one of the two custom controls will show. The knob control will show for devices with compact heights, and a vertical slider will show on any larger devices.

Customizing size class variations

The knob will consist of only a static image with no user interactivity. Your job is to have it show up and hidden in the corresponding size classes.

Open DiscSpinnerView.swift. Uncomment view and the code inside of commonInit().

With this change, you add the NIB initiated view as a subview, and you pin the added view’s edges to the container view.

Open Main.storyboard, and you’ll see the following:

img

It’s time to make this control show or hide depending on the screen size.

Open PitchControl.swift. Replace view with the following:

private lazy var view = instantiateNib(view: self)

With this code, you replace a standard UIView with a custom view initiated from NIB.

Open PitchControl.xib, and do the following:

  1. From the document outline, select Pitch Knob Control.
  2. Open the Attributes inspector, and click the + button next to the Hiddencheckbox.
  3. Add and enable an any width and compact height variation.
  4. Add and enable a regular width and regular height variation.

The knob control shows or hides depending on the screen size. For devices such as the iPhone 11, the app will hide the pitch control only in landscape mode. For iPad devices, the app will show the pitch control in landscape orientation with exception of compact width split views.

Also, to avoid repeating a similar task, user interface variations for Slider Background Image View and Thumb Image View are implemented for you. Basically, it shows vertical slider user interfaces when the pitch control is hidden and vice versa.

Next, you’ll start to implement control features for the vertical slider.

Implementing control features for vertical slider

The vertical slider will have an interactive slidable thumb that enables users to adjust the control’s value. In addition, the vertical slider will also implement Accessibility features.

Open PitchControl.xib, if it’s not already open.

You have the slider background image view and the thumb image view. The Auto Layout constraint to focus on here is the top alignment equality constraint between the thumb image view’s top edge and the slider background image view’s top ledge. Later, you’ll use this constraint to position the thumb image view as the user slides the thumb up or down.

It’s time to set up the control’s data properties.

Setting up data properties

Your control will store various data properties to implement different business and presentation logic. First, add the following properties to PitchControl:

// 1
private let minValue: CGFloat = 0
private let maxValue: CGFloat = 10
// 2
private var value: CGFloat = 1 {
  didSet {
    print("Value:", value)
  }
}
// 3
private var previousTouchLocation = CGPoint()

With this code, you:

  1. Define the slider’s lower and upper bounds.
  2. Set the slider’s initial value to 1. Set a property observer to see the most up to date control’s value in the console log.
  3. Initialize a touch location variable to keep track and compare the user’s touch input later.

Next, add the following computed properties:

// 1
private var valueRange: CGFloat {
  return maxValue - minValue + 1
}
// 2
private var halfThumbImageViewHeight: CGFloat {
  return thumbImageView.bounds.height / 2
}
// 3
private var distancePerUnit: CGFloat {
  return (sliderBackgroundImageView.bounds.height / valueRange)
    - (halfThumbImageViewHeight / 2)
}

With these computations, you:

  1. Derive the control’s value range from the lower and upper bounds defined earlier.
  2. Halve the thumb image view’s bounds height. This is a convenient way to access a value that you’ll use more than once.
  3. Calculate the distance travel between value increment and decrement. This is used when you implement adjustable accessibility values.

Your properties are settled for tracking user touch inputs.

Next, you’ll make use of touch tracking handlers to implement movements on the thumb image view.

Implementing touch tracking handlers

The touch tracking handler methods derived from UIControl. You’ll now override the touch tracking handler methods to integrate custom logic into your project.

To begin, add the following method override:

override func beginTracking(
  _ touch: UITouch,
  with event: UIEvent?
) -> Bool {
  super.beginTracking(touch, with: event)
  // 1
  previousTouchLocation = touch.location(in: self)
  // 2
  let isTouchingThumbImageView = thumbImageView.frame
    .contains(previousTouchLocation)
  // 3
  thumbImageView.isHighlighted = isTouchingThumbImageView
  // 4
  return isTouchingThumbImageView
}

Here’s the code breakdown:

  1. Cache the touch location in previousTouchLocation. You’ll need this value when comparing touch locations later in continueTracking(_:with:).
  2. Check if the user’s touch input is on the thumb image view.
  3. Set the thumb image view’s highlight state to whether the user is touching the thumb image view.
  4. The decision to begin tracking user input is defined by whether the user is touching the thumb image view.

The image view’s highlight state provides visual aids to indicate that a control is currently being interacted with. By default, the control draws the highlight image when the property is true.

When the user is touching the thumb image view, the touch handling continues. Otherwise, the touch handling logic stops right there.

To continue handling user’s touch input logic, add the following method override:

override func continueTracking(
  _ touch: UITouch,
  with event: UIEvent?
) -> Bool {
  super.continueTracking(touch, with: event)
  // 1
  let touchLocation = touch.location(in: self)
  let deltaLocation = touchLocation.y
    - previousTouchLocation.y
  // 2
  let deltaValue = (maxValue - minValue)
    * deltaLocation / bounds.height
  // 3
  previousTouchLocation = touchLocation
}

Here’s the code breakdown:

  1. Compute the change in y position between the current and previous touch locations.
  2. Calculate the value change using the defined value bounds, position difference from touch locations and the control’s height. You’ll use this information to amend the control’s value.
  3. Cache the latest touch location.

Now, add the following code at the end of continueTracking(_:with:):

// 1
value = boundValue(
  value + deltaValue,
  toLowerValue: minValue,
  andUpperValue: maxValue)
// 2
let isTouchingBackgroundImage =
  sliderBackgroundImageView.frame
    .contains(previousTouchLocation)
if isTouchingBackgroundImage {
  thumbImageViewTopConstraint.constant =
    touchLocation.y - self.halfThumbImageViewHeight
}
// 3
return true

Here’s the code breakdown:

  1. Use a helper method to ensure the control’s value is within the upper and lower value bounds. Then, set the value accordingly.
  2. If the user’s touch input is within the frame of the slider’s background image view, then update the thumb image view’s top constraint constant to the current touch location minus half of the thumb image view’s height. You minus half of the thumb image view’s height to align the image view’s center with the touch location.
  3. Continue tracking unless your users release their fingers from the screen.

You use boundValue(_:toLowerValue:andUpperValue:) for edge cases such as when users slide their finger way above or below the control frame. In other words, you bound the value according to the value’s constraints. You then update the thumb image view’s top constraint constant. You’re actually almost done with the touch tracking implementation.

To handle the touch tracking’s end state, add the following method override:

override func endTracking(
  _ touch: UITouch?,
  with event: UIEvent?
) {
  super.endTracking(touch, with: event)
  thumbImageView.isHighlighted = false
}

Here, you reset the thumb image view’s highlight state to false. Also, the image on the thumb image view will adjust to the unhighlighted version.

Build and run.

img

Now, you’ll be able to:

  • Touch and move the thumb image view.
  • See the thumb image view’s highlight state changes as you touch, continue to touch and release your finger from the screen.
  • The thumb image view’s y position and the control’s value are constraints to define the upper and lower bounds.
  • See the control’s value change in the console log as you slide the thumb.

In the next section, you’ll implement an exciting and critical custom control feature: Accessibility.

Implementing accessibility features.

Especially on Apple’s platform, iOS users are accustomed to and expect Accessibility support, so your custom control should support Accessibility features. By implementing Accessibility features, you make your app usable by a larger audience. This section focuses on the implementation of Accessibility features and assumes that you have familiarity with using VoiceOver.

Note

If you’re unfamiliar with using VoiceOver, check out https://www.raywenderlich.com/6827616-ios-accessibility-getting-startedto get you on the right track.

Setting up the basics

At the moment, you won’t even be able to select the custom control when using the app in assistive mode. Making the control selectable is one thing, but you’ll also need to make the control’s purpose clear. The assistive users shouldn’t need to spend time figuring out what to do or how to use your custom control.

First, add the following code to PitchControl:

private func setupAccessibilityElements() {
  // 1
  isAccessibilityElement = true
  // 2
  accessibilityLabel = "Pitch"
  // 3
  accessibilityTraits = [.adjustable]
  // 4
  accessibilityHint = "Adjust pitch"
}

With this code, you:

  1. Make the custom control visible to assistive apps.
  2. Return a text that succinctly describes the control.
  3. Set the characteristic of the control to adjustable. As the property name suggests, this indicates that the slider control can be adjusted. To fully support an adjustable control, you’ll need to implement accessibilityIncrement() and accessibilityDecrement(). You’ll do this later.
  4. Indicate the result of performing an action on the control.

Now, add a call to this method at the end of commonInit():

setupAccessibilityElements()

Build and run. Turn on VoiceOver. At this point, you’ll be able to select the custom control, and you’ll hear the announcer announce the control’s accessibility label, characteristic and hint. Great, you’ve made the custom control available and clear to assistive users. However, your users have no way of controlling the value of the control. That’s a problem you need to fix!

Implementing value adjustability

Implementing value adjustability isn’t an easy task. When a VoiceOver user wants to change the value of your control, you’ll need to think about a value increment/decrement that fits your user’s criteria.

You first need to consider how the value increment/decrement works. It shouldn’t be too large where users can’t choose a specific value. At the same time, it can’t be too small where it’ll take too much of your user’s time to get the right value. It’s a give-and-take scenario, and the more of these you make, the better you’ll get at finding the right balance.

Add the following property to PitchControl:

private let valueIncrement: CGFloat = 1

You’ve defined the increment/decrement value for the pitch control. When a user adjusts the pitch control’s value, it’ll either increase/decrease value by one. One is a sweet spot as it lets the user change pitch quickly to reach the approximate desired value. At the same time, it allows for a broad enough selection of significant pitch values.

OK, time to get going with making your control adjustable. When an assistive user changes the value of your control, you’ll need the announcer to announce the control’s value accordingly. Add the following code:

override var accessibilityValue: String? {
  get {
    return "\(Int(value))"
  }
  set {
    super.accessibilityValue = newValue
  }
}

This code overrides the accessibility value property and sets what the assistive technology announcer will announce each time the control’s value changes. In this case, you’re using a value that’s closest to a whole integer number. Why? Because it can be difficult for your user when the announcer announces a number such as 7.1284769201. Also, the user likely doesn’t need or care to know about the decimal places.

Now, add the following value type to the bottom of the file:

fileprivate enum Direction {
  case up
  case down
}

You’ll use the value type added to communicate the user’s swipe gesture direction from the accessibility’s increment/decrement action.

Next, add the following method to PitchControl:

private func slideThumbInDirection(_ direction: Direction) {
  // 1
  let valueChange: CGFloat
  switch direction {
  case .up:
    valueChange = valueIncrement
  case .down:
    valueChange = valueIncrement * -1
  }
  // 2
  let newValue = value + valueChange
  if newValue < minValue {
    value = minValue
  } else if newValue > maxValue {
    value = maxValue
  } else {
    value = newValue
  }
  // 3
  thumbImageViewTopConstraint.constant =
    value * distancePerUnit
}

Here’s how the code works:

  1. Define the change in value in the positive or negative direction, depending on the swipe direction.
  2. With the new value deriving from current value and the change in value, bound the new value to the control’s value bounds.
  3. Move the thumb view vertically using the latest control’s value and the calculated distance for each control’s value unit.

Finally, to put the accessibility adjustable and value control logic in place, add the following methods:

override func accessibilityIncrement() {
  super.accessibilityIncrement()
  slideThumbInDirection(.down)
}

override func accessibilityDecrement() {
  super.accessibilityDecrement()
  slideThumbInDirection(.up)
}

With the accessibility’s increment/decrement methods, you pass in the swipe direction into slideThumbInDirection(_:). The logic you’ve previously implemented will take care of the intended features here.

Build and run on a physical device. With VoiceOver switched on and your pitch control selected, you can adjust the custom control’s value as you swipe up or down. The thumb view will move according to the value change from the swipes. Also, the control’s value is limited within the bounds you’ve set, and the user interface reflects it as well.

Key points

  • Custom controls allow you to create interactive user interfaces to your app’s specifications.
  • Creating custom controls is fun; however, standard controls are intuitive to iOS users and support out-the-box application features, so use standard controls over custom controls whenever possible.
  • Custom controls aren’t complete without adopting accessibility features.