是啥导致 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) 中进行计算之后生成的。
Loop
View
动画有一个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 -> 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 如何快速识别视图(View)界面的刷新是由哪个状态的改变导致的?
SwiftUI 后台刷新多个 Section 导致 global index in collection view 与实际不匹配问题的解决