Нетривиальные анимации в SwiftUI

—

от автора

Идея

На WWDC 2019 была представлена SwiftUI — технология коренным образом влияющая на создание UI в приложениях для экосистемы Apple. Нам в Distillery стало интересно в ней разобраться чуть глубже, чем это подано в примерах от Apple. В идеале нужно было запилить какой-нибудь полезный для iOS команды и сообщества UI компонент. С идеями по этому поводу оказалось туго, поэтому решили пилить что-то просто забавное. Вдохновил вот этот концепт:

image

Особенно интересным показалось обилие нетривиальной анимации. Таким образом, по ходу реализации хотелось проверить, насколько SwiftUI удобен и приспособлен для чего-то более сложного, чем почти статический UI из примеров WWDC 2019.

Результат

Покажу сразу, что получилось:

Доступно на гитхабе и в CocoaPods.

Использование

В папке Example есть пример. Вот как в файле ExampleView.swift используется RainbowBar:

RainbowBar(waveEmitPeriod: 0.3,            visibleWavesCount: 3,            waveColors: [.red, .green, .blue],            backgroundColor: .white,            animated: animatedSignal) {                 self.running = false }

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

Краткое описание параметров:

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

visibleWavesCount — количество волн, одновременно видимых на каждой из половинок бара.

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

backgroundColor — финальный цвет замыкающей градиентной волны. К сожалению, пока без прозрачности) Подразумевается, что фон под баром будет иметь однородный цвет, и этот параметр будет равняться ему.

animated — Combine-сигнал для запуска и остановки анимации типа PassthroughSubject<Bool, Never>()

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

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

Реализация

Сам бар состоит из двух идентичных половинок WavesView. Одна из них развернута вокруг вертикальной оси Y с помощью .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0), anchor: .center). Между ними лежит Spacer с шириной centerSpacing. Всё это находится внутри HStack с высотой height:

public var body: some View {     HStack {         WavesView(waveEmitPeriod: waveEmitPeriod,                   visibleWavesCount: visibleWavesCount,                   waveColors: waveColors,                   backgroundColor: backgroundColor,                   topCornerRadius: waveTopCornerRadius,                   bottomCornerRadius: waveBottomCornerRadius,                   animatedSignal: animated,                   completion: completion)             .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0), anchor: .center)         Spacer().frame(width: centerSpacing)         WavesView(waveEmitPeriod: waveEmitPeriod,                   visibleWavesCount: visibleWavesCount,                   waveColors: waveColors,                   backgroundColor: backgroundColor,                   topCornerRadius: waveTopCornerRadius,                   bottomCornerRadius: waveBottomCornerRadius,                   animatedSignal: animated,                   completion: nil)     }.frame(height: height) }

