RxSwift и анимация в iOS

Опубликовано: 2022-03-11

Если вы являетесь разработчиком iOS, который проделал разумный объем работы с пользовательским интерфейсом и увлечен этим, вам должны понравиться возможности UIKit, когда дело доходит до анимации. Анимировать UIView проще простого. Вам не нужно много думать о том, как заставить его исчезать, вращаться, двигаться или уменьшаться/расширяться с течением времени. Однако это немного усложняется, если вы хотите связать анимации вместе и установить зависимости между ними. Ваш код может оказаться довольно многословным и трудным для понимания, с множеством вложенных замыканий и уровней отступов.

В этой статье я расскажу, как применить мощь реактивной среды, такой как RxSwift, чтобы сделать этот код более чистым, а также более легким для чтения и выполнения. Идея пришла ко мне, когда я работал над проектом для клиента. Этот конкретный клиент очень хорошо разбирался в пользовательском интерфейсе (что идеально соответствовало моей страсти)! Они хотели, чтобы пользовательский интерфейс их приложения вел себя особым образом, со множеством плавных переходов и анимаций. Одна из их идей заключалась в том, чтобы сделать вступление к приложению, в котором рассказывалось бы, о чем оно. Они хотели, чтобы эта история рассказывалась с помощью последовательности анимаций, а не путем воспроизведения предварительно обработанного видео, чтобы его можно было легко настроить и настроить. RxSwift оказался идеальным выбором для подобной проблемы, и я надеюсь, что вы поймете это, когда закончите статью.

Краткое введение в реактивное программирование

Реактивное программирование становится основным продуктом и используется в большинстве современных языков программирования. Существует множество книг и блогов, в которых подробно объясняется, почему реактивное программирование является такой мощной концепцией и как оно помогает поощрять хороший дизайн программного обеспечения, применяя определенные принципы и шаблоны проектирования. Он также предоставляет вам набор инструментов, который может помочь вам значительно уменьшить беспорядок в коде.

Я хотел бы коснуться одного аспекта, который мне очень нравится, — легкости, с которой вы можете связывать асинхронные операции и выражать их декларативным, легко читаемым способом.

Когда дело доходит до Swift, есть две конкурирующие среды, которые помогут вам превратить его в реактивный язык программирования: ReactiveSwift и RxSwift. Я буду использовать RxSwift в своих примерах не потому, что он лучше, а потому, что я лучше с ним знаком. Я предполагаю, что вы, читатель, тоже с ним знакомы, так что я могу сразу перейти к сути.

Цепочка анимаций: старый способ

Допустим, вы хотите повернуть вид на 180°, а затем затемнить его. Вы можете использовать закрытие completion и сделать что-то вроде этого:

 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 вращающегося квадрата

Это немного громоздко, но все же хорошо. Но что, если вы хотите вставить еще одну анимацию между ними, скажем, сместить вид вправо после того, как он повернется и до того, как он исчезнет? Применяя тот же подход, вы получите что-то вроде этого:

 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 прямоугольника, вращающегося, приостанавливающегося и затем скользящего вправо

Чем больше шагов вы добавляете к нему, тем более ступенчатым и громоздким он становится. И тогда, если вы решите изменить порядок определенных шагов, вам придется выполнить какую-то нетривиальную последовательность вырезания и вставки, которая подвержена ошибкам.

Что ж, Apple, очевидно, подумала об этом — они предлагают лучший способ сделать это, используя API анимации на основе ключевых кадров. При таком подходе приведенный выше код можно было бы переписать так:

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

Это большое улучшение, основные преимущества которого заключаются в следующем:

  1. Код остается неизменным независимо от того, сколько шагов вы добавляете к нему.
  2. Изменить порядок просто (с одной оговоркой ниже)

Недостатком этого подхода является то, что вы должны мыслить в терминах относительной продолжительности, и становится сложно (или, по крайней мере, не очень просто) изменить абсолютное время или порядок шагов. Просто подумайте о том, какие расчеты вам придется выполнить и какие изменения вам придется внести в общую продолжительность и относительную продолжительность/время начала для каждой из анимаций, если вы решите, чтобы вид исчезал в течение 1 секунды, а не полсекунды. во-вторых, сохраняя все остальное то же самое. То же самое происходит, если вы хотите изменить порядок шагов — вам придется пересчитать их относительное время начала.

