在 SwiftUI 中停止后重新启动永久动画

Restarting perpetual animation after stopping in SwiftUI

背景

在这个学习应用程序中,我遵循了 Hacking with Swift on generating a wave-like animation 的优秀教程。我修改了这个应用程序,进一步添加了一些功能:

问题

停止动画后不再“运行”。下面的 gif 演示了这一点。

动画停止后不重新开始。

代码

//
//  ContentView.swift
//  WaveExample
//
//  Created by Konrad on 28/07/2021.
//  Original tutorial: https://www.hackingwithswift.com/plus/custom-swiftui-components/creating-a-waveview-to-draw-smooth-waveforms
//

import SwiftUI

/**
 Creates wave shape object
 - Parameter strength: How tall the wave should be
 - Parameter frequency: How densly the wave should be packed
 - returns: Shape
 */
struct Wave: Shape {
    // Basic wave characteristics
    var strength: Double    // Height
    var frequency: Double   // Number of hills
    var phase: Double       // Offsets the wave, can be used to animate the view

    // Required to define that animation relates to moving the wave from left to right
    var animatableData: Double {
        get { phase }
        set { self.phase = newValue }
    }

    // Path drawing function
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath()

        // Basic waveline characteristics
        let width = Double(rect.width)
        let height = Double(rect.height)
        let midWidth = width / 2
        let midHeight = height / 2
        let wavelength = width / frequency
        let oneOverMidWidth = 1 / midWidth

        // Path characteristics
        path.move(to: CGPoint(x: 0, y: midHeight))

        // By determines the nmber of calculations, can be decreased to run faster
        for xPosition in stride(from: 0, through: width, by: 1) {
            let relativeX = xPosition / wavelength          // How far we are from the start point
            let distanceFromMidWidth = xPosition - midWidth // Distance from the middle of the space
            let normalDistance = distanceFromMidWidth * oneOverMidWidth // Get values from -1 to 1, normalize
            // let parabola = normalDistance // Small waves in the middle
            let parabola = -(normalDistance * normalDistance) + 1 // Big wave in the middle
            let sine = sin(relativeX + phase)       // Offset based on phase
            let yPosition = parabola * strength * sine + midHeight     // Moving this halfway
            path.addLine(to: CGPoint(x: xPosition, y: yPosition))
        }

        return Path(path.cgPath)
    }
}

struct Line: Shape {
    func path(in rect: CGRect) -> Path {

        // Positioning
        let midHeight = rect.height / 2

        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: midHeight))
        path.addLine(to: CGPoint(x: rect.width, y: midHeight))
        return Path(path.cgPath)
    }
}

struct ContentView: View {

    @State private var phase = 0.0                     // Used to animate the wave
    @State private var waveStrength: Double = 10.0     // How tall, change for interesting numbers
    @State private var waveFrequency: Double = 10.0    // How frequent, change for interesting numbers

    @State var isAnimating: Bool = false    // Currently running animation
    @State private var randNum: Int16 = 0   // Random number to keep generating while animating
    @State private var isNumberInteresting: Bool = false // Will take 'true' of the random number has some interesting properties

    // Timer publisher reflecting frequent animation changes
    @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    // Stop timer
    func stopTimer() {
        self.timer.upstream.connect().cancel()
    }
    // Start timer
    func startTimer() {
        self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    }

    // Check if number is interesting
    func checkNumber(num: Int16) -> Bool {
        var isInteresting: Bool = false
        if num % 2 == 0 {
            isInteresting.toggle()
        }
        return isInteresting
    }

