RxSwift e animazioni in iOS

Pubblicato: 2022-03-11

Se sei uno sviluppatore iOS che ha svolto una discreta quantità di lavoro sull'interfaccia utente e ne è appassionato, devi amare la potenza di UIKit quando si tratta di animazioni. Animare un UIView è facile come una torta. Non devi pensare molto a come farlo sbiadire, ruotare, spostare o rimpicciolirsi/espandersi nel tempo. Tuttavia, è un po' complicato se si desidera concatenare le animazioni e impostare le dipendenze tra di esse. Il tuo codice potrebbe risultare piuttosto dettagliato e difficile da seguire, con molte chiusure nidificate e livelli di rientro.

In questo articolo, esplorerò come applicare la potenza di un framework reattivo come RxSwift per rendere il codice molto più pulito e più facile da leggere e da seguire. L'idea mi è venuta mentre stavo lavorando a un progetto per un cliente. Quel particolare cliente era molto esperto di interfaccia utente (che corrispondeva perfettamente alla mia passione)! Volevano che l'interfaccia utente della loro app si comportasse in un modo molto particolare, con un sacco di transizioni e animazioni molto eleganti. Una delle loro idee era quella di avere un'introduzione all'app che raccontasse la storia di cosa trattasse l'app. Volevano quella storia raccontata attraverso una sequenza di animazioni piuttosto che riproducendo un video pre-renderizzato in modo che potesse essere facilmente ottimizzato e sintonizzato. RxSwift si è rivelata una scelta perfetta per un problema del genere, come spero che ti renderai conto una volta terminato l'articolo.

Breve introduzione alla programmazione reattiva

La programmazione reattiva sta diventando un punto fermo ed è stata adottata nella maggior parte dei moderni linguaggi di programmazione. Ci sono molti libri e blog là fuori che spiegano in dettaglio perché la programmazione reattiva è un concetto così potente e come aiuta a incoraggiare una buona progettazione del software applicando determinati principi e modelli di progettazione. Ti offre anche un toolkit che può aiutarti a ridurre significativamente il disordine del codice.

Vorrei toccare un aspetto che mi piace molto: la facilità con cui è possibile concatenare operazioni asincrone ed esprimerle in modo dichiarativo e di facile lettura.

Quando si tratta di Swift, ci sono due framework concorrenti che ti aiutano a trasformarlo in un linguaggio di programmazione reattivo: ReactiveSwift e RxSwift. Userò RxSwift nei miei esempi non perché sia ​​migliore ma perché ne ho più familiarità. Presumo che anche tu, lettore, ne abbiate familiarità in modo che io possa arrivare direttamente al nocciolo della questione.

Animazioni concatenate: la vecchia maniera

Diciamo che vuoi ruotare una vista di 180° e poi sfumarla. Puoi utilizzare la chiusura del completion e fare qualcosa del genere:

 UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5) { self.animatableView.alpha = 0 } }) 

GIF animata di un quadrato rotante

È un po' ingombrante, ma va bene lo stesso. Ma cosa succede se si desidera inserire un'altra animazione in mezzo, ad esempio spostare la vista a destra dopo che è stata ruotata e prima che svanisca? Applicando lo stesso approccio, ti ritroverai con qualcosa del genere:

 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 }) }) }) 

GIF animata del rettangolo che ruota, si interrompe e quindi scorre verso destra

Più passaggi aggiungi, più sfalsato e ingombrante diventa. E poi se decidi di cambiare l'ordine di alcuni passaggi, dovrai eseguire alcune sequenze taglia e incolla non banali, che sono soggette a errori.

Bene, Apple ci ha ovviamente pensato: offrono un modo migliore per farlo, utilizzando un'API di animazione basata su fotogrammi chiave. Con questo approccio, il codice sopra potrebbe essere riscritto in questo modo:

 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 }) })

Questo è un grande miglioramento, con i principali vantaggi:

  1. Il codice rimane piatto indipendentemente dal numero di passaggi aggiunti
  2. La modifica dell'ordine è semplice (con un avvertimento di seguito)

Lo svantaggio di questo approccio è che devi pensare in termini di durate relative e diventa difficile (o almeno non molto semplice) cambiare la tempistica assoluta o l'ordine dei passaggi. Pensa solo a quali calcoli dovresti eseguire e che tipo di modifiche dovresti apportare alla durata complessiva e alle durate relative/ora di inizio per ciascuna delle animazioni se decidessi di far sfumare la vista entro 1 secondo invece di mezzo secondo mantenendo tutto il resto uguale. Lo stesso vale se si desidera modificare l'ordine dei passaggi: è necessario ricalcolare i relativi orari di inizio.

