How To Make a Custom Control Tutorial: A Reusable Slider

Controls are the bread and butter of iOS apps. UIKit provides many but this tutorial shows you how to make an iOS custom control in Swift.

Version

  • Swift 4.2, iOS 12, Xcode 10
Update note: Lea Marolt Sonnenschein updated this tutorial for iOS 12, Xcode 10 and Swift 4.2. Colin Eberhardt wrote the original.

User interface controls are one of the most important building blocks of any application. They serve as the graphical components that allow users to view and interact with your application. Apple supplies a set of controls, such as UITextField, UIButton and UISwitch. Armed with this toolbox of pre-existing controls, you can create a great variety of user interfaces.

However, sometimes you need to do something a little bit different; something that the stock controls can’t handle quite the way you want.

An iOS custom control is nothing more than a control that you have created yourself. Custom controls, like standard controls, should be generic and versatile. You’ll find there’s an active and vibrant community of developers who love to share their iOS custom control creations provided they’re both of these things.

In this tutorial, you’ll implement a RangeSlider iOS custom control. This control is like a double-ended slider that lets you pick both a minimum and maximum value. You’ll touch on such concepts as extending existing controls, designing and implementing your control’s API and even how to share your new control with the development community.

Time to start customizing!

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project.

Say you’re developing an application for searching property-for-sale listings. This fictional application allows the user to filter search results so that they fall within a certain price range.

You could provide an interface that presents the user with a pair of UISlider controls, one for setting the maximum price and one for setting the minimum price. However, this interface doesn’t help the user visualize the price range. It would be much better to present a single slider with two thumbs to indicate the high and low price range for their search criteria.

good vs bad slider design

You could build this range slider by subclassing UIView and creating a bespoke view for visualizing price ranges. That would be fine for the context of your app — but it would be a struggle to port it to other apps.

It’s a much better idea to make this new component as generic as possible so that you can reuse it later. This is the very essence of custom controls.

The first decision you need to make when creating an iOS custom control is which existing class to subclass or extend to make the new control.

Your class must be a UIView subclass for it to be available in the application’s UI.

If you check Apple’s UIKit reference, you’ll see that a number of the framework controls such as UILabel and UIWebView subclass UIView directly. However, there are a handful, such as UIButton and UISwitch which subclass UIControl, as shown in the hierarchy below:

ios uiview and uicontrol class

Note: For a complete class hierarchy of UI components, check out the UIKit Framework Reference.

In this tutorial, you’ll subclass UIControl.

Open the starter project in Xcode. You’ll put your slider control code in RangeSlider.swift. Before you write any code for your control, though, add it to the view controller so that you can watch its evolution.

Open ViewController.swift and replace its contents with the following:

import UIKit

class ViewController: UIViewController {
  let rangeSlider = RangeSlider(frame: .zero)
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    rangeSlider.backgroundColor = .red
    view.addSubview(rangeSlider)
  }
  
  override func viewDidLayoutSubviews() {
    let margin: CGFloat = 20
    let width = view.bounds.width - 2 * margin
    let height: CGFloat = 30
    
    rangeSlider.frame = CGRect(x: 0, y: 0,
                               width: width, height: height)
    rangeSlider.center = view.center
  }
}

This code creates an instance of your control in the given frame and adds it to the view. It also sets the background color to red so that the control is visible on the screen.

Build and run your app. You should see something similar to the following:


Screenshot #1, Slider is a red rectangle.

Before you add the visual elements to your control, you’ll need a few properties to keep track of the state of your control. This will form the start of your control’s Application Programming Interface, or API for short.

Note: Your control’s API defines the methods and properties that you decide to expose to the other developers who will use your control.

Adding Default Control Properties

Open RangeSlider.swift and replace the code with the following:

import UIKit

class RangeSlider: UIControl {
  var minimumValue: CGFloat = 0
  var maximumValue: CGFloat = 1
  var lowerValue: CGFloat = 0.2
  var upperValue: CGFloat = 0.8
}

These four properties are all you need to describe the state of this control. You provide the maximum and minimum values for the range, along with the upper and lower values set by the user.

