Animasi nontrivial di SwiftUI

Ide


SwiftUI diperkenalkan di WWDC 2019, sebuah teknologi yang secara fundamental memengaruhi pembuatan UI dalam aplikasi untuk ekosistem Apple. Kami di Distillery tertarik untuk memahaminya sedikit lebih dalam dari apa yang disajikan dalam contoh Apple. Idealnya, perlu membungkam beberapa komponen yang berguna untuk tim iOS dan komunitas UI. Ternyata ide-ide tentang ini ketat, jadi kami memutuskan untuk memotong sesuatu yang lucu. Ini konsep yang terinspirasi oleh :


gambar


. , , 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.


.


All Articles