Günay Mert Karadoğan    About    Archive

How to Build a Custom Stepper - Part 1

Here I am in Madrid, completing my second week in H4ckademy. This week I want to create a stepper with a sliding label between the buttons. During the next 6 weeks, I will be building more user interface components like this stepper. Of course you will be able to find the tutorials here.

When you finish this 2-parts tutorial, the component will look like this:

I assume that you are familiar with Swift and iOS development, but maybe never developed a UI component.

Create the project

To begin writing our component, start by creating a single view project.

  • File > New > Project > Single View Project
  • Give a product name like GMStepperExample and pick Swift as the language.

After creating the project, create a custom UIView subclass. Later we will turn this into UIControl, and you will see its advantages against UIView.

  • File > New > File
  • Under iOS, select Cocoa Touch Class and click Next.
  • Give a class name like GMStepper
  • Select UIView as the subclass
  • Select Swift as the language, click Next. Save it into your project folder.

You can go to GMStepper.swift and clear the comments. It should look like:

import UIKit

class GMStepper: UIView {

}

Initialize the subviews

There are 2 options to initialize a view. You can initialize a view with a frame so that you can manually add the view in your code by using init(frame:). Or you can add your view in the storyboard and let init(coder:) load the view for you.

We will be writing both so that the user will have the option to choose. Both init functions will call the same setup function.

class GMStepper: UIView {

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

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

    func setup() {

    }
}

Note that every UIView subclass with an initializer must have init(coder:). This is why it has a required keyword.

In this component, we have two buttons and a label in the middle. Add them as properties. Set their title, background colors, etc. in the setup function.

class GMStepper: UIView {

    let leftButton = UIButton()
    let rightButton = UIButton()
    let label = UILabel()

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

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

    func setup() {
        leftButton.setTitle("-", forState: .Normal)
        leftButton.backgroundColor = UIColor.blueColor()
        addSubview(leftButton)

        rightButton.setTitle("+", forState: .Normal)
        rightButton.backgroundColor = UIColor.blueColor()
        addSubview(rightButton)

        label.text = "0"
        label.textAlignment = .Center
        label.backgroundColor = UIColor.redColor()
        addSubview(label)
    }
}

Layout the subviews

You can see that we haven't defined their frames yet. To adjust where they lay out in the view, we will override layoutSubviews method provided by UIView. When the system wants a layout update, it calls the layoutSubviews function.

override func layoutSubviews() {
    let labelWidthWeight: CGFloat = 0.5

    let buttonWidth = bounds.size.width * ((1 - labelWidthWeight) / 2)
    let labelWidth = bounds.size.width * labelWidthWeight

    leftButton.frame = CGRect(x: 0, y: 0, width: buttonWidth, height: bounds.size.height)

    label.frame = CGRect(x: buttonWidth, y: 0, width: labelWidth, height: bounds.size.height)

    rightButton.frame = CGRect(x: labelWidth + buttonWidth, y: 0, width: buttonWidth, height: bounds.size.height)
}

bounds.size is the size of our whole component. Here we give a percentage for label's width, 0.5. The label is half of the stepper, and we use the rest of the space for the buttons.

Now open your storyboard. Drag and drop a UIView into the scene. Give an appropriate size like Width:200 and Height:44. In the identity inspector, select GMStepper as the view's class. This will make this view an object of GMStepper class.

Checkpoint: Run your app. You should see something like:

Button actions

Whenever we tap the buttons, we will change the value and slide the label to right or left. Whenever we remove our finger, we will slide the label back to its original position.

Add a value property which defaults to 0. Add a property observer to it so that whenever the value changes, label's text changes. Also don't forget to change the line label.text = "0" to label.text = String(value) in setup method.

var value = 0 {
    didSet {
        label.text = String(value)
    }
}

A button intercepts touch events and sends an action message to a target object when some events happen. Here we add target-actions for .TouchDown, .TouchUpInside, and .TouchUpOutside events in the setup method.

leftButton.addTarget(self, action: "leftButtonTouchDown:", forControlEvents: .TouchDown)
leftButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpInside)
leftButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpOutside)

