延迟 SwiftUI 中的重复动画,在完整的自动反转重复周期之间

Posted

技术标签:

【中文标题】延迟 SwiftUI 中的重复动画,在完整的自动反转重复周期之间【英文标题】:Delay a repeating animation in SwiftUI with between full autoreverse repeat cycles 【发布时间】:2020-12-30 15:57:51 【问题描述】:

我正在使用 SwiftUI 构建一个 Apple Watch 应用,它可以读取用户的心率并将其显示在心形符号旁边。

我有一个让心脏符号反复跳动的动画。因为我知道实际用户的心率,所以我想让它以与用户心率相同的速率跳动,每次心率变化时更新动画。

我可以通过将心率除以 60 来确定节拍之间的间隔秒数。例如,如果用户的心率为 80 BPM,则动画应该每 0.75 秒 (60/80) 发生一次。

这是我现在拥有的示例代码,其中currentBPM 是一个常量,但通常会更新。

struct SimpleBeatingView: View 
    
    // Once I get it working, this will come from a @Published Int that gets updated any time a new reading is avaliable.
    let currentBPM: Int = 80
    
    @State private var isBeating = false
    
    private let maxScale: CGFloat = 0.8
    
    var beatingAnimation: Animation 
        
        // The length of one beat
        let beatLength = 60 / Double(currentBPM)
        
        return Animation
            .easeInOut(duration: beatLength)
            .repeatForever()
    
    
    var body: some View 
        Image(systemName: "heart.fill")
            .font(.largeTitle)
            .foregroundColor(.red)
            .scaleEffect(isBeating ? 1 : maxScale)
            .animation(beatingAnimation)
            .onAppear 
                self.isBeating = true
            
    

我想让这个动画表现得更像 Apple 的内置心率应用程序。与其让心脏不断变大或变小,我想让它跳动(两个方向的动画)然后暂停片刻,然后再次跳动(动画两个方向)然后再次暂停,依此类推。

当我添加一秒延迟时,例如,.delay(1).repeatForever() 之前,动画会在每个节拍中途暂停。例如,它变小,暂停,然后变大,然后暂停,等等。

我明白为什么会发生这种情况,但是如何在每个自动反转重复之间插入延迟,而不是在自动反转重复的两端插入?

我相信我可以计算出延迟应该多长以及每个节拍的长度以使一切正常进行,因此延迟长度可以是任意的,但我正在寻找的是帮助关于如何在动画循环之间实现暂停

我使用的一种方法是在每次获得新的心率 BPM 时将flatMapcurrentBPM 重复发布的Timers,这样我就可以尝试从中驱动动画,但我不确定如何实际上,我可以将其转换为 SwiftUI 中的动画,但根据我目前对 SwiftUI 的理解,当时间似乎应该由动画处理时,我不确定以这种方式手动驱动值是否是正确的方法。

【问题讨论】:

【参考方案1】:

一种可能的解决方案是使用DispatchQueue.main.asyncAfter 链接单个片段动画。这使您可以控制何时延迟特定部分。

这是一个演示:

struct SimpleBeatingView: View 
    @State private var isBeating = false
    @State private var heartState: HeartState = .normal

    @State private var beatLength: TimeInterval = 1
    @State private var beatDelay: TimeInterval = 3

    var body: some View 
        VStack 
            Image(systemName: "heart.fill")
                .imageScale(.large)
                .font(.largeTitle)
                .foregroundColor(.red)
                .scaleEffect(heartState.scale)
            Button("isBeating: \(String(isBeating))") 
                isBeating.toggle()
            
            HStack 
                Text("beatLength")
                Slider(value: $beatLength, in: 0.25...2)
            
            HStack 
                Text("beatDelay")
                Slider(value: $beatDelay, in: 0...5)
            
        
        .onChange(of: isBeating)  isBeating in
            if isBeating 
                startAnimation()
             else 
                stopAnimation()
            
        
    

private extension SimpleBeatingView 
    func startAnimation() 
        isBeating = true
        withAnimation(Animation.linear(duration: beatLength * 0.25)) 
            heartState = .large
        
        DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.25) 
            withAnimation(Animation.linear(duration: beatLength * 0.5)) 
                heartState = .small
            
        
        DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.75) 
            withAnimation(Animation.linear(duration: beatLength * 0.25)) 
                heartState = .normal
            
        
        DispatchQueue.main.asyncAfter(deadline: .now() + beatLength + beatDelay) 
            withAnimation 
                if isBeating 
                    startAnimation()
                
            
        
    

    func stopAnimation() 
        isBeating = false
    

enum HeartState 
    case small, normal, large

    var scale: CGFloat 
        switch self 
        case .small: return 0.5
        case .normal: return 0.75
        case .large: return 1
        
    

【讨论】:

【参考方案2】:

在 pawello2222 回答之前,我仍在尝试尝试使其正常工作,我想出了一个使用 Timer 发布者和组合框架的解决方案。

我已经包含了下面的代码,但这不是一个好的解决方案,因为每次currentBPM 更改时都会在下一个动画开始之前添加一个新的延迟。 pawello2222 的回答更好,因为它总是允许当前的跳动动画完成,然后以更新的速率开始下一个循环。

另外,我认为我在这里的回答不太好,因为很多动画工作是在数据存储对象中完成的,而不是封装在视图中,这可能更有意义。

import SwiftUI
import Combine

class DataStore: ObservableObject 
    
    @Published var shouldBeSmall: Bool = false
    
    @Published var currentBPM: Int = 0
    
    private var cancellables = Set<AnyCancellable>()
    
    init() 
        
        let newLengthPublisher =
            $currentBPM
            .map  60 / Double($0) 
            .share()
        
        newLengthPublisher
            .delay(for: .seconds(0.2),
                   scheduler: RunLoop.main)
            .map  beatLength in
                return Timer.publish(every: beatLength,
                                     on: .main,
                                     in: .common)
                    .autoconnect()
            
            .switchToLatest()
            .sink  timer in
                self.shouldBeSmall = false
            
            .store(in: &cancellables)
        
        newLengthPublisher
            .map  beatLength in
                return Timer.publish(every: beatLength,
                                     on: .main,
                                     in: .common)
                    .autoconnect()
            
            .switchToLatest()
            .sink  timer in
                self.shouldBeSmall = true
            
            .store(in: &cancellables)
        
        currentBPM = 75
    


struct ContentView: View 
    
    @ObservedObject var store = DataStore()
    
    private let minScale: CGFloat = 0.8
    
    var body: some View 
        
        HStack 
            
            Image(systemName: "heart.fill")
                .font(.largeTitle)
                .foregroundColor(.red)
                .scaleEffect(store.shouldBeSmall ? 1 : minScale)
                .animation(.easeIn)
            
            Text("\(store.currentBPM)")
                .font(.largeTitle)
                .fontWeight(.bold)
        
    

【讨论】:

以上是关于延迟 SwiftUI 中的重复动画,在完整的自动反转重复周期之间的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI ForEach 没有动画延迟

SwiftUI 反向动画延迟移除

随着时间的推移,重复的 SwiftUI 动画变得不稳定

SwiftUI:如何使用延迟在两个图像之间设置动画过渡?

ScrollView 中的动画缺少 SwiftUI 中的最后一个对象

如何为 SwiftUI 列表中的各个行设置动画?