الرسوم المتحركة غير التقليدية في SwiftUI

فكرة


تم تقديم SwiftUI في WWDC 2019 ، وهي تقنية تؤثر بشكل أساسي على إنشاء واجهات المستخدم في التطبيقات لنظام Apple البيئي. أصبحنا في Distillery مهتمين بفهمها بشكل أعمق قليلاً مما هو معروض في أمثلة Apple. من الناحية المثالية ، كان من الضروري الحصول على بعض المكونات المفيدة لفريق iOS ومجتمع واجهة المستخدم. اتضح أنه كان مشدودًا بالأفكار حول هذا ، لذلك قررنا قطع شيء مضحك. هذا المفهوم مستوحى من :


صورة


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