SwiftUI绑定子视图:如何让呼吸动画消失后干净重置重现?
SwiftUI 冥想呼吸动画无法重置问题
预期效果
- 点击按钮时显示带有
@Binding属性inhaling的动画子视图 - 动画子视图需从头开始播放:初始为小球,
inhaling为true时变大,为false时缩小(类似Apple冥想应用的呼吸动画)
- 再次点击按钮,动画消失
- 第三次点击按钮时,动画子视图需完全重置,回到小球状态并重新开始变大,和第一次启动效果一致
对应核心代码片段:
struct Meditation: View { @State var startBreathingAnimation = false @State var inhaling = false @State var infoText = "Start a mediation" var body: some View { VStack(spacing: 20) { ZStack { if startBreathingAnimation { BreathAnimation(inhaling: $inhaling) .onChange(of: inhaling) { newValue in infoText = newValue ? "Breath in..." : "Breath out..." } .onDisappear { infoText = "Start your meditation" // 从未执行? } } else { Circle() .frame(height: 100) .foregroundColor(.blue) } } Text(infoText) Button("Toggle") { startBreathingAnimation.toggle() } } .padding() } }
实际问题
带有绑定属性的动画子视图不会重置或重新初始化,而是从上次被按钮"关闭"时的状态继续播放。如果不给子视图添加绑定属性,动画每次都会重置并呈现全新状态,但业务需求需要观察inhaling属性变化,以此更新主视图的infoText。
可复现代码(可直接复制到Xcode)
// Can be copied to Xcode directly struct Meditation: View { @State var startBreathingAnimation = false @State var inhaling = false @State var infoText = "Start a mediation" var body: some View { VStack(spacing: 20) { ZStack { if startBreathingAnimation { BreathAnimation(inhaling: $inhaling) .onChange(of: inhaling) { newValue in if newValue { infoText = "Breath in..." } else { infoText = "Breath out..." } } .onDisappear { infoText = "Start your meditation" // Never executed? } } else { Circle() .frame(height: 100) .foregroundColor(.blue) } } Text(infoText) Button("Toggle") { startBreathingAnimation.toggle() } } .padding() } } private let gradientStart = Color.accentColor.opacity(0.9) private let gradientEnd = Color.accentColor.opacity(1.0) private let gradient = LinearGradient(gradient: Gradient(colors: [gradientStart, gradientEnd]), startPoint: .top, endPoint: .bottom) private let maskGradient = LinearGradient(gradient: Gradient(colors: [.black]), startPoint: .top, endPoint: .bottom) private let maxSize: CGFloat = 150 private let minSize: CGFloat = 30 private let inhaleTime: Double = 8 private let exhaleTime: Double = 8 private let pauseTime: Double = 1.5 private let numberOfPetals = 4 private let bigAngle = 360 / numberOfPetals private let smallAngle = bigAngle / 2 private let ghostMaxSize: CGFloat = maxSize * 0.99 private let ghostMinSize: CGFloat = maxSize * 0.95 private struct Petals: View { let size: CGFloat let inhaling: Bool var isMask = false var body: some View { let petalsGradient = isMask ? maskGradient : gradient ZStack { ForEach(0..<numberOfPetals) { index in petalsGradient .frame(maxWidth: .infinity, maxHeight: .infinity) .mask( Circle() .frame(width: size, height: size) .offset(x: inhaling ? size * 0.5 : 0) .rotationEffect(.degrees(Double(bigAngle * index))) ) .blendMode(isMask ? .normal : .screen) } } } } struct BreathAnimation: View { @State private var size = minSize @Binding var inhaling: Bool @State private var ghostSize = ghostMaxSize @State private var ghostBlur: CGFloat = 0 @State private var ghostOpacity: Double = 0 var body: some View { ZStack { ZStack { // ghosting for exhaling Petals(size: ghostSize, inhaling: inhaling) .blur(radius: ghostBlur) .opacity(ghostOpacity) // the mask is important, otherwise there is a color // 'jump' when exhaling Petals(size: size, inhaling: inhaling, isMask: true) // overlapping petals Petals(size: size, inhaling: inhaling) Petals(size: size, inhaling: inhaling) .rotationEffect(.degrees(Double(smallAngle))) .opacity(inhaling ? 0.8 : 0.6) } .rotationEffect(.degrees(Double(inhaling ? bigAngle : -smallAngle))) .drawingGroup() } .onAppear { performAnimations() } .onDisappear { size = minSize inhaling = false ghostSize = ghostMaxSize ghostBlur = 0 ghostOpacity = 0 } } func performAnimations() { withAnimation(.easeInOut(duration: inhaleTime)) { inhaling = true size = maxSize } Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in ghostSize = ghostMaxSize ghostBlur = 0 ghostOpacity = 0.8 Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in withAnimation(.easeOut(duration: exhaleTime * 0.6)) { ghostBlur = 30 ghostOpacity = 0 } } withAnimation(.easeInOut(duration: exhaleTime)) { inhaling = false size = minSize ghostSize = ghostMinSize } } Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in // endless animation! performAnimations() } } private func performAnimations2() { withAnimation(.easeInOut(duration: inhaleTime)) { inhaling = true size = maxSize } Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in ghostSize = ghostMaxSize ghostBlur = 0 ghostOpacity = 0.8 Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in withAnimation(.easeOut(duration: exhaleTime * 0.6)) { ghostBlur = 30 ghostOpacity = 0 } } withAnimation(.easeInOut(duration: exhaleTime)) { inhaling = false size = minSize ghostSize = ghostMinSize } } Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in // endless animation! performAnimations() } } } struct MainView_Previews: PreviewProvider { static var previews: some View { Meditation() } }
解决方案
问题根源:
- 父视图的
inhaling状态在动画隐藏后未及时重置,且子视图的onDisappear可能因SwiftUI视图复用机制未触发 - 子视图中创建的
Timer在视图消失后仍在运行,会持续修改状态
修复步骤及代码
1. 父视图Meditation修改
struct Meditation: View { @State var startBreathingAnimation = false @State var inhaling = false @State var infoText = "Start a mediation" // 生成唯一ID,每次启动动画时更新,强制子视图重新初始化 @State private var animationId = UUID() var body: some View { VStack(spacing: 20) { ZStack { if startBreathingAnimation { BreathAnimation(inhaling: $inhaling) .id(animationId) // 用ID强制刷新视图 .onChange(of: inhaling) { newValue in infoText = newValue ? "Breath in..." : "Breath out..." } } else { Circle() .frame(height: 100) .foregroundColor(.blue) } } Text(infoText) Button("Toggle") { startBreathingAnimation.toggle() if !startBreathingAnimation { // 关闭动画时直接重置状态 inhaling = false infoText = "Start your meditation" } else { // 启动动画时生成新ID,触发子视图重新初始化 animationId = UUID() } } } .padding() } }
2. 子视图BreathAnimation修改
struct BreathAnimation: View { @State private var size = minSize @Binding var inhaling: Bool @State private var ghostSize = ghostMaxSize @State private var ghostBlur: CGFloat = 0 @State private var ghostOpacity: Double = 0 // 存储所有Timer任务,用于视图消失时取消 @State private var timers = Set<AnyCancellable>() var body: some View { ZStack { ZStack { Petals(size: ghostSize, inhaling: inhaling) .blur(radius: ghostBlur) .opacity(ghostOpacity) Petals(size: size, inhaling: inhaling, isMask: true) Petals(size: size, inhaling: inhaling) Petals(size: size, inhaling: inhaling) .rotationEffect(.degrees(Double(smallAngle))) .opacity(inhaling ? 0.8 : 0.6) } .rotationEffect(.degrees(Double(inhaling ? bigAngle : -smallAngle))) .drawingGroup() } .onAppear { // 初始化所有状态 size = minSize inhaling = false ghostSize = ghostMaxSize ghostBlur = 0 ghostOpacity = 0 performAnimations() } .onDisappear { // 取消所有未完成的Timer任务 timers.forEach { $0.cancel() } timers.removeAll() } } func performAnimations() { withAnimation(.easeInOut(duration: inhaleTime)) { inhaling = true size = maxSize } // 使用DispatchQueue管理异步任务,存储到timers集合 DispatchQueue.main.asyncAfter(deadline: .now() + inhaleTime + pauseTime) { [weak self] in guard let self = self else { return } self.ghostSize = ghostMaxSize self.ghostBlur = 0 self.ghostOpacity = 0.8 DispatchQueue.main.asyncAfter(deadline: .now() + exhaleTime * 0.2) { [weak self] in guard let self = self else { return } withAnimation(.easeOut(duration: exhaleTime * 0.6)) { self.ghostBlur = 30 self.ghostOpacity = 0 } } withAnimation(.easeInOut(duration: exhaleTime)) { self.inhaling = false self.size = minSize self.ghostSize = ghostMinSize } // 循环调用动画 DispatchQueue.main.asyncAfter(deadline: .now() + exhaleTime + pauseTime) { [weak self] in self?.performAnimations() } }.store(in: &timers) } }
修复说明
id(animationId):每次启动动画时生成新的UUID,SwiftUI会判定为新视图,从而完全重新初始化BreathAnimation,确保状态从初始值开始- Timer生命周期管理:用
Set<AnyCancellable>存储所有异步任务,视图消失时全部取消,避免后台任务继续修改状态 - 父视图主动重置:关闭动画时直接重置
inhaling和infoText,避免依赖不可靠的onDisappear回调
内容的提问来源于stack exchange,提问作者Dince-afk
相关产品推荐
相关产品推荐

