RxSwift i animacje w iOS
Opublikowany: 2022-03-11Jeśli jesteś programistą iOS, który wykonał rozsądną ilość pracy z interfejsem użytkownika i pasjonuje się tym, musisz pokochać moc UIKit, jeśli chodzi o animacje. Animowanie UIView jest tak proste jak ciasto. Nie musisz dużo myśleć o tym, jak sprawić, by zanikało, obracało się, przesuwało lub zmniejszało/rozszerzało się w czasie. Jednak trochę się to angażuje, jeśli chcesz połączyć animacje ze sobą i skonfigurować zależności między nimi. Twój kod może być dość szczegółowy i trudny do naśladowania, z wieloma zagnieżdżonymi zamknięciami i poziomami wcięć.
W tym artykule dowiem się, jak wykorzystać moc reaktywnego frameworka, takiego jak RxSwift, aby kod wyglądał na znacznie czystszy, a także łatwiejszy do czytania i śledzenia. Pomysł przyszedł mi do głowy, gdy pracowałem nad projektem dla klienta. Ten konkretny klient był bardzo obeznany z interfejsem użytkownika (co idealnie pasowało do mojej pasji)! Chcieli, aby interfejs użytkownika ich aplikacji zachowywał się w bardzo szczególny sposób, z mnóstwem bardzo eleganckich przejść i animacji. Jednym z ich pomysłów było stworzenie intro do aplikacji, które opowiadałoby o tym, o czym była aplikacja. Chcieli, aby ta historia została opowiedziana za pomocą sekwencji animacji, a nie przez odtwarzanie wstępnie wyrenderowanego wideo, aby można je było łatwo podrasować i dostroić. RxSwift okazał się idealnym wyborem na taki problem, o czym mam nadzieję, że zdasz sobie sprawę, gdy skończysz artykuł.
Krótkie wprowadzenie do programowania reaktywnego
Programowanie reaktywne staje się podstawą i zostało przyjęte w większości nowoczesnych języków programowania. Istnieje wiele książek i blogów szczegółowo wyjaśniających, dlaczego programowanie reaktywne jest tak potężną koncepcją i jak pomaga w zachęcaniu do dobrego projektowania oprogramowania poprzez egzekwowanie pewnych zasad i wzorców projektowych. Daje również zestaw narzędzi, który może pomóc w znacznym zmniejszeniu bałaganu w kodzie.
Chciałbym poruszyć jeden aspekt, który bardzo mi się podoba — łatwość, z jaką można łączyć operacje asynchroniczne i wyrażać je w deklaratywny, łatwy do odczytania sposób.
Jeśli chodzi o Swift, istnieją dwa konkurencyjne frameworki, które pomagają przekształcić go w reaktywny język programowania: ReactiveSwift i RxSwift. Użyję RxSwift w moich przykładach nie dlatego, że jest lepszy, ale dlatego, że jestem z nim bardziej zaznajomiony. Zakładam, że Ty, Czytelniku, również go znasz, więc mogę przejść bezpośrednio do jego sedna.
Łączenie animacji: stara droga
Powiedzmy, że chcesz obrócić widok o 180°, a następnie go zaciemnić. Możesz skorzystać z zamknięcia completion
i zrobić coś takiego:
UIView.animate(withDuration: 0.5, animations: { self.animatableView.transform = CGAffineTransform(rotationAngle: .pi/2) }, completion: { _ in UIView.animate(withDuration: 0.5) { self.animatableView.alpha = 0 } })
Jest trochę nieporęczny, ale nadal w porządku. Ale co, jeśli chcesz wstawić jeszcze jedną animację pomiędzy, powiedzmy przesunąć widok w prawo po jego obróceniu i przed zniknięciem? Stosując to samo podejście, otrzymasz coś takiego:
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 }) }) })
Im więcej kroków do niego dodasz, tym bardziej będzie to rozłożone i nieporęczne. A jeśli zdecydujesz się zmienić kolejność niektórych kroków, będziesz musiał wykonać nietrywialną sekwencję wycinania i wklejania, która jest podatna na błędy.
Cóż, Apple najwyraźniej pomyślał o tym - oferują lepszy sposób na zrobienie tego, używając interfejsu API animacji opartego na klatkach kluczowych. Dzięki takiemu podejściu powyższy kod można przepisać w ten sposób:
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 }) })
To duża poprawa, a kluczowymi zaletami są:
- Kod pozostaje niezmienny niezależnie od tego, ile kroków do niego dodasz
- Zmiana kolejności jest prosta (z jednym zastrzeżeniem poniżej)
Wadą tego podejścia jest to, że musisz myśleć w kategoriach względnych czasów trwania, a zmiana bezwzględnego czasu lub kolejności kroków staje się trudna (a przynajmniej niezbyt prosta). Pomyśl tylko o tym, przez jakie obliczenia musiałbyś przejść i jakie zmiany musiałbyś wprowadzić do ogólnego czasu trwania i względnych czasów trwania/czasów rozpoczęcia dla każdej z animacji, jeśli zdecydowałeś, aby widok znikał w ciągu 1 sekundy zamiast połowy po drugie, zachowując wszystko inne bez zmian. To samo dotyczy zmiany kolejności kroków — musiałbyś ponownie obliczyć ich względne czasy rozpoczęcia.
Biorąc pod uwagę wady, nie uważam żadnego z powyższych podejść za wystarczająco dobre. Idealne rozwiązanie, którego szukam, powinno spełniać następujące kryteria:
- Kod musi pozostać płaski niezależnie od liczby kroków
- Powinienem móc łatwo dodawać/usuwać lub zmieniać kolejność animacji i zmieniać ich czas trwania niezależnie bez żadnych efektów ubocznych innych animacji
Animacje łańcuchowe: sposób RxSwift
Odkryłem, że używając RxSwift, mogę z łatwością osiągnąć oba te cele. RxSwift nie jest jedynym frameworkiem, którego możesz użyć do zrobienia czegoś takiego — każdy framework oparty na obietnicach, który pozwala owijać operacje asynchroniczne w metody, które można połączyć składniowo bez użycia bloków uzupełniania. Ale RxSwift ma znacznie więcej do zaoferowania dzięki szerokiej gamie operatorów, o których opowiemy nieco później.
Oto zarys, jak to zrobię:
- Zawijam każdą z animacji w funkcję, która zwraca obserwowalny typ
Observable<Void>
. - Ten obserwowalny wyemituje tylko jeden element przed zakończeniem sekwencji.
- Element zostanie wyemitowany, gdy tylko animacja opakowana przez funkcję zakończy się.
- Połączę te obserwable razem za pomocą operatora
flatMap
.
Tak mogą wyglądać moje funkcje:
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() } }
A oto jak to wszystko złożyłem:

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)
Z pewnością jest to znacznie więcej kodu niż w poprzednich implementacjach i może wyglądać na trochę przesadę dla tak prostej sekwencji animacji, ale piękno polega na tym, że można go rozszerzyć, aby obsłużyć dość złożone sekwencje animacji i jest bardzo łatwy do odczytania ze względu na deklaratywny charakter składni.
Gdy już to opanujesz, możesz tworzyć animacje tak złożone, jak film i mieć do dyspozycji szeroką gamę przydatnych operatorów RxSwift, których możesz użyć, aby wykonać rzeczy, które byłyby bardzo trudne do zrobienia przy użyciu któregokolwiek z wyżej wymienionych podejść.
Oto, jak możemy użyć operatora .concat
, aby mój kod był jeszcze bardziej zwięzły — część, w której animacje są łączone w łańcuch:
Observable.concat([ rotate(animatableView, duration: 0.5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Możesz wstawić opóźnienia między animacjami w ten sposób:
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)
Załóżmy teraz, że chcemy, aby widok obrócił się określoną liczbę razy, zanim zacznie się poruszać. I chcemy łatwo dostosować, ile razy powinien się obracać.
Najpierw stworzę metodę, która w sposób ciągły powtarza animację obrotu i emituje element po każdym obrocie. Chcę, żeby te rotacje zatrzymały się, gdy tylko obserwowalny zostanie usunięty. Mógłbym zrobić coś takiego:
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 } } }
A wtedy mój piękny łańcuch animacji mógłby wyglądać tak:
Observable.concat([ rotateEndlessly(animatableView, duration: 0.5).take(5), shift(animatableView, duration: 0.5), fade(animatableView, duration: 0.5) ]) .subscribe() .disposed(by: disposeBag)
Widzisz, jak łatwo jest kontrolować, ile razy widok zostanie obrócony — wystarczy zmienić wartość przekazaną do operatora take
.
Teraz chciałbym pójść o krok dalej w mojej implementacji, owijając każdą utworzoną przeze mnie funkcję animacji w rozszerzenie „Reactive” UIView (dostępne poprzez sufiks .rx
). Byłoby to bardziej zgodne z konwencjami RxSwift, w których funkcje reaktywne są zwykle dostępne za pośrednictwem sufiksu .rx
, aby było jasne, że zwracają obserwowalny.
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 } } } }
Dzięki temu mogę je połączyć w ten sposób:
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)
Gdzie iść stąd?
Jak pokazuje ten artykuł, uwalniając moc RxSwift, a kiedy już masz swoje prymitywne na miejscu, możesz naprawdę bawić się animacjami. Twój kod jest przejrzysty i łatwy do odczytania, i nie wygląda już jak „kod” — „opisujesz” sposób, w jaki twoje animacje są składane i po prostu ożywają! Jeśli chcesz zrobić coś bardziej rozbudowanego niż opisane tutaj, z pewnością możesz to zrobić, dodając więcej własnych prymitywów, zawierających inne typy animacji. Zawsze możesz również skorzystać z wciąż rosnącego arsenału narzędzi i frameworków opracowanych przez pasjonatów społeczności open source.
Jeśli jesteś konwerterem RxSwift, regularnie odwiedzaj repozytorium RxSwiftCommunity: zawsze możesz znaleźć coś nowego!
Jeśli szukasz więcej porad dotyczących interfejsu użytkownika, przeczytaj artykuł Jak zaimplementować projekt interfejsu użytkownika w systemie iOS z doskonałymi pikselami autorstwa innego Toptalera, Romana Stetsenko.
Uwagi do źródła (zrozumienie podstaw)
- Wikipedia — Swift (język programowania)
- Lynda.com - RxSwift: Wzorce projektowe dla programistów iOS
- We Heart Swift — podstawy UIView
- Angular — obserwowalne