Of course you do the similar for the right button. Now we need a simple animation to change the center of the label which will slide it to left or right. We also need to slide it back when the button touch is over. For that we need to keep the original center of the label. We can get label.center in layoutSubviews.


    var labelOriginalCenter: CGPoint!

    //...

    override func layoutSubviews() {
        //...

        labelOriginalCenter = label.center
    }

    // MARK: Button Event Actions
    func leftButtonTouchDown(button: UIButton) {
        value -= 1
        slideLabelLeft()
    }

    func rightButtonTouchDown(button: UIButton) {
        value += 1
        slideLabelRight()
    }

    func buttonTouchUp(button: UIButton) {
        slideLabelToOriginalPosition()
    }

    // MARK: Animations
    let labelSlideLength: CGFloat = 5
    let labelSlideDuration = NSTimeInterval(0.1)

    func slideLabelLeft() {
        slideLabel(-labelSlideLength)
    }

    func slideLabelRight() {
        slideLabel(labelSlideLength)
    }

    func slideLabel(slideLength: CGFloat) {
        UIView.animateWithDuration(labelSlideDuration) {
            self.label.center.x += slideLength
        }
    }

    func slideLabelToOriginalPosition() {
        if label.center != labelOriginalCenter {
            UIView.animateWithDuration(labelSlideDuration) {
                self.label.center = self.labelOriginalCenter
            }
        }
    }

When the label slides, the white background shows up, that is not nice. We can add two views behind the label. The views should have the some color with the button next to it. But our component's buttons will be the same color. So it is enough to set the component's background color to the button's color. In setup method, add backgroundColor = UIColor.blueColor().

Checkpoint: When you run the app, you should see:

Pan gesture

Let's add a pan gesture recognizer to the label. There are two sides of this: adding it to a view, and providing a method to handle the gesture. In the end of the setup method, add these lines:

let panRecognizer = UIPanGestureRecognizer(target: self, action: "handlePan:")
panRecognizer.maximumNumberOfTouches = 1
label.userInteractionEnabled = true
label.addGestureRecognizer(panRecognizer)

The abstract superclass, UIGestureRecognizer, provides a state information which is changing during the gesture. UIPanGestureRecognizer has 3 methods:

  • translationInView(view:)
  • velocityInView(view:)
  • setTranslation(translation:inView:)

translationInView gives the total translation since beginning of the gesture. We use setTranslationto reset the transition to zero in the end of the .Changed state. This will provide us to get incremental transition. Go ahead and add the handlePan: method. Note that we limit the transition between labelOriginalCenter.x - labelSlideLength and labelOriginalCenter.x + labelSlideLength.

func handlePan(gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .Changed:
        var translation = gesture.translationInView(label)

        let minimumLabelX = labelOriginalCenter.x - labelSlideLength
        let maximumLabelX = labelOriginalCenter.x + labelSlideLength
        label.center.x = max(minimumLabelX, min(maximumLabelX, label.center.x + translation.x))

        gesture.setTranslation(CGPointZero, inView: label)
    case .Ended:
        slideLabelToOriginalPosition()
    default:
        break
    }
}

When the label hits the edge, we will update the value. So we make an addition to handlePan as below:

func handlePan(gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .Changed:
        //...

        if label.center.x == minimumLabelX {
            value -= 1
        } else if label.center.x == maximumLabelX {
            value += 1
        }

        gesture.setTranslation(CGPointZero, inView: label)
    case .Ended:
        slideLabelToOriginalPosition()
    default:
        break
    }
}

Checkpoint:

The stepper stops changing the value when the pan stops. We will make it nicer in the part 2 of this tutorial.

Access control and API

Since other modules will use this component, we need to make our class public, and also decide which properties will be public. I added some more properties to make it more customizable. You can check the final version which has more properties like, minimumValue, stepValue, etc.

@IBDesignable public class GMStepper: UIView {

    /// Current value of the stepper. Defaults to 0.
    @IBInspectable public var value = 0 {
        didSet {
            label.text = String(value)
        }
    }

    /// Text on the left button. Be sure that it fits in the button. Defaults to "-".
    @IBInspectable public var leftButtonText: String = "-" {
        didSet {
            leftButton.setTitle(leftButtonText, forState: .Normal)
        }
    }

    /// Text on the right button. Be sure that it fits in the button. Defaults to "+".
    @IBInspectable public var rightButtonText: String = "+" {
        didSet {
            rightButton.setTitle(rightButtonText, forState: .Normal)
        }
    }

    /// Text color of the buttons. Defaults to white.
    @IBInspectable public var buttonsTextColor: UIColor = UIColor.whiteColor() {
        didSet {
            for button in [leftButton, rightButton] {
                button.setTitleColor(buttonsTextColor, forState: .Normal)
            }
        }
    }

    /// Background color of the buttons. Defaults to dark blue.
    @IBInspectable public var buttonsBackgroundColor: UIColor = UIColor(red:0.21, green:0.5, blue:0.74, alpha:1) {
        didSet {
            for button in [leftButton, rightButton] {
                button.backgroundColor = buttonsBackgroundColor
            }
            backgroundColor = buttonsBackgroundColor
        }
    }

