You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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()
    }
}

解决方案

问题根源:

  1. 父视图的inhaling状态在动画隐藏后未及时重置,且子视图的onDisappear可能因SwiftUI视图复用机制未触发
  2. 子视图中创建的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 12:24:19