Учитывая недостатки, я не нахожу ни один из вышеперечисленных подходов достаточно хорошим. Идеальное решение, которое я ищу, должно удовлетворять следующим критериям:

  1. Код должен оставаться плоским независимо от количества шагов.
  2. Я должен иметь возможность легко добавлять/удалять или изменять порядок анимаций и независимо изменять их продолжительность без каких-либо побочных эффектов для других анимаций.

Цепочка анимаций: путь RxSwift

Я обнаружил, что с помощью RxSwift я могу легко достичь обеих этих целей. RxSwift — не единственный фреймворк, который вы можете использовать для чего-то подобного — подойдет любой фреймворк, основанный на промисах, который позволяет оборачивать асинхронные операции в методы, которые могут быть синтаксически связаны друг с другом без использования блоков завершения. Но RxSwift может предложить гораздо больше благодаря своему набору операторов, о которых мы поговорим чуть позже.

Вот схема того, как я собираюсь это сделать:

  1. Я оборачиваю каждую анимацию в функцию, которая возвращает наблюдаемое значение типа Observable<Void> .
  2. Этот наблюдаемый будет испускать только один элемент до завершения последовательности.
  3. Элемент будет испущен, как только анимация, обернутая функцией, завершится.
  4. Я свяжу эти наблюдаемые вместе, используя оператор flatMap .

Вот как могут выглядеть мои функции:

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

И вот как я все это собрал:

 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)

Кода, безусловно, намного больше, чем в предыдущих реализациях, и он может показаться излишним для такой простой последовательности анимаций, но прелесть в том, что его можно расширить для обработки некоторых довольно сложных последовательностей анимаций, и его очень легко читать из-за декларативный характер синтаксиса.

Как только вы разберетесь с этим, вы сможете создавать анимацию столь же сложную, как фильм, и иметь в своем распоряжении большое количество удобных операторов RxSwift, которые вы можете применять для выполнения задач, которые было бы очень сложно сделать с помощью любого из вышеупомянутых подходов.

Вот как мы можем использовать оператор .concat , чтобы сделать мой код еще более лаконичным — часть, где анимации соединяются вместе:

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

Вы можете вставлять задержки между анимациями следующим образом:

 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)

Теперь давайте предположим, что мы хотим, чтобы представление вращалось определенное количество раз, прежде чем оно начнет двигаться. И мы хотим легко настроить, сколько раз он должен вращаться.

Сначала я создам метод, который непрерывно повторяет анимацию вращения и испускает элемент после каждого вращения. Я хочу, чтобы эти вращения прекратились, как только наблюдаемое будет удалено. Я мог бы сделать что-то вроде этого:

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

И тогда моя красивая цепочка анимаций могла бы выглядеть так:

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

Вы видите, как легко контролировать, сколько раз будет вращаться вид — просто измените значение, переданное оператору take .

Анимированный GIF улучшенной анимации

Теперь я хотел бы сделать свою реализацию еще на один шаг, обернув каждую функцию анимации, которую я создал, в «реактивное» расширение UIView (доступное через суффикс .rx ). Это сделало бы его более похожим на соглашения RxSwift, где доступ к реактивным функциям обычно осуществляется через суффикс .rx , чтобы было ясно, что они возвращают наблюдаемое.

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

При этом я могу собрать их вместе следующим образом:

 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)

Куда пойти отсюда

Как показано в этой статье, высвобождая мощь RxSwift и располагая примитивами, вы можете по-настоящему повеселиться с анимацией. Ваш код чист и легко читается, и он больше не выглядит как «код» — вы «описываете», как ваши анимации собираются вместе, и они просто оживают! Если вы хотите сделать что-то более сложное, чем описано здесь, вы, безусловно, можете сделать это, добавив больше собственных примитивов, инкапсулирующих другие типы анимации. Вы также всегда можете воспользоваться постоянно растущим арсеналом инструментов и сред, разработанных некоторыми увлеченными людьми в сообществе открытого исходного кода.

Если вы перешли на RxSwift, регулярно посещайте репозиторий RxSwiftCommunity: вы всегда можете найти что-то новое!

Если вам нужны дополнительные советы по пользовательскому интерфейсу, попробуйте прочитать статью « Как реализовать пиксель-идеальный дизайн пользовательского интерфейса iOS» от коллеги по Toptaler Романа Стеценко.


Примечания к источникам (понимание основ)

  • Википедия - Swift (язык программирования)
  • Lynda.com - RxSwift: шаблоны проектирования для разработчиков iOS
  • We Heart Swift — основы UIView
  • Угловой — наблюдаемые