Well-designed controls should define some default property values or else your control will look strange when it draws on the screen.

Now it’s time to work on the interactive elements of your control: namely, the thumbs to represent the high and low values and the track on which the thumbs will slide.

CoreGraphics vs. Images

There are two primary ways that you can render controls on-screen:

  1. CoreGraphics: Render your control using a combination of layers and CoreGraphics.
  2. Images: Create images that represent the various elements of your control.

There are pros and cons to each technique, as outlined below:

  • Core Graphics: Constructing your control using Core Graphics means that you have to write the rendering code yourself, which requires more effort. However, this technique allows you to create a more flexible API.

    Using Core Graphics, you can parameterize every feature of your control, such as colors, border thickness, and curvature — pretty much every visual element that goes into drawing your control!

  • Images: Constructing your control using images is probably the simplest option for authoring the control. If you want other developers to be able to change the look and feel of your control, you need to expose these images as UIImage properties.

    Using images provides the most flexibility to developers who will use your control. Developers can change every pixel and every detail of your control’s appearance, but this requires exceptional graphic design skills — and it’s difficult to modify the control from code.

In this tutorial, you’ll use a bit of both. You’ll use images to render the thumbs and CoreGraphics to render the track layer.

Note: Interestingly, Apple also tends to opt for using images in their controls. This is most likely because they know the size of each control and don’t tend to want to allow too much customization. After all, they want all apps to end up with a similar look-and-feel.

Adding thumbs to your custom control

Open RangeSlider.swift and add the following properties, just after the ones you defined above:

var thumbImage = #imageLiteral(resourceName: "Oval")

private let trackLayer = CALayer()
private let lowerThumbImageView = UIImageView()
private let upperThumbImageView = UIImageView()

The trackLayer, lowerThumbImageView and upperThumbImageView are used to render the various components of your slider control.

Still in RangeSlider, add an initializer:

override init(frame: CGRect) {
  super.init(frame: frame)
  
  trackLayer.backgroundColor = UIColor.blue.cgColor
  layer.addSublayer(trackLayer)
  
  lowerThumbImageView.image = thumbImage
  addSubview(lowerThumbImageView)
  
  upperThumbImageView.image = thumbImage
  addSubview(upperThumbImageView)
}

required init?(coder aDecoder: NSCoder) {
  fatalError("init(coder:) has not been implemented")
}

This initializer adds the layer and the views to the control.

To see the added elements, you’ll need to set their frames. Add the following code after the initializers:

// 1
private func updateLayerFrames() {
  trackLayer.frame = bounds.insetBy(dx: 0.0, dy: bounds.height / 3)
  trackLayer.setNeedsDisplay()
  lowerThumbImageView.frame = CGRect(origin: thumbOriginForValue(lowerValue),
                                     size: thumbImage.size)
  upperThumbImageView.frame = CGRect(origin: thumbOriginForValue(upperValue),
                                     size: thumbImage.size)
}
// 2
func positionForValue(_ value: CGFloat) -> CGFloat {
  return bounds.width * value
}
// 3
private func thumbOriginForValue(_ value: CGFloat) -> CGPoint {
  let x = positionForValue(value) - thumbImage.size.width / 2.0
  return CGPoint(x: x, y: (bounds.height - thumbImage.size.height) / 2.0)
}

Here’s what’s going on in these methods:

  1. In this first method, you center the trackLayer and calculate the thumbs’ positions using thumbOriginForValue(_:).
  2. This method scales the given value to the bound’s context.
  3. Lastly, thumbOriginForValue(_:) returns the position so that the thumb is centered given the scaled value.

Add the following code to the end of init(frame:) to invoke your update method:

updateLayerFrames()

Next, override frame and implement a property observer by adding the following to the top of the class:

override var frame: CGRect {
  didSet {
    updateLayerFrames()
  }
}

The property observer updates the layer frames when the frame changes. This is necessary when the control is initialized with a frame that’s not its final frame like in ViewController.swift.

Build and run your app. Your slider is starting to take shape!


ScreenShot #2 - Slider Layout

