Think & Build

Icon

Custom Controls: button action with confirmation through 3D Touch

3D touch is the ability to track user’s touch pressure level and, in my opinion, is one of the most interesting and unexploited feature of iOS touch handling system.

With this tutorial we are going to build a custom button that leverages on 3D touch to ask user to confirm button action and, if 3D touch is not available on user device, it just fallback to a different behaviour. Here is a quick video to show you how this control works:

1. When user’s touch begins, a circular progress bar keeps track of user touch pressure. The circle will be filled in relation to user pressure, the harder the button is pressed the more the circle is filled (I’ll show you later how to we simulate this behaviour on devices that do not support 3D touch).

2. When the circle is fully filled, it becomes an active button, label changes to “OK” and color to green, indicating that the action can be confirmed. Now user can just swipe up and release his finger over the circle to confirm the action.

In general you ask user to confirm a delete-action using a pop up. I really love to experiment with UX interactions, and in my opinion this control can easily substitute the “standard” flow. You should try this behaviour on a physical device to understand how easy it is to interact with this control :)

Let’s code

As first, if you don’t know how custom controls work I strongly encourage you to read my previous article about building custom controls and download the tutorial project to easily follow the next steps.

Drawing the UI

The code to draw the circle and the label displayed when user starts interacting with the button is straightforward, let’s check it:

    private let circle = CAShapeLayer()
    private let msgLabel = CATextLayer()
    private let container = CALayer()
    .
    .
    .
   
    private func drawControl(){
        
        // Circle
        var transform = CGAffineTransform.identity
        circle.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
        circle.path = CGPath(ellipseIn: CGRect(x: 0,y: 0,width: size.width, height: size.height),
                             transform: &transform)
        
        circle.strokeColor = UIColor.white.cgColor
        circle.fillColor = UIColor.clear.cgColor
        circle.lineWidth = 1
        circle.lineCap = kCALineCapRound
        circle.strokeEnd = 0 // initially set to 0
        circle.shadowColor = UIColor.white.cgColor
        circle.shadowRadius = 2.0
        circle.shadowOpacity = 1.0
        circle.shadowOffset = CGSize.zero
        circle.contentsScale = UIScreen.main.scale

        // Label
        msgLabel.font = UIFont.systemFont(ofSize: 3.0)
        msgLabel.fontSize = 12
        msgLabel.foregroundColor = UIColor.white.cgColor
        msgLabel.string = ""
        msgLabel.alignmentMode = "center"
        msgLabel.frame = CGRect(x: 0, y: (size.height / 2) - 8.0, width: size.width, height: 12)
        msgLabel.contentsScale = UIScreen.main.scale
        
        // Put it all together
        container.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
        container.addSublayer(msgLabel)
        container.addSublayer(circle)
        
        layer.addSublayer(container)
    }

The circle and msgLabel layers are initialized and attached to the container layer.
There is nothing special to highlight in this code, just note that the strokeEnd property of circle is set to 0.
This property is really useful to easily obtain nice animation on a shape layer. Briefly, the path that describes the shape layer draws its stroke between strokeStart and strokeEnd, the default value for these properties are 0 and 1, so playing with this range you can easily get nifty drawing animations. For this control we set strokeEnd to 0 and we animate it reflecting user touch pressure.

Control States

This controller defines its UI and behaviour with a simple state machine described by the ConfirmActionButtonState enum.

enum ConfirmActionButtonState {
    case idle
    case updating
    case selected
    case confirmed
}

When no action is taken on the control, the state is idle. When user interaction starts the state changes to updating. When the circle is completely filled the state is selected and if users has already moved is finger inside the green circle the state is confirmed.

When user lift his finger, if the control state is equal to confirmed, we finally propagate the button action, since we can considered it as confirmed, otherwise the state will just moves back to idle

Handling user touch

We override beginTracking, continueTracking and endTracking methods to easily respond to user touches and grab all the information for the control.

Within these methods we have to track 3 elements:
1. The touch location. Useful to define where to draw the container layer (the one that contains the circle and the message label).
2. The touch force value. Needed to animate the circle and understand wether to set the control state to updating or to selected and confirmed.
3. The updated touch location. We need to track touch position to verify if it is contained into the container layer bounds and, in that case, set state to confirmed or updating.

Let’s see the code for the beginTracking method.

    override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        super.beginTracking(touch, with: event)
        
        if traitCollection.forceTouchCapability != UIForceTouchCapability.available{
  // fallback code ….
        }
        
        let initialLocation = touch.location(in: self)
        
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        container.position = initialLocation ++ CGPoint(x: 0, y: -size.height)
        CATransaction.commit()
        
        return true
    }

