是啥导致 SwiftUI 嵌套的 View 项目在初始绘制后出现跳跃动画?

Posted

技术标签:

【中文标题】是啥导致 SwiftUI 嵌套的 View 项目在初始绘制后出现跳跃动画?【英文标题】:What's causing SwiftUI nested View items jumpy animation after the initial drawing?是什么导致 SwiftUI 嵌套的 View 项目在初始绘制后出现跳跃动画? 【发布时间】:2020-05-16 00:57:59 【问题描述】:

我最近在一个容器 View 中遇到了一个问题,该容器有一个嵌套列表 View 使用 repeatForever 动画的项目,在动态添加同级项目后第一次绘制和跳跃时效果很好。

View 的列表是从ObservableObject 属性动态生成的,这里表示为Loop。它是在后台线程 (AVAudioPlayerNodeImpl.CompletionHandlerQueue) 中进行计算之后生成的。

LoopView 动画有一个duration,它等于其传递参数player 的动态duration 属性值。每个Loop 都有自己的值,每个兄弟姐妹中的值可能相同,也可能不同。

当第一个 Loop View 创建时,动画完美无缺,但在列表中包含新项目后变得跳跃。这意味着,动画对尾部项(列表中的最后一项或最新成员)正确工作,而前一项则错误。

从我的角度来看,这似乎与 SwiftUI 如何重绘有关,并且我的知识存在差距,这导致了导致动画状态分散的实现。问题是是什么导致了这种情况,或者如何防止这种情况在未来发生?

我已最小化实施,以提高清晰度并专注于主题。

让我们看一下容器视图:

import SwiftUI

struct ContentView: View 
    @EnvironmentObject var engine: Engine

    fileprivate func Loop(duration: Double, play: Bool) -> some View 
            ZStack 
                Circle()
                    .stroke(style: StrokeStyle(lineWidth: 10.0))
                    .foregroundColor(Color.purple)
                    .opacity(0.3)
                    .overlay(
                        Circle()
                            .trim(
                                from: 0,
                                to: play ? 1.0 : 0.0
                        )
                            .stroke(
                                style: StrokeStyle(lineWidth: 10.0,
                                                   lineCap: .round,
                                                   lineJoin: .round)
                        )
                            .animation(
                                self.audioEngine.isPlaying ?
                                    Animation
                                        .linear(duration: duration)
                                        .repeatForever(autoreverses: false) :
                                    .none
                        )
                            .rotationEffect(Angle(degrees: -90))
                            .foregroundColor(Color.purple)
                )
            
            .frame(width: 100, height: 100)
            .padding()
    

    var body: some View 
        VStack 
            ForEach (0 ..< self.audioEngine.players.count, id: \.self)  index in
                HStack 
                    self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
                
            
        
    

Body 中,您将找到一个ForEach,它监视Players 的列表,一个来自Engine@Published 属性。

看看Engine 类:

Class Engine: ObservableObject 
  @Published var players = []

  func record() 
    ...
  

  func stop() 
    ...
    self.recorderCompletionHandler()
  

  func recorderCompletionHandler() 
    ...

    let player = self.createPlayer(...)
    player.play()

    DispatchQueue.main.async 
      self.players.append(player)
    
  

  func createPlayer() 
    ...
  

最后,一个小视频演示来展示这个比文字更有价值的问题:

对于这个特定的示例,最后一个项目的持续时间是前两个项目的持续时间的两倍,每个项目都具有相同的持续时间。尽管无论这种示例状态如何,问题都会发生。

想提一下,开始时间或者触发时间都是一样的,.play是一个同步调用的方法!

已编辑

在遵循@Ralf Ebert 提供的良好实践之后的另一项测试,根据我的要求稍作更改,切换play 状态,不幸的是这会导致同样的问题,到目前为止,这似乎与一些原则有关值得学习的 SwiftUI。

@Ralf Ebert提供的版本的修改版:

// SwiftUIPlayground
import SwiftUI

struct PlayerLoopView: View 
    @ObservedObject var player: MyPlayer

    var body: some View 
        ZStack 
            Circle()
                .stroke(style: StrokeStyle(lineWidth: 10.0))
                .foregroundColor(Color.purple)
                .opacity(0.3)
                .overlay(
                    Circle()
                        .trim(
                            from: 0,
                            to: player.isPlaying ? 1.0 : 0.0
                        )
                        .stroke(
                            style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
                        )
                        .animation(
                            player.isPlaying ?
                                Animation
                                .linear(duration: player.duration)
                                .repeatForever(autoreverses: false) :
                                .none
                        )
                        .rotationEffect(Angle(degrees: -90))
                        .foregroundColor(Color.purple)
                )
        
        .frame(width: 100, height: 100)
        .padding()
    


