RxSwift et animations dans iOS
Publié: 2022-03-11Si vous êtes un développeur iOS qui a effectué une quantité raisonnable de travail sur l'interface utilisateur et qui en est passionné, vous devez aimer la puissance d'UIKit en matière d'animations. Animer un UIView est aussi simple que du gâteau. Vous n'avez pas besoin de beaucoup réfléchir à la façon de le faire s'estomper, pivoter, bouger ou rétrécir/s'étendre au fil du temps. Cependant, cela devient un peu compliqué si vous souhaitez enchaîner des animations et établir des dépendances entre elles. Votre code peut finir par être assez verbeux et difficile à suivre, avec de nombreuses fermetures imbriquées et des niveaux d'indentation.
Dans cet article, j'explorerai comment appliquer la puissance d'un framework réactif tel que RxSwift pour rendre ce code beaucoup plus propre et plus facile à lire et à suivre. L'idée m'est venue alors que je travaillais sur un projet pour un client. Ce client particulier était très averti en matière d'interface utilisateur (ce qui correspondait parfaitement à ma passion) ! Ils voulaient que l'interface utilisateur de leur application se comporte d'une manière très particulière, avec de nombreuses transitions et animations très élégantes. L'une de leurs idées était d'avoir une introduction à l'application qui raconterait l'histoire de ce qu'était l'application. Ils voulaient que cette histoire soit racontée à travers une séquence d'animations plutôt qu'en jouant une vidéo pré-rendue afin qu'elle puisse être facilement modifiée et ajustée. RxSwift s'est avéré être un choix parfait pour ce problème, comme j'espère que vous vous en rendrez compte une fois que vous aurez terminé l'article.
Brève introduction à la programmation réactive
La programmation réactive devient un incontournable et a été adoptée dans la plupart des langages de programmation modernes. Il existe de nombreux livres et blogs expliquant en détail pourquoi la programmation réactive est un concept si puissant et comment elle aide à encourager une bonne conception de logiciels en appliquant certains principes et modèles de conception. Il vous donne également une boîte à outils qui peut vous aider à réduire considérablement l'encombrement du code.
J'aimerais aborder un aspect que j'aime beaucoup : la facilité avec laquelle vous pouvez enchaîner des opérations asynchrones et les exprimer de manière déclarative et facile à lire.
En ce qui concerne Swift, il existe deux frameworks concurrents qui vous aident à le transformer en un langage de programmation réactif : ReactiveSwift et RxSwift. J'utiliserai RxSwift dans mes exemples non pas parce que c'est mieux mais parce que je le connais mieux. Je supposerai que vous, le lecteur, le connaissez également afin que je puisse aller directement à l'essentiel.
Enchaîner les animations : à l'ancienne
Supposons que vous vouliez faire pivoter une vue de 180°, puis l'estomper. Vous pouvez utiliser la fermeture d' completion
et faire quelque chose comme ceci :
UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5) { self.animatableView.alpha = 0 } })
C'est un peu encombrant, mais ça reste correct. Mais que se passe-t-il si vous souhaitez insérer une autre animation entre les deux, par exemple déplacer la vue vers la droite après sa rotation et avant qu'elle ne disparaisse ? En appliquant la même approche, vous vous retrouverez avec quelque chose comme ceci :
UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5, animations: { self.animatableView.frame = self.animatableView.frame.offsetBy(dx: 50, dy: 0) }, completion: { _ in UIView.animate(withDuration: 0.5, animations: { self.animatableView.alpha = 0 }) }) })
Plus vous y ajoutez d'étapes, plus cela devient échelonné et encombrant. Et puis, si vous décidez de modifier l'ordre de certaines étapes, vous devrez effectuer une séquence de copier-coller non triviale, sujette aux erreurs.
Eh bien, Apple a évidemment pensé à cela - ils offrent une meilleure façon de le faire, en utilisant une API d'animations basée sur des images clés. Avec cette approche, le code ci-dessus pourrait être réécrit comme ceci :
UIView.animateKeyframes(withDuration: 1.5, delay: 0, options: [], animations: { UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.33, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }) UIView.addKeyframe(withRelativeStartTime: 0.33, relativeDuration: 0.33, animations: { self.animatableView.frame = self.animatableView.frame.offsetBy(dx: 50, dy: 0) }) UIView.addKeyframe(withRelativeStartTime: 0.66, relativeDuration: 0.34, animations: { self.animatableView.alpha = 0 }) })
C'est une grande amélioration, dont les principaux avantages sont :
- Le code reste plat quel que soit le nombre d'étapes que vous y ajoutez
- Changer l'ordre est simple (avec une mise en garde ci-dessous)
L'inconvénient de cette approche est que vous devez penser en termes de durées relatives et qu'il devient difficile (ou du moins pas très simple) de modifier le timing absolu ou l'ordre des étapes. Pensez simplement aux calculs que vous auriez à effectuer et au type de modifications que vous auriez à apporter à la durée globale et aux durées/heures de début relatives pour chacune des animations si vous décidiez de faire disparaître la vue en 1 seconde au lieu d'une demi-seconde seconde tout en gardant tout le reste identique. Il en va de même si vous souhaitez modifier l'ordre des étapes - vous devrez recalculer leurs heures de début relatives.
Compte tenu des inconvénients, je ne trouve aucune des approches ci-dessus assez bonne. La solution idéale que je recherche doit répondre aux critères suivants :
- Le code doit rester plat quel que soit le nombre d'étapes
- Je devrais pouvoir facilement ajouter/supprimer ou réorganiser des animations et modifier leurs durées indépendamment sans aucun effet secondaire sur les autres animations
Enchaînement d'animations : la méthode RxSwift
J'ai trouvé qu'en utilisant RxSwift, je peux facilement atteindre ces deux objectifs. RxSwift n'est pas le seul framework que vous pouvez utiliser pour faire quelque chose comme ça - tout framework basé sur des promesses qui vous permet d'encapsuler des opérations asynchrones dans des méthodes qui peuvent être enchaînées syntaxiquement sans utiliser de blocs de complétion fera l'affaire. Mais RxSwift a bien plus à offrir avec son éventail d'opérateurs, dont nous parlerons un peu plus tard.
Voici les grandes lignes de la façon dont je vais procéder :
- Je vais envelopper chacune des animations dans une fonction qui renvoie un observable de type
Observable<Void>
. - Cet observable n'émettra qu'un seul élément avant de terminer la séquence.
- L'élément sera émis dès que l'animation enveloppée par la fonction sera terminée.
- Je vais enchaîner ces observables ensemble à l'aide de l'opérateur
flatMap
.
Voici à quoi mes fonctions peuvent ressembler:

