How to implement custom UISlider with swift. part 2

subclassing uicontrol

This post is a continuation of previous article, where we customized our UISlider using images. In this tutorial we will dive deeper into customization. Honestly, we are about to create our own UIControl. Which of course is a parent class for UISlider.

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

Right-click the project name in Project navigator area and select New File / iOS / Source / Coca Touch Class template and click Next. Call the class as you want, I will name my as TicksSlider. Enter UIControl into the subclass of field and make sure that language is Swift. Click Next and Create.

Ok, let's first make some preparations to be able to see our changes when we build and run. (Or download starter project here.)

 If you completed previous tutorial:

  1. Remove your slider from story board as well as outlet and action references in code.
  2. Remove all property changes to slider instance in viewDidLoad()

Instead add instance of our newly created TicksSlider and add it to view like this:

import UIKit

class ViewController: UIViewController {
    
    @IBOutlet weak var label: UILabel!
    let ticksSlider = TicksSlider(frame: CGRectZero)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        label.textColor = UIColor.darkTextColor()
        ticksSlider.backgroundColor = UIColor.lightGrayColor()
        view.addSubview(ticksSlider)
    }
    
    override func viewDidLayoutSubviews() {
        let margin: CGFloat = 20.0
        let width = view.bounds.width - 2 * margin
        ticksSlider.frame = CGRect(x: margin, y: 3 * margin, width: width, height: 1.5 * margin)
    }
}

The above code simply creates an instance of our new control and adds it to the main view. I set background color for it so it will be visible against app's background.

Go, build and run your app; you should see something like this:

how-to-customize-uislider-with-swift-coregraphics-init.png

Let's take a look of what we are about to build:

I have decided to use CoreGraphics instead of images because you never know what the minimum and maximum values are and how many ticks to draw.

Before you add visual elements to your slider, you will need a few properties to track various pieces of information that are stored in your control. Also you will need couple of layer to render the components of your slider. Open up TicksSlider.swift file and replace the code with the following lines: 

import UIKit

class TicksSlider: UIControl {
    var minimumValue = 0.0
    var maximumValue = 10.0
    var value = 7.0
    
    let trackLayer = CALayer()
    var trackHight:CGFloat = 2.0
    var trackColor = UIColor.blackColor().CGColor
    
    var tickHight:CGFloat = 8.0
    var tickWidth: CGFloat = 2.0
    var tickColor = UIColor.blackColor().CGColor
    
    let thumbLayer = CALayer()
    var thumbColor = UIColor.blackColor().CGColor
    var thumbMargin:CGFloat = 2.0
    var thumbWidth: CGFloat {
        return CGFloat(bounds.height)
    }
}

The value properties are all you need to describe the current state of slider. Layers will be used to render the various components of your control. Different colors, width and margin will be used to layout elements.

Next, let's setup some graphical properties.

Inside your TicksSlider class define an initializer and the following functions:

required init(coder: NSCoder) {
  super.init(coder: coder)
}

override init(frame: CGRect) {
  super.init(frame: frame)
 
  trackLayer.backgroundColor = trackColor
  layer.addSublayer(trackLayer)
  
  thumbLayer.backgroundColor = thumbColor
  layer.addSublayer(thumbLayer)

  updateFrames()
}
    
func updateFrames() {
  trackLayer.frame = CGRect(x: 0, y: tickHight - trackHight, width: bounds.width, height: trackHight)
  trackLayer.setNeedsDisplay()

  let thumbCenter = CGPoint(x: CGFloat(value) * (bounds.width / CGFloat(maximumValue)), y: bounds.midY)
  thumbLayer.frame = CGRect(x: thumbCenter.x - thumbWidth / 2, y: tickHight + thumbMargin , width: thumbWidth, height: thumbWidth)
  thumbLayer.setNeedsDisplay()
}

Initializer creates two layers and adds them to the main slider's layer. updateFrames() just sets layers frames to proper position and size.

The only thing left to do so you can see actual changes is to override frame, and implement a property observer. Add the following to TicksSlider.swift

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

Ok, we have something:

how-to-customize-uislider-swift-coregraphics-2

Our slider is starting to take shape. Remember, gray is just a background of slider, black line is a track and black square is a thumb. I arranged thumb like this because it is going to be a triangle pointing to the current value. Let's give a user an option to interact with our control.

ADDING TOUCH AND DRAG HANDLERS

The interaction logic needs to know user taps our thumbLayer, so our UI can reflect it.