We check for device touch force capabilities and if the hardware doesn’t support this feature we execute a fallback code (we’ll talk about fallback behaviour later) . Then the touch location is used to define the container layer position, subtracting the control height. The ++ operand is defined at the end of the file to permit sum of CGPoint elements.
To avoid implicit system animations, the container position is assigned after the setDisableActions call (more information about this technique here [CALayer: CATransaction in Depth](http://calayer.com/core-animation/2016/05/17/catransaction-in-depth.html#preventing-animations-from-occurring) )

From the continueTracking function we perform all the needed operation to verify the control state.

    override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        super.continueTracking(touch, with: event)
        lastTouchPosition = touch
        updateSelection(with:touch)
        
        return true
    }

The lastTouchPosition will be used later to support older devices that doesn’t have 3D touch capability. While the updateSelection method receives the updated touch.
Here is the code for updateSelection:

    private func updateSelection(with touch: UITouch) {
        
        if self.traitCollection.forceTouchCapability == UIForceTouchCapability.available{
            intention = 1.0 * (min(touch.force, 3.0) / min(touch.maximumPossibleForce, 3.0))
        }
        
        if intention > 0.97 {
            if container.frame.contains(touch.location(in:self)){
                selectionState = .confirmed
            }else{
                selectionState = .selected
            }
            updateUI(with: 1.0)
        }
        else{
            if !container.frame.contains(touch.location(in:self)){
                selectionState = .updating
                updateUI(with: intention)
            }
        }
    }

Again, we check for force availability first and, if the feature is supported, we calculate the current “user intention”. The intention property can be assigned with a value that goes from 0 (when no touches are observed) to 1 (when touch reaches the maximum needed force). The operation to obtain this value is extremely simple: we just divide the current touch force by the maximum force, normalizing the value to a range valid for the “intention” property. Trying this code on a real device I found out that user has to press with to much force to reach the maximum value, for this reason I’ve added a cap of 3.0 to reduce the needed touch pressure.
(Actually I’m not so sure the name “intention” is a good choice… native speakers please, let me know if the name is clear enough to describe the property role :P ).

Now that the intention value has been calculated for this touch cycle, we can update control state and UI. If the value is greater then 0.97 and user touch has already moved inside the green circle, the control state is confirmed, otherwise, if user is still pressing the “delete” button, the current state is set toselected. When the value is less than 0.97 we say the control is just updating.

The updateUI function takes the current intention value and passes it to the endStroke property of the circle layer. Any other UI customization related to the “intention” could be defined inside this method.

    private func updateUI(with value:CGFloat){
        circle.strokeEnd = value
    }

Finally, we override the endTracking method to trigger the valueChanged event if the current control state is equal to confirmed.

    override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        super.endTracking(touch, with: event)
        intention = 0
        
        if selectionState == .confirmed{
            self.sendActions(for: UIControlEvents.valueChanged)
        }else{
            selectionState = .idle
            circle.strokeEnd = 0
        }
    }

If you check the Main.storyboard file you will see that the valueChanged action for the “delete” button has been assigned to the confirmDelete method of ViewController and, obviously, the custom class value for the delete button has bee set to ConfirmActionButton.

Control state and UI

The control UI is updated in relation to the current control state. To simplify this behaviour the code to update UI has been placed directly inside the didSet observer for the selectionState property.

The code is straightforward, just change circle Color and label message depending on the new state and call setNeedsLayout on circle to force its layout to be redrawn.

    private var selectionState:ConfirmActionButtonState = .idle {
        didSet{
            switch self.selectionState {
            case .idle, .updating:
                if oldValue != .updating || oldValue != .idle {
                    circle.strokeColor = UIColor.white.cgColor
                    circle.shadowColor = UIColor.white.cgColor
                    circle.transform = CATransform3DIdentity
                    msgLabel.string = ""
                }
                
            case .selected:
                if oldValue != .selected{
                    circle.strokeColor = UIColor.red.cgColor
                    circle.shadowColor = UIColor.red.cgColor
                    circle.transform = CATransform3DMakeScale(1.1, 1.1, 1)
                    msgLabel.string = "CONFIRM"
                }
                
            case .confirmed:
                if oldValue != .confirmed{
                    circle.strokeColor = UIColor.green.cgColor
                    circle.shadowColor = UIColor.green.cgColor
                    circle.transform = CATransform3DMakeScale(1.3, 1.3, 1)
                    msgLabel.string = "OK"
                }
            }
            circle.setNeedsLayout()
        }
    }

Fallback code

Just a quick note about the fallback for devices that don’t support 3D touch. I really wanted to keep the same design for all the devices so I decided to setup the intention property with a timed updated, relying on time instead of touch force. All the logics are identical to what we have previously discussed, but the intention property is updated automatically each 0.1 second when user is pressing the delete button. Here is the code for the beginTracking function where the timer is defined:

        if traitCollection.forceTouchCapability != UIForceTouchCapability.available{
            timer = Timer.scheduledTimer(timeInterval: 0.1,
                                         target: self,
                                         selector: #selector(ConfirmActionButton.updateTimedIntention),
                                         userInfo: nil,
                                         repeats: true)
            timer?.fire()
        }

the updateTimedIntention is responsible to update the intention value to reach completion (1.0) after 2 seconds:

    func updateTimedIntention(){
        intention += CGFloat(0.1 / 2.0)
        updateSelection(with: lastTouchPosition)
    }

Conclusions

I really enjoyed writing this code and I think I’m going to talk about other custom controls soon. In my opinion still there is a lot of space to experiment on custom UI and improve user experience leveraging on new devices feature… I hope this tutorial might inspire you :)

Download Source