    var body: some View {
        VStack {
            if self.isAnimating {
                VStack {
                    Button("Stop") {
                        self.isAnimating = false
                        stopTimer()
                    }
                    .font(.title)
                    .foregroundColor(Color(.blue))

                    Text("Random number: \(String(randNum)), interesting: \(String(isNumberInteresting))")
                        .onReceive(timer, perform: { _ in
                            randNum = Int16.random(in: 0..<Int16.max)
                            isNumberInteresting = checkNumber(num: randNum)
                        })
                }
            } else {
                Button("Start") {
                    self.isAnimating = true
                    startTimer()
                }
                .font(.title)
                .foregroundColor(Color(.red))
            }
            if self.isAnimating {
                // Animation
                ZStack {
                    ForEach(0..<10) { waveIteration in
                        Wave(strength: waveStrength, frequency: waveFrequency, phase: phase)
                            .stroke(Color.blue.opacity(Double(waveIteration) / 3), lineWidth: 1.1)
                            .offset(y: CGFloat(waveIteration) * 10)
                    }
                }
                .onReceive(timer) { _ in
                    // withAnimation requires info on how to animate
                    withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
                        self.phase = .pi * 2 // 180 degrees of sine being calculated
                        if isNumberInteresting {
                            waveFrequency = 50.0
                            waveStrength = 50.0
                        } else {
                            waveFrequency = 10.0
                            waveStrength = 10.0
                        }
                    }
                }
                .frame(height: UIScreen.main.bounds.height * 0.8)
            } else {
                // Static line
                ZStack {
                    Line()
                        .stroke(Color.blue)
                }
                .frame(height: UIScreen.main.bounds.height * 0.8)
            }
            Spacer()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

旁注

对于上述问题,任何关于使用 Swift 的良好实践建议总是受欢迎的。

我让你的项目工作,你可以看到更改后的代码 // <<: Here!,问题是你没有显示动画更改后的值!你只展示了一次!之后你保持不变!如果您在问题中看到您的代码,那么您正在重复 self.phase = .pi * 2 它对动画没有任何意义!我刚刚处理了您的 ContentView 所有项目都需要重构工作,但这不是这里的问题。

struct ContentView: View {
    
    @State private var phase = 0.0
    @State private var waveStrength: Double = 10.0
    @State private var waveFrequency: Double = 10.0
    
    @State var isAnimating: Bool = false
    @State private var randNum: Int16 = 0
    @State private var isNumberInteresting: Bool = false
    
    
    @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    
    @State private var stringOfText: String = String()   // <<: Here!
    
    func stopTimer() {
        
        self.timer.upstream.connect().cancel()
        
        phase = 0.0  // <<: Here!
    }
    
    func startTimer() {
        
        self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
        
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(500)) { phase = .pi * 2 }    // <<: Here!
        
    }
    
    
    func checkNumber(num: Int16) -> Bool {
        var isInteresting: Bool = false
        if num % 2 == 0 {
            isInteresting.toggle()
        }
        return isInteresting
    }
    
    
    
    var body: some View {
        
        VStack {
            
            Button(isAnimating ? "Stop" : "Start") {  // <<: Here!
                
                isAnimating.toggle()    // <<: Here!
                
                isAnimating ? startTimer() : stopTimer()   // <<: Here!
                
            }
            .font(.title)
            .foregroundColor(isAnimating ? Color.red : Color.blue)  // <<: Here!
            
            
            ZStack {
                
                if isAnimating {
                    
                    ForEach(0..<10) { waveIteration in
                        Wave(strength: waveStrength, frequency: waveFrequency, phase: phase)
                            .stroke(Color.blue.opacity(Double(waveIteration) / 3), lineWidth: 1.1)
                            .offset(y: CGFloat(waveIteration) * 10)
                    }
                    
                }
                else {
                    
                    Line().stroke(Color.blue)
                    
                }
                
            }
            .frame(height: UIScreen.main.bounds.height * 0.8)
            .overlay(isAnimating ? Text(stringOfText) : nil, alignment: .top)   // <<: Here!
            .onReceive(timer) { _ in

                if isAnimating {  // <<: Here!
                    
                    randNum = Int16.random(in: 0..<Int16.max)        
                    isNumberInteresting = checkNumber(num: randNum)  

                    stringOfText = "Random number: \(String(randNum)), interesting: \(String(isNumberInteresting))" // <<: Here!

                    if isNumberInteresting {
                        waveFrequency = 50.0
                        waveStrength = 50.0
                    } else {
                        waveFrequency = 10.0
                        waveStrength = 10.0
                    }
 
                }
                else {
                    stopTimer() // For safety! Killing Timer in case!        // <<: Here!
                }
  
            }
            .animation(nil, value: stringOfText)  // <<: Here!
            .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false))  // <<: Here!
            .id(isAnimating)  // <<: Here!
            
        }
        
        
    }
}