Red is the background color of the entire control; Blue is the track color for the slider; And the blue circles are the two thumbs for the upper and lower values.

Your control is starting to take shape visually, but you can’t interact with it!

For your control, the user must be able to drag each thumb to set the desired range of the control. You’ll handle those interactions and update both the UI and the properties exposed by the control.

Adding Touch Handlers

Open RangeSlider.swift and add the following property along with the others:

private var previousLocation = CGPoint()

You use this property to track the touch locations.

How are you going to track the various touch and release events of your control?

UIControl provides several methods for tracking touches. Subclasses of UIControl can override those methods to add their own interaction logic.

In your custom control, you’ll override three key methods of UIControl: beginTracking(_:with:), continueTracking(_:with:) and endTracking(_:with:).

Add the following code to the end of RangeSlider.swift file:


extension RangeSlider {
  override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    // 1
    previousLocation = touch.location(in: self)
    
    // 2
    if lowerThumbImageView.frame.contains(previousLocation) {
      lowerThumbImageView.isHighlighted = true
    } else if upperThumbImageView.frame.contains(previousLocation) {
      upperThumbImageView.isHighlighted = true
    }
    
    // 3
    return lowerThumbImageView.isHighlighted || upperThumbImageView.isHighlighted
  }
}

iOS invokes this method when the user first touches the control. Here’s how it works:

  1. First, it translates the touch event into the control’s coordinate space.
  2. Next, it checks each thumb view to see whether the touch was within its frame.
  3. The return value informs the UIControl superclass whether subsequent touches should be tracked. Tracking touch events continues if either thumb is highlighted.

Now that you have the initial touch event, you’ll need to handle the events as the user’s finger moves across the screen.

Add the following methods after beginTracking(_:with:):

override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
  let location = touch.location(in: self)
  
  // 1
  let deltaLocation = location.x - previousLocation.x
  let deltaValue = (maximumValue - minimumValue) * deltaLocation / bounds.width
  
  previousLocation = location
  
  // 2
  if lowerThumbImageView.isHighlighted {
    lowerValue += deltaValue
    lowerValue = boundValue(lowerValue, toLowerValue: minimumValue,
                            upperValue: upperValue)
  } else if upperThumbImageView.isHighlighted {
    upperValue += deltaValue
    upperValue = boundValue(upperValue, toLowerValue: lowerValue,
                            upperValue: maximumValue)
  }
  
  // 3
  CATransaction.begin()
  CATransaction.setDisableActions(true)
  
  updateLayerFrames()
  
  CATransaction.commit()
  
  return true
}

// 4
private func boundValue(_ value: CGFloat, toLowerValue lowerValue: CGFloat, 
                        upperValue: CGFloat) -> CGFloat {
  return min(max(value, lowerValue), upperValue)
}

Here’s the code breakdown:

  1. First, you calculate a delta location, which determines the number of points the user’s finger traveled. You then convert it into a scaled delta value based on the minimum and maximum values of the control.
  2. Here, you adjust the upper or lower values based on where the user drags the slider to.
  3. This section sets the disabledActions flag inside a CATransaction. This ensures that the changes to the frame for each layer are applied immediately, and not animated. Finally, updateLayerFrames is called to move the thumbs to the correct location.
  4. boundValue(_:toLowerValue:upperValue:) clamps the passed in value so it’s within the specified range. Using this helper function is easier to read than a nested min/max call.

You’ve coded the dragging of the slider, but you still need to handle the end of the touch and drag events.

Add the following method after continueTracking(_:with:):

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

This code resets both thumbs to a non-highlighted state.

Build and run your project, and play around with your shiny new slider! You should be able to drag the thumbs around.


ScreenShot #3 - Interactive

You’ll notice that when the slider is tracking touches, you can drag your finger beyond the bounds of the control, then back within the control without losing your tracking action. This is an important usability feature for small screen devices with low precision pointing devices — or as they’re more commonly known, fingers! :]

Notifying Changes

You now have an interactive control the user can manipulate to set upper and lower bounds. But how do you communicate these change notifications to the calling app so that the app knows the control has new values?