Create a new Coca Touch Class and call it TSThumbLayer which is a subclass of CALayer.

Add boolean variable to the newly created class to track if thumb is highlighted and back reference to our slider.

import UIKit

class TSThumbLayer: CALayer {
    var highlighted = false
    weak var ticksSlider : TicksSlider?
}

Go back to TicksSlider.swift and change type of the thumbLayer to TSThumbLayer

let thumbLayer = TSThumbLayer()

Still in TicksSlider.swift, find init() and insert the following line:

thumbLayer.ticksSlider = self

The above code sets the thumbLayer's ticksSlider property to populate the back reference to self.

Open TicksSlider.swift and add one more property to track touch locations.

var previousLocation = CGPoint()

Preparations for thumbLayer are done and we are ready to add touch handlers.

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

Yes, that is exactly what we will do now. We will override three methods of UIControl: 

  1. beginTrackingWithTouch
  2. continueTrackingWithTouch
  3. endTrackingWithTouch

So beginTrackingWithTouch method:

override func beginTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool {
  previousLocation = touch.locationInView(self)      
  if thumbLayer.frame.contains(previousLocation) {
    thumbLayer.highlighted = true
  }     
  return thumbLayer.highlighted
}

This method invoked when user first touches the slider.

Inside this method we translate the touch into slider's coordinate system. Next we check if touch is inside the thumb and set thumbLayer's property to true. In return we informs the UIControl whether touches should be tracked or not.

Tracking touch events continues while thumb is highlighted.

override func continueTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool {
  let location = touch.locationInView(self)
        
  // Track how much user has dragged
  let deltaLocation = Double(location.x - previousLocation.x)
  let deltaValue = (maximumValue - minimumValue) * deltaLocation / Double(bounds.width - thumbLayer.frame.width)
        
  previousLocation = location
        
  // update value
  if thumbLayer.highlighted {
    value += deltaValue
    value = clipValue(value)
  }
  // update the UI
  CATransaction.begin()
  CATransaction.setDisableActions(true)
  updateFrames()
  CATransaction.commit()
        
  return thumbLayer.highlighted
}
    
func clipValue(value: Double) -> Double {
  return min(max(value, minimumValue), maximumValue)
}

clipValue will keep passed value in range, so our thumb will not be placed outside the track.

Inside continueTrackWithTouch we calculate how much pixels user dragged our thumb along the track. Then we update value based on where user drags the slider to. We also sets the disableActions flag inside CATransaction. This is to ensure that the changes are applied immediately, and not animated. updateFrames is called to relocate thumb to correct location.

To complete set of touch handlers you need one more method.

override func endTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) {
  thumbLayer.highlighted = false
}

This simply resets thumb to non-highlighted state.

Go build and run your project. You should be able to drag the thumb and change value.

We can drag thumb but what about tracking value? How the app knows the controls has new value?

The slider value are updated inside continueTrackingWithTouch method, we will add notification code here. It is actually one line just before "return true" statement.

sendActionsForControlEvents(.ValueChanged)

Go to ViewController.swift and add this line to the end of viewDidLoad()

ticksSlider.addTarget(self, action: "sliderValueDidChanged:", forControlEvents: .ValueChanged)

and this simple method to ViewController.swift, so we can see live value of our slider

func sliderValueDidChanged(slider: TicksSlider) {
  label.text = "\(slider.value)"
}

Build and run your project. Try to move thumb back and forth, label is displaying live value changes isn't it?

how-to-customize-uislider-with-swift-coregraphics-mid.png

If not, here is a project up to his point.

Our slider works pretty well, but it looks quite raw. It's time to draw!

core graphics way

First let's draw our thumb.

Open up TicksSlider.swift and make sure that thumbLayer is an instance of TSThumbLayer

let thumbLayer = TSThumbLayer()

Find init() and make the following changes:

override init(frame: CGRect) {
        super.init(frame: frame)
        
        trackLayer.backgroundColor = trackColor
        layer.addSublayer(trackLayer)
        
        thumbLayer.ticksSlider = self
        thumbLayer.contentsScale = UIScreen.mainScreen().scale
        layer.addSublayer(thumbLayer)
        
        updateFrames()
}

We just replaced background color for thumbLayer to contentScale factor to match that of the device's screen everything is sharp on retina displays and removed background color. We will do custom drawing instead.

Find this line in your ViewController.swift inside viewDidLoad() and remove it: 