struct PlayersProgressView: View 
    @ObservedObject var engine = Engine()

    var body: some View 
        NavigationView 
            VStack 
                ForEach(self.engine.players)  player in
                    HStack 
                        Text("Player")
                        PlayerLoopView(player: player)
                    
                
            
            .navigationBarItems(trailing:
                VStack 
                    Button("Add Player") 
                        self.engine.addPlayer()
                    
                    Button("Play All") 
                        self.engine.playAll()
                    
                    Button("Stop All") 
                        self.engine.stopAll()
                    
                .padding()
            )
        
    


class MyPlayer: ObservableObject, Identifiable 
    var id = UUID()
    @Published var isPlaying: Bool = false
    var duration: Double = 1
    func play() 
        self.isPlaying = true
    
    func stop() 
        self.isPlaying = false
    


class Engine: ObservableObject 
    @Published var players = [MyPlayer]()

    func addPlayer() 
        let player = MyPlayer()
        players.append(player)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) 
            player.isPlaying = true
        
    

    func stopAll() 
        self.players.forEach  $0.stop() 
    

    func playAll() 
        self.players.forEach  $0.play() 
    


struct PlayersProgressView_Previews: PreviewProvider 
    static var previews: some View 
        PlayersProgressView()
    

以下demo是按照以下步骤制作的(demo只在stop all后显示,以保持Stack Overflow中最大图片上传不超过2mb):

- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)

找到一篇报告类似问题的文章: https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/

我将不得不找到一种不同的方法,而不是使用.repeatForever

【问题讨论】:

看起来这里需要可动画的基于值的进度,而不仅仅是无端动画。我在Rectangle progress bar swiftUI 中的解决方案可能会有所帮助。在提供的代码中,动画只是在每次 body 重建时重置而没有存储当前状态 - 这就是效果。 @Asperi,太好了,谢谢!不幸的是,player 的当前实现目前没有progress 事件处理程序,但将来会研究它,或者任何其他可以增量计算进度的用例;)感谢您的时间;)跨度> 【参考方案1】:

您需要确保没有视图更新(例如由添加新播放器等更改触发)导致重新评估“循环”,因为这可能会重置动画。

在这个例子中,我会:

将播放器设为Identifiable,以便SwiftUI 可以跟踪对象(var id = UUID() 就足够了),然后您可以使用ForEach(self.engine.players),SwiftUI 可以跟踪Player -&gt; View 关联。 将播放器本身设为ObservableObject 并创建PlayerLoopView,而不是您示例中的Loop 函数:
struct PlayerLoopView: View 
    @ObservedObject var player: Player

    var body: some View 
        ZStack 
            Circle()
            // ...
        
    

恕我直言,这是防止状态更新干扰动画的最可靠方法。

请参阅此处以获取可运行的示例:https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift

【讨论】:

非常感谢您花时间和精力研究这个问题!提供的示例中分享的良好实践的另一个大+!我做了一个状态变化的测试,不幸的是它有同样的缺陷;相应地更新了帖子;我会继续研究以找出答案,并希望尽快分享我的发现!谢谢 我可以用更新的代码重现这个问题。它有助于将持续时间设置为 10 秒以使其更明显。虽然是由状态变化引起的,但在闪烁期间不会重新评估身体。动画代码对我来说看起来不错。我猜这是一个 SwiftUI 错误。在 Apple 反馈助手中报告它以及示例代码和重现步骤可能是值得的。 没错,可惜我的原帖太长了;但我已将此问题传达给 SwiftUI 团队。感谢您的支持! 找到了解决办法,看看吧!【参考方案2】:

这个问题似乎是由原始实现产生的,其中.animation 方法采用条件,这就是导致跳跃的原因。

如果我们决定不这样做,而是保留所需的动画声明,并且只切换动画持续时间,它就可以正常工作!

如下:

ZStack 
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 10.0))
        .foregroundColor(Color.purple)
        .opacity(0.3)
    Circle()
        .trim(
            from: 0,
            to: player.isPlaying ? 1.0 : 0.0
        )
        .stroke(
            style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
        )
        .animation(
            Animation
                .linear(duration: player.isPlaying ? player.duration : 0.0)
                .repeatForever(autoreverses: false)
        )
        .rotationEffect(Angle(degrees: -90))
        .foregroundColor(Color.purple)

.frame(width: 100, height: 100)
.padding()

Obs:第三个元素的持续时间延长了 4 倍,仅用于测试

想要的结果:

【讨论】:

以上是关于是啥导致 SwiftUI 嵌套的 View 项目在初始绘制后出现跳跃动画?的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI制作View可嵌套组件

解析器 SwiftUI 导致崩溃 - 调用的顺序是啥?

SwiftUI 如何快速识别视图(View)界面的刷新是由哪个状态的改变导致的?

SwiftUI 如何快速识别视图(View)界面的刷新是由哪个状态的改变导致的?

SwiftUI 组嵌套在 VStack 中抛出错误

SwiftUI 后台刷新多个 Section 导致 global index in collection view 与实际不匹配问题的解决