There are a number of different patterns you can implement to provide change notification:NSNotification, Key-value observing (KVO), the delegate pattern, the target-action pattern and many others. So many choices!

So, what to do?

If you look at the UIKit controls, you’ll find they don’t use NSNotification or encourage the use of KVO, so for consistency with UIKit you can exclude those two options. The other two patterns — delegates and target-action patterns — are used extensively in UIKit.

Here’s a detailed analysis of the delegate and the target-action patterns:

Delegate pattern: With the delegate pattern, you provide a protocol which contains methods that are used for a range of notifications. The control has a property, usually named delegate, which accepts any class that implements this protocol. A classic example of this is UITableView which provides the UITableViewDelegate protocol. Note that those controls only accept a single delegate instance. A delegate method can take any number of parameters so you can pass in as much information as you desire to such methods.

Target-action pattern: The UIControl base class provides the target-action pattern. When a change in control state occurs, the target is notified of the event which is described by one of the UIControlEvents enum values. You can provide multiple targets to control actions and, while it’s possible to create custom events (see UIControlEventApplicationReserved), you’re limited to no more than four custom events. Control actions cannot send any information with the event, so they can’t be used to pass extra information when the event is fired.

The key differences between the two patterns are as follows:

  • Multicast: The target-action pattern multicasts its change notifications, while the delegate pattern is bound to a single delegate instance.
  • Flexibility: You define the protocols yourself in the delegate pattern, meaning you can control exactly how much information you pass. Target-action provides no way to pass extra information and clients have to look it up themselves after receiving the event.

Your range slider control doesn’t have a large number of state changes or interactions that you need to provide notifications for. The only things that change are the upper and lower values of the control.

In this situation, the target-action pattern makes perfect sense. This is one of the reasons why you subclassed UIControl at the start of this iOS custom control tutorial.

Aha! It’s making sense now! :]

The slider values are updated inside continueTracking(_:with:), so this is where you need to add the notification code.

Open RangeSlider.swift, locate continueTracking(_:with:) and add the following just before the return true statement:

sendActions(for: .valueChanged)

That’s all you need to do to notify any subscribed targets of the changes.

Now that you have your notification handling in place, you should hook it up to your app.

Open ViewController.swift and add the following method to the bottom of the class:

@objc func rangeSliderValueChanged(_ rangeSlider: RangeSlider) {
  let values = "(\(rangeSlider.lowerValue) \(rangeSlider.upperValue))"
  print("Range slider value changed: \(values)")
}

This method logs the range slider values to the console as proof that your control is sending notifications as planned.

Now, add the following code to the end of viewDidLoad():

rangeSlider.addTarget(self, action: #selector(rangeSliderValueChanged(_:)),
                      for: .valueChanged)

This invokes rangeSliderValueChanged(_:) each time the range slider sends the valueChanged event.

Build and run your app, and move the sliders back and forth. You’ll see the control’s values in the console, similar to this:

Range slider value changed: (0.117670682730924 0.390361445783134)
Range slider value changed: (0.117670682730924 0.38835341365462)
Range slider value changed: (0.117670682730924 0.382329317269078)

You’re probably sick of looking at the multi-colored range slider UI by now. It looks like an angry fruit salad! It’s time to give the control a much-needed facelift.

Modifying Your Control With Core Graphics

First, you’ll update the graphics of the track that the slider thumbs move along.

In RangeSliderTrackLayer.swift, replace the code with the following:

import UIKit

class RangeSliderTrackLayer: CALayer {
  weak var rangeSlider: RangeSlider?
}

This code adds a reference back to the RangeSlider. Since the slider owns the track, the back reference is a weak variable to avoid a retain cycle.

Open RangeSlider.swift, locate the trackLayer property and modify it to be an instance of the new layer class:

private let trackLayer = RangeSliderTrackLayer()

Now, find init(frame:) and replace it with the following:

override init(frame: CGRect) {
  super.init(frame: frame)
  
  trackLayer.rangeSlider = self
  trackLayer.contentsScale = UIScreen.main.scale
  layer.addSublayer(trackLayer)
  
  lowerThumbImageView.image = thumbImage
  addSubview(lowerThumbImageView)
  
  upperThumbImageView.image = thumbImage
  addSubview(upperThumbImageView)    
}