    /// Font of the buttons. Defaults to AvenirNext-Bold, 20.0 points in size.
    public var buttonsFont = UIFont(name: "AvenirNext-Bold", size: 20.0)! {
        didSet {
            for button in [leftButton, rightButton] {
                button.titleLabel?.font = buttonsFont
            }
        }
    }

    /// Text color of the middle label. Defaults to white.
    @IBInspectable public var labelTextColor: UIColor = UIColor.whiteColor() {
        didSet {
            label.textColor = labelTextColor
        }
    }

    /// Text color of the middle label. Defaults to lighter blue.
    @IBInspectable public var labelBackgroundColor: UIColor = UIColor(red:0.26, green:0.6, blue:0.87, alpha:1) {
        didSet {
            label.backgroundColor = labelBackgroundColor
        }
    }

    /// Font of the middle label. Defaults to AvenirNext-Bold, 25.0 points in size.
    public var labelFont = UIFont(name: "AvenirNext-Bold", size: 25.0)! {
        didSet {
            label.font = labelFont
        }
    }

    /// Percentage of the middle label's width. Must be between 0 and 1. Defaults to 0.5. Be sure that it is wide enough to show the value.
    @IBInspectable public var labelWidthWeight: CGFloat = 0.5 {
        didSet {
            labelWidthWeight = min(1, max(0, labelWidthWeight))
            setNeedsLayout()
        }
    }

    func setup() {
        leftButton.setTitle(leftButtonText, forState: .Normal)
        leftButton.backgroundColor = buttonsBackgroundColor
        leftButton.setTitleColor(buttonsTextColor, forState: .Normal)
        leftButton.titleLabel?.font = buttonsFont
        leftButton.addTarget(self, action: "leftButtonTouchDown:", forControlEvents: .TouchDown)
        leftButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpInside)
        leftButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpOutside)
        addSubview(leftButton)

        rightButton.setTitle(rightButtonText, forState: .Normal)
        rightButton.backgroundColor = buttonsBackgroundColor
        rightButton.setTitleColor(buttonsTextColor, forState: .Normal)
        rightButton.titleLabel?.font = buttonsFont
        rightButton.addTarget(self, action: "rightButtonTouchDown:", forControlEvents: .TouchDown)
        rightButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpInside)
        rightButton.addTarget(self, action: "buttonTouchUp:", forControlEvents: .TouchUpOutside)
        addSubview(rightButton)

        label.text = String(value)
        label.textAlignment = .Center
        label.backgroundColor = labelBackgroundColor
        label.textColor = labelTextColor
        label.font = labelFont
        addSubview(label)

        let panRecognizer = UIPanGestureRecognizer(target: self, action: "handlePan:")
        panRecognizer.maximumNumberOfTouches = 1
        label.userInteractionEnabled = true
        label.addGestureRecognizer(panRecognizer)

        backgroundColor = buttonsBackgroundColor
    }
}

Note that:

  • We added documentation comments /// above public properties.
  • We have a default value and a property observer for each public property. Whenever these properties are set, they also update the related part of the component.
  • We use @IBDesignable and @IBInspectable. @IBDesignable renders the view directly in the storyboard without running the app. @IBInspectable lets you configure the properties in the storyboard. You can change the text of the buttons (even use emojis), or the background colors from the storyboard.

Some fixes

  • When you tap and hold the left button, you are able to tap right button at the same time. Or when you are making the pan gesture, you are able to use the buttons. It is better if we enable and disable them in the button handlers and the pan gesture handler.
  • I mentioned that subclassing UIControl has advantages over UIView. UIControl provides methods for sending action messages. When our value changes, we will send an action message to our target. This target is probably a view controller that manages our component. Replace the superclass as @IBDesignable public class GMStepper: UIControl {...}. We will also change the value property as below:
@IBInspectable public var value = 0 {
    didSet {
        label.text = String(value)

        if oldValue != value {
            sendActionsForControlEvents(.ValueChanged)
        }
    }
}

Add a target-action mechanism In the ViewController.swift. Don't forget to connect the outlet to the GMStepper from the storyboard.

class ViewController: UIViewController {
    @IBOutlet weak var stepper: GMStepper!

    override func viewDidLoad() {
        super.viewDidLoad()
        stepper.addTarget(self, action: "stepperValueChanged:", forControlEvents: .ValueChanged)
    }

    func stepperValueChanged(stepper: GMStepper) {
        println(stepper.value)
    }
}

This was the end of part 1. The next part will be about making the autorepeat functionality of UIStepper by using a timer. You can find GMStepper in this repo.

If you liked this post, you can share it with your followers or follow me on Twitter!