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

Особенно интересным показалось обилие нетривиальной анимации. Таким образом, по ходу реализации хотелось проверить, насколько 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.
Статья написана моим коллегой Алексеем Кубаревым и опубликована для сообщества по его просьбе.
