Il y a 4 ans -
Temps de lecture 6 minutes
Pépite #10 – Rx(Swift) : interagir facilement avec les UIButton
Impossible d’imaginer des pépites sans un petit article consacré à Rx !
Si vous êtes déjà familier avec Rx, les Observable
, Driver
et autres BehaviorSubject
n’ont sûrement plus de secret pour vous. Aujourd’hui nous allons consacrer quelques lignes aux ControlEvent
et découvrir une application concrète que vous rencontrez forcément dans votre quotidien : changer l’apparence d’un bouton quand l’utilisateur tape dessus (et revenir à son état initial quand le bouton est « relâché »)
Vous l’aurez compris, les ControlEvent
sont destinés à faciliter l’interaction avec les composants UI de votre interface.
Important à savoir : un ControlEvent
est un Observable
qui ne renvoie jamais d’erreur et émet sur le MainScheduler.instance
, donc sur le main thread.
Changer l’apparence d’un bouton quand l’utilisateur tape dessus (et revenir à son état initial au « touch up »)
Rx propose une interface très simple pour interagir avec les UIControl.Event
d’un bouton :
let button: UIButton
button.rx.controlEvent(_ controlEvents: UIControlEvents) // UIControlEvents est un simple typealias de UIControl.Event
|
Ainsi, si l’on souhaite réaliser une action lorsque l’utilisateur tape sur un bouton, rien de plus simple :
button.rx.controlEvent(.touchDown) .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) |
Et inversement, quand l’utilisateur relâche le bouton on pourra écrire le même code avec le ControlEvent .touchUp
.
C’est simple ? Oui ! Mais ce n’est pas tout, car il faut prendre en compte les autres évènements : .touchDragEnter
, .touchUpOutside
, .touchUpInside
, .touchDragOutside
et .touchCancel
… 🤯 Et là, le code devient vite verbeux… mais avec Rx et les extensions, tout devient plus lisible et agréable 🤩
Première étape : regrouper tous les évènements qui s’apparentent au « touchDown » ensemble, et pareil pour les « touchUp ». Au final, on souhaite simplement deux évènements : le « touchDown » et le « touchUp ».
extension Reactive where Base: UIControl { func onTouchDown() -> Observable<()> { return Observable.of(controlEvent(.touchDown), controlEvent(.touchDragEnter)) .merge() } func onTouchUp() -> Observable<()> { return Observable.of(controlEvent(.touchUpOutside), controlEvent(.touchUpInside), controlEvent(.touchDragOutside), controlEvent(.touchCancel)) .merge() } } |
Et l’implémentation sur un bouton :
button.rx.onTouchDown() .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) button.rx.onTouchUp() .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) |
Facile ! 🙌🏻
Néanmoins, il reste un petit souci… si vous tapez sur votre bouton, maintenez votre doigt dessus et le déplacez en dehors de la zone du bouton, l’évènement « onTouchUp » va être déclenché plusieurs fois, à cause du .touchDragOutside
. Ceci pourrait provoquer quelques effets de bords en fonction de l’instruction dans le .subscribe()… mais pas de panique, on va y remédier ! 🕺🏻
Revenons à notre idée de départ : avoir un événement « onTouchUp » et un « onTouchDown ». Pour cela, nous avons fusionné plusieurs flux Rx, et notre problème vient de là : quatre sources différentes peuvent émettre un « onTouchUp ». Il faudrait donc pouvoir appliquer un .distinctUntilChanged()
quelque part… reste à savoir où… 🧐…d’autant que tous les ControlEvent
émettent Void
, impossible de les distinguer donc 😰
Mais c’est bien sûr ! Il suffit de maper chaque ControlEvent
sur une String
et d’appliquer le .distinctUntilChanged()
dessus ! Et derrière, on remap vers Void
car on n’a pas besoin de notre String
. Donc on aurait :
extension Reactive where Base: UIControl { func onTouchDown() -> Observable<()> { return Observable.of(controlEvent(.touchDown).map { _ in "touchDown" }, controlEvent(.touchDragEnter).map { _ in "touchDragEnter" }) .merge() .distinctUntilChanged() .map { _ in () } } func onTouchUp() -> Observable<()> { return Observable.of(controlEvent(.touchUpOutside).map { _ in "touchUpOutside" }, controlEvent(.touchUpInside).map { _ in "touchUpInside" }, controlEvent(.touchDragOutside).map { _ in "touchDragOutside" }, controlEvent(.touchCancel).map { _ in "touchCancel" }) .merge() .distinctUntilChanged() .map { _ in () } } } |
Mais on a toujours un souci… et ce n’est plus le même cette fois-ci : parfois, on ne passe pas dans les flux « onTouchUp » et « onTouchDown »… et c’est logique ! Si vous faites simplement les actions suivantes :
- (A) tap sur le bouton
- (B) relâche
- (C) tap
- (D) relâche
le flux « onTouchDown » va émettre les events .touchDown
deux fois de suite (A et C) et « onTouchUp » les events .touchUpInside
deux fois également (B et D). Et donc le .distinctUntilChanged()
annulera les émissions C et D. Logique 🙃
Bon alors, qu’est ce qu’on fait maintenant ? 🤔
Il faut finalement regrouper tous nos flux dans un même Observable
, les maper vers deux String
« touchDown » et « touchUp », appliquer le .distinctUntilChanged()
et filtrer sur l’évènement souhaité. Ce qui nous donne :
button.rx.onTouchDown() .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) button.rx.onTouchUp() .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) extension Reactive where Base: UIControl { private static var touchDownEventName: String { return "touchDown" } private static var touchUpEventName: String { return "touchUp" } private var allControlEvents: Observable<String> { return Observable.of(controlEvent(.touchDown).map { _ in Reactive.touchDownEventName }, controlEvent(.touchDragEnter).map { _ in Reactive.touchDownEventName }, controlEvent(.touchUpOutside).map { _ in Reactive.touchUpEventName }, controlEvent(.touchUpInside).map { _ in Reactive.touchUpEventName }, controlEvent(.touchDragOutside).map { _ in Reactive.touchUpEventName }, controlEvent(.touchCancel).map { _ in Reactive.touchUpEventName }) .merge() } private func filterAllControlsEvents(for eventName: String) -> Observable<()> { return allControlEvents .distinctUntilChanged() .filter { $0 == eventName } .map { _ in () } } func onTouchDown() -> Observable<()> { return filterAllControlsEvents(for: Reactive.touchDownEventName) } func onTouchUp() -> Observable<()> { return filterAllControlsEvents(for: Reactive.touchUpEventName) } } |
Et cette fois-ci le résultat est parfait 🍾🎉
💡Petite astuce supplémentaire si vous souhaitez forcer un premier touchUp, appelez .startWith(())
:
button.rx.onTouchUp() .startWith(()) .subscribe(onNext: { _ in // do something }) .disposed(by: disposeBag) |
One more thing…
Grâce à cette extension, on peut très facilement créer une nouvelle interaction qui, à première vue, parait compliquée : répéter une instruction à interval régulier tant qu’un bouton reste appuyé. Un cas que vous pouvez rencontrer pour incrémenter un compteur ou faire une avance rapide sur un lecteur par exemple.
Il nous faut simplement transformer notre onTouchDown()
en un timer Rx qui sera déclenché jusqu’à ce que l’utilisateur onTouchUp()
notre UIButton
:
extension Reactive where Base: UIControl { func onTouchDown(triggerWithPeriod period: RxTimeInterval) -> Observable<()> { let touchUp = onTouchUp() return onTouchDown() .flatMapLatest { _ in Observable<Int>.timer((period + 0.5), period: period, scheduler: MainScheduler.instance) .startWith(0) .takeUntil(touchUp) } .map { _ in () } } } button.rx.onTouchDown(triggerWithPeriod: 0.2) .subscribe(onNext: { _ in // do one more thing }) .disposed(by: disposeBag) |
Dans cet exemple, le flux Rx sera déclenché toutes les 0,2 secondes. Le startWith(0)
permet de le déclencher au premier tap, et le premier paramètre du Observable<Int>.timer
(appelé dueTime
) permet de modifier le premier délai avant le déclenchement du timer (pour ainsi laisser le temps à l’utilisateur de relâcher son tap si besoin).
Enfin, le takeUntil()
permet de stopper le flux Rx dès qu’un autre flux Rx émet, le touchUp()
en l’occurence ✋🏻
Commentaire