21.
RxGesture
Written by Florent Pillet
Gesture processing is a good candidate for reactive extensions. Gestures can be viewed as a stream of events, either discrete or continuous. Working with gestures normally involves using the target-action pattern, where you set some object as the gesture target and create a function to receive updates.
At this point, you can appreciate the value of turning as much as of your data and event sources as possible into observable sequences. Enter RxGesture, https://github.com/RxSwiftCommunity/RxGesture, a project living under the RxSwiftCommunity banner at https://github.com/RxSwiftCommunity. It’s cross-platform, working on both iOS and macOS.
In this chapter, you’ll focus on the iOS implementation of RxGesture.
Attaching gestures
RxGesture makes it dead simple to attach a gesture to a view:
view.rx.tapGesture()
.when(.recognized)
.subscribe(onNext: { _ in
print("view tapped")
})
.disposed(by: disposeBag)
In this example, RxGesture creates a UITapGestureRecognizer, attaches it to the view and emits an event every time the gesture is recognized. When you want to get rid of the recognizer, simply call dispose() on the Disposable object returned by the subscription.
You can also attach multiple gestures at once:
view.rx.anyGesture(.tap(), .longPress())
.when(.recognized)
.subscribe(onNext: { [weak view] gesture in
if let tap = gesture as? UITapGestureRecognizer {
print("view was tapped at \(tap.location(in: view!))")
} else {
print("view was long pressed")
}
})
.disposed(by: disposeBag)
The event the subscription emits is the gesture recognizer object which changed state. The when(_:...) operator above lets you filter events based on the recognizer state to avoid processing events you’re not interested in.
Supported gestures
RxGesture works with all iOS and macOS built-in gesture recognizers. You can use it with your own gesture recognizers, but that’s beyond the scope of this chapter.
When you need a single gesture, use its reactive extension directly to attach it to the view. When you need multiple gestures at once, use the anyGesture(_:...) operator along with one of the supported functions. As seen in the examples above, you can either use view.tapGesture() or view.anyGesture(.tap()).
On iOS, the gesture extensions of UIView are rx.tapGesture(), rx.swipeGesture(_:), rx.longPressGesture(), rx.screenEdgePanGesture(edges:), rx.pinchGesture(), rx.panGesture(), rx.rotationGesture(). In addition, rx.touchDownGesture() is a variation on long press gestures, rx.forceTouchGesture() lets you recognize force touch and rx.transformGestures() lets you combine pan, rotation and pinch (keep reading for an example). Finally, rx.hoverGesture() available on iOS 13 and up (and on Mac Catalyst) lets you recognize when the pointer hovers over a view.
Swipe and Screen Edge Pan gestures require you to provide parameters to indicate the expected swipe direction or the screen edge for the recognizer to detect the gesture:
view.rx.screenEdgePanGesture(edges: [.top, .bottom])
.when(.recognized)
.subscribe(onNext: { recognizer in
// gesture was recognized
})
.disposed(by: disposeBag)
On macOS, the gesture extensions of NSView are rx.clickGesture(), rx.leftClickGesture(), rx.rightClickGesture(), rx.pressGesture(), rx.rotationGesture() and rx.magnificationGesture().
Each method that creates a gesture observable can take a configuration closure; this allows you to further tweak the gesture to your needs. For example, if you’re writing an iPad Pro application and want to detect a swipe with the stylus only, you could do the following:
let observable = view.rx.swipeGesture(.left, configuration: { recognizer in
recognizer.allowedTouchTypes = [NSNumber(value: UITouchType.stylus.rawValue)]
})
Current location
Any gesture observable can be transformed to an observable of the location in the view of your choice with asLocation(in:), saving you from doing it manually:
view.rx.tapGesture()
.when(.recognized)
.asLocation(in: .window)
.subscribe(onNext: { location in
// you now directly get the tap location in the window
})
.disposed(by: disposeBag)
Pan gestures
When creating a pan gesture observable with the rx.panGesture() reactive extension, use the asTranslation(in:) operator to transform events and obtain a tuple of current translation and velocity. The operator lets you specify which of the gestured view, superview, window or any other views you want to obtain the relative translation for. You’ll get an Observable<(translation: CGPoint, velocity: CGPoint)> in return:
view.rx.panGesture()
.asTranslation(in: .superview)
.subscribe(onNext: { translation, velocity in
print("Translation=\(translation), velocity=\(velocity)")
})
.disposed(by: disposeBag)
Rotation gestures
Similarly to pan gestures, rotation gestures created with the rx.rotationGesture() extension can be further transformed with the asRotation() operator. It creates an Observable<(rotation: CGFloat, velocity: CGFloat)>.
view.rx.rotationGesture()
.asRotation()
.subscribe(onNext: { rotation, velocity in
print("Rotation=\(rotation), velocity=\(velocity)")
})
.disposed(by: disposeBag)
Automated view transform
More complex interactions, such as the pan/pinch/rotate combination gesture in MapView, can be fully automated with the help of the transformGestures() reactive extension of UIView:
view.rx.transformGestures()
.asTransform()
.subscribe(onNext: { [unowned view] transform, velocity in
view.transform = transform
})
.disposed(by: disposeBag)
transformGestures() is a convenience extension which creates three gestures — a pan, a pinch and a rotation — attaches them to the view and returns an Observable<TransformGestureRecognizers>. The TransformGestureRecognizers struct simply holds the three recognizers.
The asTransform() operator turns the structure into an Observable<(transform: CGAffineTransform, velocity: TransformVelocity)>. The TransformVelocity struct holds the individual velocity for each of the gestures.
If you don’t need the three gestures, you can disable one of them at configuration time, as the default configuration creates and attaches all three recognizers:
view.rx.transformGestures(configuration: { (recognizers, delegate) in
recognizers.pinchGesture.isEnabled = false
})
Advanced usage
You’ll sometimes need to use the observable for the same gesture at multiple places. Since subscribing to the observable creates and attaches the gesture recognizer, you only want to do this once.
This is a good opportunity to use the share(replay:scope:) operator, as shown here:
let panGesture = view.rx.panGesture()
.share(replay: 1)
panGesture
.when(.changed)
.asTranslation()
.subscribe(onNext: { [unowned view] translation, _ in
view.transform = CGAffineTransform(translationX: translation.x,
y: translation.y)
})
.disposed(by: disposeBag)
panGesture
.when(.ended)
.subscribe(onNext: { _ in
print("Done panning")
})
.disposed(by: disposeBag)