Published by

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 ✋🏻

Published by

Publié par Florent Capon

Je suis consultant iOS et je développe en Objective-C / Swift depuis 2012. Je suis dans le monde du Web et passionné depuis le début des années 2000, où j'ai notamment pu toucher à de nombreuses technos back & front comme le HTML, PHP, MySQL, ActionScript. Aujourd'hui, ma passion est clairement tournée vers le mobile et notamment iOS. Fort de mes 6 années passées à développer des sites en Flash, j'aime apporter un soin particulier à l'animation et à la réalisation des applications sur lesquelles je travaille.

Commentaire

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.