Статьи

Создание пользовательского распознавателя жестов 3D Touch в Swift

Еще в марте я смотрел на создание пользовательских распознавателей жестов для поворота одним касанием в  Создание пользовательских распознавателей жестов в Swift . С появлением  3D Touch  в новом iPhone 6s я подумал, что было бы интересно сделать то же самое для глубоких нажатий. 

Мой  DeepPressGestureRecognizer  — это расширенный  UIGestureRecognizer,  который вызывает действие, когда печать проходит заданный порог. Его синтаксис такой же, как и любой другой распознаватель жестов, например, долгое нажатие, и реализован так:

let button = UIButton(type: UIButtonType.System)

    button.setTitle("Button with Gesture Recognizer", forState: UIControlState.Normal)

    stackView.addArrangedSubview(button)

    let deepPressGestureRecognizer = DeepPressGestureRecognizer(target: self,
        action: "deepPressHandler:",
        threshold: 0.75)


    button.addGestureRecognizer(deepPressGestureRecognizer)

Действие имеет те же состояния, что и другие распознаватели, поэтому, когда состояние  начинается , сила касания пользователя превысила порог:

func deepPressHandler(value: DeepPressGestureRecognizer)
    {
        if value.state == UIGestureRecognizerState.Began
        {
            print("deep press begin")
        }
        else if value.state == UIGestureRecognizerState.Ended
        {
            print("deep press ends.")
        }

    }

Если это слишком много кода, я также создал расширение протокола, которое означает, что вы получаете распознаватель глубокой печати просто, если ваш класс реализует DeepPressable:

class DeepPressableButton: UIButton, DeepPressable
    {

    }

… и затем устанавливаем соответствующее действие в setDeepPressAction ():

  let deepPressableButton = DeepPressableButton(type: UIButtonType.System)
  deepPressableButton.setDeepPressAction(self, action: "deepPressHandler:")

Sadly, there’s no public API to Apple’s Taptic Engine (however, there are workarounds as Dal Rupnik discusses here). Rather than using private APIs, my code optionally vibrates the  device when a deep press has been recognised.

Deep Press Gesture Recogniser Mechanics

To extend UIGestureRecognizer, you’ll need to add a bridging header to import UIKit/UIGestureRecognizerSubclass.h. Once you have that you’re free to override touchesBegan, touchesMoved and touchesEnded. In DeepPressGestureRecognizer, the first of these two methods call handleTouch() which checks either:

  • If a deep press hasn’t been recognised but the current force is above a normalised threshold, then treat that touch event as the beginning of the deep touch gesture.
  • If a deep press has been recognised and the touch force has dropped below the threshold, treat that touch event as the end of the gesture.

The code for handleTouch() is: 

    private func handleTouch(touch: UITouch)
    {
        guard let view = view where touch.force != 0 && touch.maximumPossibleForce != 0 else
        {
            return
        }

        if !deepPressed && (touch.force / touch.maximumPossibleForce) >= threshold
        {
            view.layer.addSublayer(pulse)
            pulse.pulse(CGRect(origin: CGPointZero, size: view.frame.size))

            state = UIGestureRecognizerState.Began

            if vibrateOnDeepPress
            {
                AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
            }

            deepPressed = true
        }
        else if deepPressed && (touch.force / touch.maximumPossibleForce) < threshold
        {
            state = UIGestureRecognizerState.Ended

            deepPressed = false
        }

    }

In touchesEnded  if a deep touch hasn’t been recognised (e.g. the user has lightly tapped a button or changed a slider), I set the gesture’s state to Failed:     

    override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent)
    {
        super.touchesEnded(touches, withEvent: event)

        state = deepPressed ?
            UIGestureRecognizerState.Ended :
            UIGestureRecognizerState.Failed

        deepPressed = false

    }

Visual Feedback

In the absence of access to the iPhone’s Taptic Engine, I decided to add a radiating pulse effect to the source component when the gesture is recognised. This is done by adding a CAShapeLayer to the component’s CALayer and transitioning from a rectangle path the size of the component to a much larger one (thanks to Jameson Quave for this article that describes that beautifully). 

To do this, first I two CGPath instances for the beginning and end states:

let startPath = UIBezierPath(roundedRect: frame,
        cornerRadius: 5).CGPath
    let endPath = UIBezierPath(roundedRect: frame.insetBy(dx: -50, dy: -50),

        cornerRadius: 5).CGPath

Then create three basic animations to grow the path, fade it out by reducing the opacity to zero and fattening the stroke:

    let pathAnimation = CABasicAnimation(keyPath: "path")
    pathAnimation.toValue = endPath

    let opacityAnimation = CABasicAnimation(keyPath: "opacity")
    opacityAnimation.toValue = 0

    let lineWidthAnimation = CABasicAnimation(keyPath: "lineWidth")

    lineWidthAnimation.toValue = 10

Inside a single CATransaction  I give all three animations the same properties for duration, timing function, etc. and set them going. Once the animation is finished, I remove the pulse layer from the source component’s layer:

    CATransaction.begin()

    CATransaction.setCompletionBlock
    {
        self.removeFromSuperlayer()
    }

    for animation in [pathAnimation, opacityAnimation, lineWidthAnimation]
    {
        animation.duration = 0.25
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
        animation.removedOnCompletion = false
        animation.fillMode = kCAFillModeForwards

        addAnimation(animation, forKey: animation.keyPath)
    }


    CATransaction.commit()

DeepPressable Protocol Extension

I couldn’t resist adding a protocol extension to make any class that can add gesture recognisers deep-pressable. The protocol itself has two of my own methods for setting and removing deep press actions:

    func setDeepPressAction(target: AnyObject, action: Selector)
    func removeDeepPressAction()

These are given default behaviour in the extension:

    func setDeepPressAction(target: AnyObject, action: Selector)
    {
        let deepPressGestureRecognizer = DeepPressGestureRecognizer(target: target, action: action, threshold: 0.75)

        self.addGestureRecognizer(deepPressGestureRecognizer)
    }

    func removeDeepPressAction()
    {
        guard let gestureRecognizers = gestureRecognizers else
        {
            return
        }

        for recogniser in gestureRecognizers where recogniser is DeepPressGestureRecognizer
        {
            removeGestureRecognizer(recogniser)
        }

    }

In Conclusion

Without the access to the Taptic Engine, this may not be an ideal interaction experience, however the visual feedback may help mitigate that. However, hopefully this post illustrates how easy it is to integrate 3D Touch information into a custom gesture recogniser. You may want to use this example to create a continuous force gesture recogniser, for example in a drawing application. 

As always, the source code for this project is available in my GitHub repository here.  Enjoy!