ticksSlider.backgroundColor = UIColor.lightGrayColor()

In order to make programmable drawing to will override drawInContext method and use CoreGraphics API to perform rendering.

Open up your TSThumbLayer.swift and let's override drawInContext:

override func drawInContext(ctx: CGContext!) {
  if let slider = ticksSlider {
    // Color Declarations
    let color = UIColor.blackColor()
    
     // Triangle Drawing
     var trianglePath = UIBezierPath()
     trianglePath.moveToPoint(CGPoint(x: bounds.midX, y: bounds.minY))
     trianglePath.addLineToPoint(CGPoint(x: bounds.maxX, y: bounds.maxY / 2 ))
     trianglePath.addLineToPoint(CGPoint(x: bounds.minX, y: bounds.maxY / 2))
     trianglePath.addLineToPoint(CGPoint(x: bounds.midX, y: bounds.minY))
     trianglePath.closePath()
     trianglePath.miterLimit = 4;
     CGContextAddPath(ctx, trianglePath.CGPath)
            
     // fill the triangle
     CGContextSetFillColorWithColor(ctx, slider.thumbColor)
     CGContextAddPath(ctx, trianglePath.CGPath)
     CGContextFillPath(ctx)
  }
}

The code above is simple drawing if you curious about how Core Graphics API works in more details here is a link, it a bit out if the scope for this tutorial.

Build and run your project! You should see your code rendered triangle thumb : )

But don't forget, track is actually just a background color. So let's fix it! 

As before, create a subclass of CALayer and call it TSTrackLayer Next open up you new shiny TSTrackLayer.swift file and add back reference to our slider:

import UIKit

class TSTrackLayer: CALayer {
    weak var ticksSlider: TicksSlider?
}

Go to TicksSlider.swift, locate trackLayer and replace it's instance to be TSTrackLayer:

let trackLayer = TSTrackLayer()

Again in init method still inside TicksSlider.swift make the following changes:

override init(frame: CGRect) {
    super.init(frame: frame)

    trackLayer.ticksSlider = self
    trackLayer.contentsScale = UIScreen.mainScreen().scale
    layer.addSublayer(trackLayer)

    thumbLayer.ticksSlider = self
    thumbLayer.contentsScale = UIScreen.mainScreen().scale
    layer.addSublayer(thumbLayer)
        
    updateFrames()
}

If build and run project at this point you probably will not see track at all. This is because there is no drawing code in TSTrackLayer yet.

First, let's change our track layer frame, because it was a temporary frame just for demostration purposes. Find updateFrames() in TicksSlider replace it's contents with the following:

func updateFrames() {
    trackLayer.frame = CGRect(x: 0, y: 0, width: bounds.width, height: tickHight)
    trackLayer.setNeedsDisplay()
        
    let thumbCenter = CGPoint(x: CGFloat(value) * (bounds.width / CGFloat(maximumValue)), y: bounds.midY)
    thumbLayer.frame = CGRect(x: thumbCenter.x - thumbWidth / 2, y: tickHight + thumbMargin , width: thumbWidth, height: thumbWidth)
    thumbLayer.setNeedsDisplay()
}

And let's draw the track with ticks (override drawInContext: method in TSTrackLayer.swift): 

override func drawInContext(ctx: CGContext!) {
    if let slider = ticksSlider {
        // Path without ticks
        let trackPath = UIBezierPath(rect: CGRect(x: 0, y: bounds.maxY - slider.trackHight, width: bounds.width, height: slider.trackHight))
    
        // Fill the track
        CGContextSetFillColorWithColor(ctx, slider.trackColor)
        CGContextAddPath(ctx, trackPath.CGPath)
        CGContextFillPath(ctx)
        
        // Draw ticks
        for index in Int(slider.minimumValue)...Int(slider.maximumValue) {
            let delta = bounds.width / CGFloat(slider.maximumValue)
                
            // Clip
            let tickPath = UIBezierPath(rect: CGRect(x: CGFloat(index) * delta - 0.5 * slider.tickWidth , y: 0.0, width: slider.tickWidth, height: slider.tickHight))
                
            // Fill the tick
            CGContextSetFillColorWithColor(ctx, slider.tickColor)
            CGContextAddPath(ctx, tickPath.CGPath)
            CGContextFillPath(ctx)    
        }
    }
}

One more thing before we build our slider. Make the following changes to declaration of the highlighted property inside TSThumbLayer.swift:

var highlighted: Bool = false {
  didSet {
    setNeedsDisplay()
  }
}

Build and run! Yeah. We're almost done.

how-to-customize-uislider-swift-2.png

Why almost? 

Imagine a situation when you change value somewhere in the code... Your slider will not reflect the changes yet. You need to implement property observers that update control's frame or drawing. Let's add this functionality by changing property declarations in TicksSlider.swift like this:

var minimumValue: Double = 0.0 {
     didSet {
         updateFrames()
     }
 }
 var maximumValue: Double = 10.0 {
     didSet {
         updateFrames()
     }
 }
 var value: Double = 7.0 {
     didSet {
         updateFrames()
     }
 }
 var previousLocation = CGPoint()
 
 let trackLayer = TSTrackLayer()
 var trackHight:CGFloat = 2.0 {
     didSet {
         trackLayer.setNeedsDisplay()
     }
 }
 var trackColor: CGColor = UIColor.blackColor().CGColor {
     didSet {
         trackLayer.setNeedsDisplay()
     }
 }
 var tickHight:CGFloat = 8.0 {
     didSet {
         trackLayer.setNeedsDisplay()
     }
 }
 var tickWidth: CGFloat = 2.0 {
     didSet {
         trackLayer.setNeedsDisplay()
     }
 }
 var tickColor: CGColor = UIColor.blackColor().CGColor {
     didSet {
         trackLayer.setNeedsDisplay()
     }
 }
 
 let thumbLayer = TSThumbLayer()
 var thumbColor: CGColor = UIColor.blackColor().CGColor {
     didSet {
         thumbLayer.setNeedsDisplay()
     }
 }
 var thumbMargin:CGFloat = 2.0 {
     didSet {
         thumbLayer.setNeedsDisplay()
     }
 }

Next look up for updateFrames() and insert the following to the top of the method

CATransaction.begin()
CATransaction.setDisableActions(true)

nd one line to the very bottom of the same method:

CATransaction.commit()

his three lines will wrap frame update into one transaction, so the layer frames are update immediately.

Since we now updating frames automatically, every time value changes, we can remove the same code from continueTrackingWithTouch. Go find this lines inside continueTrackingWithTouch method and delete them.

CATransaction.begin()
CATransaction.setDisableActions(true)
updateFrames()
CATransaction.commit()

et's give it a try. Actually to test if our slider is reflecting to programmatic changes we need one simple delayed task. Open up ViewController.seift file and and the bottom of viewDidLoad add the following code:

let time = dispatch_time(DISPATCH_TIME_NOW, 2 * Int64(NSEC_PER_SEC))
dispatch_after(time, dispatch_get_main_queue()) { () -> Void in
    self.ticksSlider.value = 3.0
}

his will change slider's value after two seconds.

Honestly, you're already have a completely new control! Your slider is now fully functional and ready to be used in your applications.

However, for my purposes I needed a thumb to sticks to integer values. You can skip it if you don't need that kind of functionality. Or to do so, open up TicksSlider.swift, and add some animations to endTrackingWithTouch: method like this:

override func endTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) {
    thumbLayer.highlighted = false
    UIView.animateWithDuration(0.4, delay: 0.0, usingSpringWithDamping: 7, initialSpringVelocity: 15, options: nil, animations: { () -> Void in
        let roundValue = round(self.value)
        let thumbCenter = CGPoint(x: CGFloat(roundValue) * (self.bounds.width / CGFloat(self.maximumValue)), y: self.bounds.midY)
        self.thumbLayer.frame = CGRect(x: thumbCenter.x - self.thumbWidth / 2, y: self.tickHight + self.thumbMargin , width: self.thumbWidth, height: self.thumbWidth)
    }) { (Bool) -> Void in
        self.value = round(self.value)
        self.sendActionsForControlEvents(.ValueChanged)
    }
}

Next, find the following line in continueTrackingWithTouch: and remove it, since we have it now in endTrackingWithTouch:

sendActionsForControlEvents(.ValueChanged)

The code above simply relocate sendActionsForControlEvents to the endTrackingWithTouch: method. It also rounds value to integer and animates thumb location to new value. That's it.

At this point you're done!  You did a great job, congratulations!

You can grab final project here.

Thank you for joining me on this tutorial. Hope it helps and give you some ideas to create your own controls. Please, you're more than welcome to leave any questions and comments. I also would like to see your UIControl examples. 

Until next time!