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