The code above ensures that the new track layer has a reference to the range slider and removes the default background color. Setting the contentsScale factor to match that of the device’s screen ensures everything is crisp on retina displays.

There’s just one more bit: removing the red background of the control.

Open ViewController.swift, locate the following line in viewDidLoad() and remove it:

rangeSlider.backgroundColor = .red

Build and run now. What do you see?


Screenshot #4, Floating thumbs

Floating thumbs? Great!

Don’t fret — you’ve just removed the gaudy test colors. Your controls are still there, but now you have a blank canvas to dress it up.

Since most developers like it when controls can be configured to emulate the look and feel of the particular app they are coding, you’ll add some properties to the slider to allow customization of the look of the control.

Open RangeSlider.swift and add the following properties just beneath the upperValue property:

var trackTintColor = UIColor(white: 0.9, alpha: 1)
var trackHighlightTintColor = UIColor(red: 0, green: 0.45, blue: 0.94, alpha: 1)

Next, open RangeSliderTrackLayer.swift.

This layer renders the track on which the two thumbs slide. It currently inherits from CALayer, which only renders a solid color.

To draw the track, you need to implement draw(in:) and use the Core Graphics APIs to perform the rendering.

Note: To learn about Core Graphics in depth, I highly recommend the Core Graphics 101 tutorial series from this site, as exploring Core Graphics is out of scope for this tutorial.

Add the following method to RangeSliderTrackLayer:

override func draw(in ctx: CGContext) {
  guard let slider = rangeSlider else {
    return
  }
  
  let path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
  ctx.addPath(path.cgPath)
  
  ctx.setFillColor(slider.trackTintColor.cgColor)
  ctx.fillPath()
  
  ctx.setFillColor(slider.trackHighlightTintColor.cgColor)
  let lowerValuePosition = slider.positionForValue(slider.lowerValue)
  let upperValuePosition = slider.positionForValue(slider.upperValue)
  let rect = CGRect(x: lowerValuePosition, y: 0,
                    width: upperValuePosition - lowerValuePosition,
                    height: bounds.height)
  ctx.fill(rect)
}

Once the track shape is clipped, you fill in the background. After that, you fill in the highlighted range.

Build and run to see your new track layer rendered in all its glory! It’ll look like this:


Screenshot #5, Track layer filled in.

Handling Changes to Control Properties

The control looks pretty snazzy now; The visual styling is versatile, and it supports target-action notifications.

It sounds like you’re done — or are you?

Think for a moment about what happens if one of the range slider properties is set in code after it’s been rendered. For example, you might want to change the slider range to some preset value or change the track highlight to indicate a valid range.

Currently, nothing is observing the property setters. You’ll need to add that functionality to your control. You need to implement property observers that update the control’s frame or drawing.

Open RangeSlider.swift and change the property declarations of the following properties like this:

var minimumValue: CGFloat = 0 {
  didSet {
    updateLayerFrames()
  }
}

var maximumValue: CGFloat = 1 {
  didSet {
    updateLayerFrames()
  }
}

var lowerValue: CGFloat = 0.2 {
  didSet {
    updateLayerFrames()
  }
}

var upperValue: CGFloat = 0.8 {
  didSet {
    updateLayerFrames()
  }
}

var trackTintColor = UIColor(white: 0.9, alpha: 1) {
  didSet {
    trackLayer.setNeedsDisplay()
  }
}

var trackHighlightTintColor = UIColor(red: 0, green: 0.45, blue: 0.94, alpha: 1) {
  didSet {
    trackLayer.setNeedsDisplay()
  }
}

var thumbImage = #imageLiteral(resourceName: "Oval") {
  didSet {
    upperThumbImageView.image = thumbImage
    lowerThumbImageView.image = thumbImage
    updateLayerFrames()
  }
}

var highlightedThumbImage = #imageLiteral(resourceName: "HighlightedOval") {
  didSet {
    upperThumbImageView.highlightedImage = highlightedThumbImage
    lowerThumbImageView.highlightedImage = highlightedThumbImage
    updateLayerFrames()
  }
}

You call setNeedsDisplay() for changes to the track layer, and you call updateLayerFrames() for every other change. When you change the thumbImage or the highlightedThumbImage you also change the property on their respective image views.

You also added a new property! highlightedThumbImage shows when the thumb image view is highlighted. It helps to give the user more feedback on how they’re interacting with the control.

Now, find updateLayerFrames() and add the following to the top of the method:

CATransaction.begin()
CATransaction.setDisableActions(true)

Add the following to the very bottom of the method:

CATransaction.commit()

This code wraps the entire frame update into one transaction to make the re-flow rendering smooth. It also disables implicit animations on the layer, just like you did before, so the layer frames are updated immediately.

Since you’re now updating the frames automatically every time the upper and lower values change, find the following code in continueTracking(_:with:) and delete it:

// 3
CATransaction.begin()
CATransaction.setDisableActions(true)

updateLayerFrames()

CATransaction.commit()

That’s all you need to do to make the range slider react to property changes.

However, you now need a bit more code to test your new property observers and make sure everything is hooked up and working as expected.

Open ViewController.swift and add the following code to the end of viewDidLoad():

let time = DispatchTime.now() + 1
DispatchQueue.main.asyncAfter(deadline: time) {
    self.rangeSlider.trackHighlightTintColor = .red
    self.rangeSlider.thumbImage = #imageLiteral(resourceName: "RectThumb")
    self.rangeSlider.highlightedThumbImage = 
      #imageLiteral(resourceName: "HighlightedRect")
}

This updates some of the control’s properties after a one second pause. It also changes the track highlight color to red and the thumb images to a rectangle.

Build and run your project. After a second, you’ll see the range slider change from this:

Blue Slider with Oval Thumbs

To this:

Red Slider with Rect Thumbs

Pretty cool, aye? :]

Where to Go From Here?

Your range slider is now fully functional and ready to use within your own applications! You can download the final version of the project using the Download Materials button at the top or bottom of this tutorial.

However, one of the key benefits of creating a generic iOS custom control is that you can share it across projects — and share it with other developers.

Is your control ready for prime time?

Not just yet. Here are a few other points to consider before sharing your custom controls:

Documentation – Every developer’s favorite job! :] While you might like to think your code is beautifully crafted and self-documenting, other developers will no doubt disagree. A good practice is to provide public API documentation, at a minimum, for all publicly shared code. This means documenting all public classes and properties.

For example, your RangeSlider needs documentation to explain what it is — a slider which is defined by four properties: minimumValue, maximumValue, lowerValue, and upperValue — and what it does — allows a user to visually define a range of numbers.

Robustness – What happens if you set the upperValue to a value greater than the maximumValue? Surely you would never do that yourself, but you can guarantee that someone eventually will! You need to ensure that the control state always remains valid — despite what some silly coder tries to do to it.

API Design – The previous point about robustness touches on a much broader topic — API design. Creating a flexible, intuitive and robust API ensures that your control can be widely used (and wildly popular).

API design is a topic of great depth and one which is out of scope for this tutorial. If you are interested, Matt Gemmell’s 25 rules of API design comes highly recommended.

There are many places to start sharing your custom controls with the world. Here are few suggestions of places to start:

  • GitHub – One of the most popular places to share open source projects. There are already numerous custom controls for iOS on GitHub. It allows people to easily access your code and collaborate by forking your code for other controls or raising issues on your existing controls.
  • CocoaPods – To allow people to easily add your control to their projects, you can share it via CocoaPods, which is a dependency manager for iOS and macOS projects.
  • Cocoa Controls – This site provides a directory of both commercial and open source controls. Many of the open source controls covered by Cocoa Controls are hosted on GitHub, and it’s a great way of promoting your creation.

Hopefully, you’ve had fun creating this range slider control, and perhaps you have been inspired to create a custom control of your own. If you do, please share it in the comments thread for this article — we’d love to see your creations!

Contributors

Comments