func rotate(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func shift(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.frame = view.frame.offsetBy(dx: 50, dy: 0) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func fade(_ view: UIView, duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { view.alpha = 0 }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } }
Et voici comment j'ai tout assemblé :
rotate(animatableView, duration: 0.5) .flatMap { [unowned self] in self.shift(self.animatableView, duration: 0.5) } .flatMap { [unowned self] in self.fade(self.animatableView, duration: 0.5) } .subscribe() .disposed(by: disposeBag)
C'est certainement beaucoup plus de code que dans les implémentations précédentes et peut sembler un peu exagéré pour une séquence d'animations aussi simple, mais la beauté est qu'il peut être étendu pour gérer des séquences d'animation assez complexes et est très facile à lire grâce à le caractère déclaratif de la syntaxe.
Une fois que vous maîtrisez cela, vous pouvez créer des animations aussi complexes qu'un film et avoir à votre disposition une grande variété d'opérateurs RxSwift pratiques que vous pouvez appliquer pour accomplir des choses qui seraient très difficiles à faire avec l'une des approches susmentionnées.
Voici comment nous pouvons utiliser l'opérateur .concat
pour rendre mon code encore plus concis, la partie où les animations sont enchaînées :
Observable.concat([ rotate(animatableView, duration: 0.5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Vous pouvez insérer des délais entre les animations comme ceci :
func delay(_ duration: TimeInterval) -> Observable<Void> { return Observable.of(()).delay(duration, scheduler: MainScheduler.instance) } Observable.concat([ rotate(animatableView, duration: 0.5), delay(0.5), shift(animatableView, duration: 0.5), delay(1), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Supposons maintenant que nous voulions que la vue tourne un certain nombre de fois avant de commencer à se déplacer. Et nous voulons modifier facilement le nombre de fois qu'il doit tourner.
Je vais d'abord créer une méthode qui répète l'animation de rotation en continu et émet un élément après chaque rotation. Je veux que ces rotations s'arrêtent dès que l'observable est éliminé. Je pourrais faire quelque chose comme ça :
func rotateEndlessly(_ view: UIView, duration: TimeInterval) -> Observable<Void> { var disposed = false return Observable.create { (observer) -> Disposable in func animate() { UIView.animate(withDuration: duration, animations: { view.transform = view.transform.rotated(by: .pi/2) }, completion: { (_) in observer.onNext(()) if !disposed { animate() } }) } animate() return Disposables.create { disposed = true } } }
Et puis ma belle chaîne d'animations pourrait ressembler à ça :
Observable.concat([ rotateEndlessly(animatableView, duration: 0.5).take(5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Vous voyez à quel point il est facile de contrôler le nombre de rotations de la vue : modifiez simplement la valeur transmise à l'opérateur de take
.
Maintenant, j'aimerais aller plus loin dans mon implémentation en enveloppant chacune des fonctions d'animation que j'ai créées dans l'extension "Réactive" de UIView (accessible via le suffixe .rx
). Cela le rendrait plus conforme aux conventions RxSwift, où les fonctions réactives sont généralement accessibles via le suffixe .rx
pour indiquer clairement qu'elles renvoient un observable.
extension Reactive where Base == UIView { func shift(duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { self.base.frame = self.base.frame.offsetBy(dx: 50, dy: 0) }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func fade(duration: TimeInterval) -> Observable<Void> { return Observable.create { (observer) -> Disposable in UIView.animate(withDuration: duration, animations: { self.base.alpha = 0 }, completion: { (_) in observer.onNext(()) observer.onCompleted() }) return Disposables.create() } } func rotateEndlessly(duration: TimeInterval) -> Observable<Void> { var disposed = false return Observable.create { (observer) -> Disposable in func animate() { UIView.animate(withDuration: duration, animations: { self.base.transform = self.base.transform.rotated(by: .pi/2) }, completion: { (_) in observer.onNext(()) if !disposed { animate() } }) } animate() return Disposables.create { disposed = true } } } }
Avec ça, je peux les assembler comme ceci:
Observable.concat([ animatableView.rx.rotateEndlessly(duration: 0.5).take(5), animatableView.rx.shift(duration: 0.5), animatableView.rx.fade(duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Où aller en partant d'ici
Comme cet article l'illustre, en libérant la puissance de RxSwift, et une fois que vous avez vos primitives en place, vous pouvez vous amuser avec les animations. Votre code est propre et facile à lire, et il ne ressemble plus à du « code » : vous « décrivez » comment vos animations sont assemblées et elles prennent simplement vie ! Si vous voulez faire quelque chose de plus élaboré que décrit ici, vous pouvez certainement le faire en ajoutant vos propres primitives encapsulant d'autres types d'animations. Vous pouvez également toujours profiter de l'arsenal toujours croissant d'outils et de frameworks développés par des passionnés de la communauté open source.
Si vous êtes un converti RxSwift, continuez à visiter régulièrement le référentiel RxSwiftCommunity : vous pouvez toujours trouver quelque chose de nouveau !
Si vous recherchez plus de conseils sur l'interface utilisateur, essayez de lire Comment mettre en œuvre une conception d'interface utilisateur iOS parfaite pour les pixels par un collègue Toptaler Roman Stetsenko.
Notes de source (comprendre les bases)
- Wikipédia - Swift (langage de programmation)
- Lynda.com - RxSwift : Modèles de conception pour les développeurs iOS
- We Heart Swift - Fondamentaux d'UIView
- Angulaire - Observables