You may have used gesture recognisers in your Swift projects already, for example UIPinchGestureRecognizer for handling pinch-to-zoom or UIRotationGestureRecognizer for detecting a two touch rotation. This post looks at creating bespoke gesture recognisers and illustrates the technique with a single touch rotation gesture. The companion project for this post is available at my GitHub repository here.
The original code for the single touch rotation gesture comes from an ActionScript project I did back in 2008. Although I originally thought it would be useful for games, it could also be useful as a jog/shuttle control for a video application.
The UIGestureRecognizer base class allows us to decouple the logic for recognising and acting upon user gestures. Rather than adding code directly to a view or controller, delegating this code to an extended UIGestureRecognizer allows for code reuse and supports the single responsibility principle.
The Swift code for implementing an existing gesture recogniser, for example UILongPressGestureRecognizer, in its simplest form looks like this: I create an instance of the recogniser, set its target to the component that I want to respond to the gesture and define the method to invoke in response to the gesture:
let longPress = UILongPressGestureRecognizer(target: self, action: "longHoldHandler:")
addGestureRecognizer(longPress)
When the gesture begins, changes or ends, my function, longHoldHandler() is invoked with the recogniser passed as an argument:
func longHoldHandler(recognizer: UILongPressGestureRecognizer)
{
// react to the beginning, change or end of the gesture
}
Let’s look at creating and implementing a new gesture recogniser, SingleTouchRotaionalGestureRecognizer. First things first, before extending UIGestureRecognizer, we need a header file. Under File -> New File, create a new header file and add the following:
#import
In the project view, under the project’s targets -> Code Generation -> Objective-C Bridging Header, add a link to the new file.
Now, the new class, SingleTouchRotaionalGestureRecognizer, is able to extend UIGestureRecognizer and override its methods.
The three important methods to override are when the gesture begins, changes or ends. A gesture begins when the user first touches the view and touchesBegan() is invoked. My code populates an array with all the touch positions in order to calculate an average position, so in this function, I create a new array and add the initial touch position to it. Most importantly, I want to set the state of the recogniser to Began:
overridepublicfunc touchesBegan(touches: NSSet!, withEvent event: UIEvent!)
{
super.touchesBegan(touches, withEvent: event)
let touch = touches.anyObject() asUITouch
let currentPoint = touch.locationInView(self.view)
averagePoint = currentPoint
touchPoints = [currentPoint]
state = UIGestureRecognizerState.Began
}
With each move of the touch, touchesMoved() is invoked. My first step in this case is to ensure the distance between the current touch position and the last is worth considering:
overridepublicfunc touchesMoved(touches: NSSet!, withEvent event: UIEvent!)
{
super.touchesMoved(touches, withEvent: event)
let touch = touches.anyObject() asUITouch
let currentPoint = touch.locationInView(self.view)
let previousPoint = touchPoints.last ?? CGPointZero
let distance = currentPoint.distance(previousPoint)
if distance > 2.0
{
[…]
If it is, I append the new touch position to my array and then loop over that array to calculate the average position over the entire gesture:
averagePoint = CGPoint(x: 0, y: 0)
let lastGestureAngle:Float = gestureAngle
touchPoints.append(currentPoint)
for touchPoint intouchPoints
{
averagePoint!.x+= touchPoint.x
averagePoint!.y+= touchPoint.y
}
let touchPointsCount = CGFloat(touchPoints.count)
averagePoint!.x = averagePoint!.x/ touchPointsCount
averagePoint!.y = averagePoint!.y/ touchPointsCount
I then calculate the angle between the newly calculated average point and the current touch:
let dx = Float(averagePoint!.x- currentPoint.x)
let dy = Float(averagePoint!.y- currentPoint.y)
gestureAngle = atan2(dy, dx).toDegrees()
…and if nothing crazy has happened, i.e. that angle is less than 45 degrees, I can calculate whether the rotation is clockwise or anti-clockwise, the distance between the average position and the current position and, of course, update the recogniser’s state to Changed:
if(abs(gestureAngle - lastGestureAngle) < 45)
{
rotatationDirection = gestureAngle< lastGestureAngle ? RotatationDirection.AntiClockwise : RotatationDirection.Clockwise
currentAngle = gestureAngle
distanceFromCentre = currentPoint.distance(averagePoint!)
state = UIGestureRecognizerState.Changed
}
Finally, when the user lifts their finger, touchesEnded() is invoked and here I null everything that should be nulled and set the status to Ended:
publicoverridefunc touchesEnded(touches: NSSet!, withEvent event: UIEvent!)
{
super.touchesEnded(touches, withEvent: event)
rotatationDirection = nil
currentAngle = nil
distanceFromCentre = nil
touchPoints.removeAll(keepCapacity: false)
state = UIGestureRecognizerState.Ended
}
The new class is implemented like any other gesture recogniser. In my view controller, I create an instance and set the target to the controller:
let gestureRecogniser = SingleTouchRotationalGestureRecognizer(target: self, action: "rotateHandler:")
view.addGestureRecognizer(gestureRecogniser)
Inside the view controller, I have two components to illustrate how my gesture recogniser works:
- TouchOverlay which displays a two circles joined by a line. The smaller of the two circles is placed at the average point of the gesture and the larger at the current touch point
- Square which is a rounded square with a rotation that matches the gesture’s rotation and a side which matches the distance between the gesture’s average and current position.
I defined the recogniser’s action as rotateHanlder() in its instantiation above and that is where I react to the user’s actions and position and rotate these two components. Remembering that the initial state of the recogniser is Began, it’s in that condition that I add the instance of touchOverlay to the view and have it draw itself:
func rotateHandler(gestureRecogniser : SingleTouchRotationalGestureRecognizer)
{
if gestureRecogniser.state==UIGestureRecognizerState.Began
{
view.layer.addSublayer(touchOverlay)
iflet centre = gestureRecogniser.getAveragePoint()
{
touchOverlay.frame = CGRect(origin: centre , size: CGSizeZero)
touchOverlay.drawTouchOverlay(0)
}
}
[…]
The other important state is Changed, here, I use the gesture recogniser’s current angle to create a 3D transform around the z-axis and apply it to both of the visual components:
[…]
elseif gestureRecogniser.state==UIGestureRecognizerState.Changed
{
iflet angle = gestureRecogniser.getCurrentAngle()?.toRadians()
{
iflet centre = gestureRecogniser.getAveragePoint()
{
touchOverlay.frame = CGRect(origin: centre , size: CGSizeZero)
}
let rotateTransform = CATransform3DMakeRotation(CGFloat(angle), 0, 0, 1)
touchOverlay.transform = rotateTransform
rotatingSquare.transform = rotateTransform
[…]
…then I use the recogniser’s distanceFromAverage to set the length of the touch overlay and the size of the square:
[…]
let distance = Int(gestureRecogniser.getDistanceFromAverage() ?? 0)
touchOverlay.drawTouchOverlay(distance)
rotatingSquare.drawSqure(distance)
[…]
Finally, because the recogniser also exposes the direction of rotation, either clockwise or anti-clockwise, I populate a label with that information:
[…]
clockwiseAntiClockwiseLabel.text = gestureRecogniser.getRotatationDirection()?.rawValue
[…]
When the gesture is finished, I clear the label and remove the touch overlay from the view:
[…]
else// Ended, Cacelled or Failed...
{
touchOverlay.removeFromSuperlayer()
clockwiseAntiClockwiseLabel.text = ""
}
[…]
You may have noticed a few new methods on Floats: toRadians() and toDegrees() - these convert degrees to radians and vice-versa and live inside an extension:
extensionFloat
{
func toRadians() -> Float
{
returnself / Float(180.0 / M_PI)
}
func toDegrees() -> Float
{
returnself * Float(180.0 / M_PI)
}
}
…I also created an extension to return the distance between two CGPoint instances:
extensionCGPoint
{
func distance(otherPoint: CGPoint) -> Float
{
let xSquare = Float((self.x- otherPoint.x) * (self.x- otherPoint.x))
let ySquare = Float((self.y- otherPoint.y) * (self.y- otherPoint.y))
returnsqrt(xSquare + ySquare)
}
}
The entire project is available at my GitHub repository here.