Dati gli svantaggi, non trovo nessuno degli approcci di cui sopra abbastanza buono. La soluzione ideale che cerco dovrebbe soddisfare i seguenti criteri:

  1. Il codice deve rimanere piatto indipendentemente dal numero di passaggi
  2. Dovrei essere in grado di aggiungere/rimuovere o riordinare facilmente le animazioni e cambiarne la durata indipendentemente senza effetti collaterali rispetto ad altre animazioni

Animazioni concatenate: il modo RxSwift

Ho scoperto che usando RxSwift posso facilmente raggiungere entrambi questi obiettivi. RxSwift non è l'unico framework che potresti usare per fare qualcosa del genere: andrà bene qualsiasi framework basato su promesse che ti consente di racchiudere operazioni asincrone in metodi che possono essere concatenati sintatticamente senza utilizzare i blocchi di completamento. Ma RxSwift ha molto di più da offrire con la sua gamma di operatori, di cui parleremo un po' più avanti.

Ecco lo schema di come lo farò:

  1. Avvolgerò ciascuna delle animazioni in una funzione che restituisce un osservabile di tipo Observable<Void> .
  2. Quell'osservabile emetterà un solo elemento prima di completare la sequenza.
  3. L'elemento verrà emesso non appena l'animazione racchiusa dalla funzione verrà completata.
  4. Concatenerò questi osservabili insieme usando l'operatore flatMap .

Ecco come possono apparire le mie funzioni:

 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() } }

Ed ecco come ho messo insieme il tutto:

 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)

È sicuramente molto più codice rispetto alle precedenti implementazioni e può sembrare un po' eccessivo per una sequenza di animazioni così semplice, ma il bello è che può essere esteso per gestire alcune sequenze di animazione piuttosto complesse ed è molto facile da leggere grazie a il carattere dichiarativo della sintassi.

Una volta che hai capito, puoi creare animazioni complesse come un film e avere a tua disposizione una grande varietà di utili operatori RxSwift che puoi applicare per realizzare cose che sarebbero molto difficili da fare con uno qualsiasi degli approcci sopra menzionati.

Ecco come possiamo usare l'operatore .concat per rendere il mio codice ancora più conciso, la parte in cui le animazioni vengono concatenate insieme:

 Observable.concat([ rotate(animatableView, duration: 0.5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)

Puoi inserire ritardi tra le animazioni in questo modo:

 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)

Ora, supponiamo di volere che la vista ruoti un certo numero di volte prima che inizi a muoversi. E vogliamo modificare facilmente quante volte dovrebbe ruotare.

Per prima cosa creerò un metodo che ripete continuamente l'animazione di rotazione ed emette un elemento dopo ogni rotazione. Voglio che queste rotazioni si interrompano non appena l'osservabile viene eliminato. Potrei fare qualcosa del genere:

 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 } } }

E poi la mia bella catena di animazioni potrebbe assomigliare a questa:

 Observable.concat([ rotateEndlessly(animatableView, duration: 0.5).take(5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)

Vedete quanto è facile controllare quante volte la vista ruoterà: basta cambiare il valore passato all'operatore take .

GIF animata dell'animazione migliorata

Ora, vorrei fare un ulteriore passo avanti nella mia implementazione avvolgendo ciascuna delle funzioni di animazione che ho creato nell'estensione "Reattiva" di UIView (accessibile tramite il suffisso .rx ). Ciò lo renderebbe più in linea con le convenzioni RxSwift, in cui di solito si accede alle funzioni reattive tramite il suffisso .rx per chiarire che stanno restituendo un osservabile.

 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 } } } }

Con quello, posso metterli insieme in questo modo:

 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)

Dove andare da qui

Come illustra questo articolo, scatenando la potenza di RxSwift e una volta che hai le tue primitive a posto, puoi divertirti davvero con le animazioni. Il tuo codice è pulito e facile da leggere e non sembra più "codice": "descrivi" come sono assemblate le tue animazioni e prendono vita! Se vuoi fare qualcosa di più elaborato di quanto descritto qui, puoi sicuramente farlo aggiungendo più primitive che incapsulano altri tipi di animazioni. Puoi anche sfruttare sempre l'arsenale in continua crescita di strumenti e framework sviluppati da alcune persone appassionate della comunità open source.

Se sei un convertito RxSwift, continua a visitare regolarmente il repository RxSwiftCommunity: puoi sempre trovare qualcosa di nuovo!

Se stai cercando altri consigli sull'interfaccia utente, prova a leggere Come implementare un'interfaccia utente iOS perfetta per i pixel Design del collega Toptaler Roman Stetsenko.


Note sulla fonte (Capire le basi)

  • Wikipedia - Swift (linguaggio di programmazione)
  • Lynda.com - RxSwift: modelli di progettazione per sviluppatori iOS
  • We Heart Swift - Fondamenti di UIView
  • Angolare - Osservabili