Как стать автором
Обновить

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

Время на прочтение8 мин
Количество просмотров5.7K

Идея


На 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.


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

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии1

Публикации

Истории

Работа

Swift разработчик
31 вакансия
iOS разработчик
24 вакансии

Ближайшие события