WavesView является ZStack`ом WaveView

ZStack {     ForEach(waveNodes) { node in         WaveView(animationDuration: self.animationDuration,                  animationFinished: self.waveFinished,                  node: node,                  topCornerRadius: self.topCornerRadius,                  bottomCornerRadius: self.bottomCornerRadius)     } }

который строится из массива моделей волн @State private var waveNodes = [WaveNode](). Изменяется он при старте/стопе анимации в сеттере animatedInnerState:

@State private var animatedInnerState: Bool = false {     didSet {         if animatedInnerState {             var res = [NotchWaveNode]()             for index in 0..<visibleWavesCount {                 guard let color = self.colorEmitter.nextColor(from: self.waveColors) else { continue }                 let newNode = NotchWaveNode(color: color,                                             delay: waveEmitPeriod * Double(index))                 res.append(newNode)             }             waveNodes = res         } else {             waveNodes.removeAll {                 !$0.started             }             if let lastVisibleNode = waveNodes.last as? NotchWaveNode {                 let gradientNode = GradientWaveNode(frontColor: lastVisibleNode.color,                                                     backColor: backgroundColor,                                                     animationDuration: animationDuration,                                                     delay: 0,                                                     animationFinished: self.waveFinished)                 waveNodes.append(gradientNode)             }         }     } }

и при завершении волны:

onReceive(waveFinished) { node in     if node is GradientWaveNode, let completion = self.completion {         DispatchQueue.main.async {             completion()         }         return     }      // remove invisible (lower, first) node?     if self.waveNodes.count > 0 {         var removeFirstNode = false         if self.waveNodes.count > 1 {             removeFirstNode = self.waveNodes[1].finished         }         if removeFirstNode {             self.waveNodes.removeFirst()         }     }      //add new color (node)     if self.animatedInnerState, let color = self.colorEmitter.nextColor(from: self.waveColors) {         let newNode = NotchWaveNode(color: color, delay: 0)         self.waveNodes.append(newNode)     } }

Отдельные модели (ноды) нужны для надёжного хранения и изменения состояния. Жизненный цикл структур используемых в SwiftUI нетривиален и неудобен — нет явного деструктора. К тому же изменять поля структуры в body нельзя, кроме полей, помеченных @State.

Первые visibleWavesCount волн имеют задержки анимации (node.delay), отличные от нуля. Остальные же, что будут добавлены уже после завершения первой волны, будут иметь нулевую задержку. Особо стоит отметить drawingGroup, применяемый к ZStack — это ускоряет рендеринг множества вьюшек.

Теперь рассмотрим WaveView, которые генерились для каждой ноды:

func makeWave(from node: WaveNode) -> some View {     let phase: CGFloat = self.animated ? 1.0 : 0.0     if let notchNode = node as? NotchWaveNode {         return AnyView(NotchWave(phase: phase,                                  animationFinished: self.animationFinished,                                  node: notchNode,                                  topCornerRadius: topCornerRadius,                                  bottomCornerRadius: bottomCornerRadius).foregroundColor(notchNode.color))     } else if let gradientNode = node as? GradientWaveNode {         return AnyView(GradientWave(phase: phase,                                     frontColor: gradientNode.frontColor,                                     backColor: gradientNode.backColor,                                     node: gradientNode,                                     minWidth: topCornerRadius + bottomCornerRadius))     } else {         return AnyView(EmptyView())     } }  var body: some View {     return makeWave(from: node).animation(Animation.easeIn(duration: animationDuration).delay(node.delay)).onAppear {         self.animated.toggle()     } }

По сути это просто "анимационная прокладка" для конкретных (NotchWave, GradientWave) вьюшек, но это неточно, нод. Самое важное тут — это старт анимации при отображении вьюшки. easeIn делает движение волн ускорящимся ближе к краям экрана.

Оказывается, что NotchWave — это не вьюшка (реализатор протокола View), а Shape — точнее реализатор этого протокола.

struct NotchWave: Shape {     var phase: CGFloat     var animationFinished: AnimationSignal     var node: NotchWaveNode     var topCornerRadius, bottomCornerRadius: CGFloat      var animatableData: CGFloat {         get { return phase }         set { phase = newValue }     }      func path(in rect: CGRect) -> Path {         if !self.node.started && self.phase > 0.0 {             self.node.started = true         }          DispatchQueue.main.async {             if self.phase >= 1.0 {                 self.node.finished = true                 self.animationFinished.send(self.node)             }         }          var p = Path()          p.move(to: CGPoint.zero)          let currentWidth = 2 * (topCornerRadius + bottomCornerRadius) + rect.size.width * phase         p.addLine(to: CGPoint(x: currentWidth, y: 0))          let topArcCenter = CGPoint(x: currentWidth, y: topCornerRadius)         p.addArc(center: topArcCenter, radius: topCornerRadius, startAngle: .degrees(270), endAngle: .degrees(180), clockwise: true)          let height = rect.size.height         p.addLine(to: CGPoint(x: currentWidth - topCornerRadius, y: height - bottomCornerRadius))          let bottomArcCenter = CGPoint(x: currentWidth - topCornerRadius - bottomCornerRadius, y: height - bottomCornerRadius)         p.addArc(center: bottomArcCenter, radius: bottomCornerRadius, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)          p.addLine(to: CGPoint(x: 0, y: height))          p.closeSubpath()          return p     } }

В animatableData указан параметр phase, который будет анимироваться извне. Если определить его как @State, что логично, то анимация не будет работать.

Обработчик завершения отрисовки реализован с помощью GCD и рассчитан на выполнение в главном потоке, который используется для отрисовки UI, сразу (или почти) после выполнения функции path. К сожалению, пока в SwiftUI нет родного обработчика завершения анимации, подобного completion в animateWithDuration из UIKit:

DispatchQueue.main.async {     if self.phase >= 1.0 {         self.node.finished = true         self.animationFinished.send(self.node)     } }

Помимо цветовой волны есть также завершающая градиентная волна. Она реализована иначе — реализует протокол View:

struct GradientWave: View {     var phase: CGFloat     var frontColor, backColor: Color     var node: GradientWaveNode     var minWidth: CGFloat      var body: some View {         if self.phase == 0 {             node.startAnimationTimer()         }          return GeometryReader { geometry in             HStack(spacing: 0) {                 Rectangle().foregroundColor(self.backColor).frame(width: (geometry.size.width + self.minWidth) * self.phase)                  Rectangle().fill(LinearGradient(gradient: Gradient(colors: [self.backColor, self.frontColor]), startPoint: .leading, endPoint: .trailing)).frame(width: self.minWidth)                  Spacer()             }         }     } }

Градиентная волна представляет собой горизонтальный стек: прямоугольника цвета backgroundColor, линейного градиента от backgroundColor до цвета последней видимой волны NotchWave. В отличие от path(in rect: CGRect) -> Path в NotchWave тут можно использовать GeometryReader и брать ширину из него.

Обрабатывать завершение анимации как NotchWave не получится, так как var body: some View вызывается сразу два раза с крайними значениями анимируемого phase. Поэтому тут используется startAnimationTimer() из GradientWaveNode:

class GradientWaveNode: WaveNode {     let frontColor, backColor: Color     let animationDuration: Double     let animationFinished: AnimationSignal      private var timer: Timer?      func startAnimationTimer() {         self.timer = Timer.scheduledTimer(withTimeInterval: animationDuration, repeats: false) { _ in             self.animationFinished.send(self)         }     }      init(frontColor: Color, backColor: Color, animationDuration: Double, delay: Double, animationFinished: AnimationSignal) {         self.frontColor = frontColor         self.backColor = backColor         self.animationDuration = animationDuration         self.animationFinished = animationFinished          super.init(delay: delay)     }      deinit {         timer?.invalidate()     } }

то есть сигнал о завершении анимации посылает просто таймер по прошествию времени, равному длительности анимации. Не самый точный и простой способ. Но, как говорилось выше, пока в SwiftUI нет родного обработчика завершения анимации(

Нода цветовой волны проста и интереса не представляет:

class NotchWaveNode: WaveNode {     let color: Color      init(color: Color, delay: Double) {         self.color = color         super.init(delay: delay)     } }

Базовый класс нод:

class WaveNode: Identifiable {     let id = UUID()     let delay: Double      var started: Bool = false     var finished: Bool = false      init(delay: Double) {         self.delay = delay     } }

Он интересен разве что реализацией протокола Identifiable, который необходим для использования массива нод в ForEach. В случае встроенных типов для ForEach проще использовать параметр id: \.self. В нашем же случае, со своим классом, это потребовало бы от него соответствия протоколу Hashable. Что сравнимо по количеству кода с реализованным соответствием Identifiable и менее элегантно.

Перебор цветов для новых волн вынесен в отдельный класс ColorEmitter, фактически делающий из массива цветов кольцевой буфер. Сделано это, потому что в SwiftUI нельзя без State менять состояние из body:

class ColorEmitter {     var colors, refColors: [Color]?      func nextColor(from newColors: [Color]) -> Color? {         if !(refColors?.elementsEqual(newColors) ?? false) {             colors = newColors             refColors = newColors         }          let res = colors?.removeFirst()         if let res = res {             colors?.append(res)         }         return res     } }

Заключение

В целом, сейчас SwiftUI производит впечатление незавершенности. Как и Combine по сравнению с другими rx-фреймворками. Он хорош для всего, что раньше делалось в Interface builder: разметки стандартного UI и не сильно динамического его наполнения. Плюс немного магии в виде отсутствия конфликтов разметки и автоматического dark mode. Для более сложного рендеринга SwiftUI не имеет даже обработчика завершения анимации. Лично я с нетерпением жду новую версию. Изучить и попробовать его (как и Combine) советую уже сейчас.

Исходники компонента доступны здесь.
Подключить в свой проект можно с помощью CocoaPods.

Статья написана моим коллегой Алексеем Кубаревым и опубликована для сообщества по его просьбе.

ссылка на оригинал статьи https://habr.com/ru/post/501790/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *