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

SwiftUI:Mac端视图离开屏幕时停止持续动画

解决NSPopover中SwiftUI视图动画在弹窗关闭后持续运行的问题

问题核心

Mac应用里用NSPopover承载NSHostingView包裹的SwiftUI视图时,SwiftUI原生的.onAppear()和.onDisappear()无法适配弹窗的显示/隐藏逻辑:前者仅在视图首次加入层级时触发一次,后者在弹窗关闭时完全不触发,导致持续动画(比如宽度脉动)在弹窗消失后仍后台运行,占用5-6%的CPU。

正统SwiftUI解决方案

方案1:利用@Environment(\.scenePhase)监听窗口活跃状态

通过SwiftUI的环境变量感知当前窗口(弹窗属于窗口实例)的活跃状态,以此启停动画:

struct PulsingRectangle: View {
    @State private var pulseWidth: CGFloat = 200
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue)
            .frame(width: pulseWidth, height: 60)
            .onChange(of: scenePhase) { newPhase in
                switch newPhase {
                case .active:
                    startPulse()
                case .inactive, .background:
                    stopPulse()
                @unknown default:
                    break
                }
            }
            .onAppear(perform: startPulse)
    }
    
    private func startPulse() {
        withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
            pulseWidth = 220
        }
    }
    
    private func stopPulse() {
        // 立即重置并终止动画
        withAnimation(.linear(duration: 0)) {
            pulseWidth = 200
        }
        // 显式重置动画状态
        NSView.setAnimationsEnabled(false)
        NSView.setAnimationsEnabled(true)
    }
}

方案2:自定义NSView追踪可见性,结合通知传递状态

通过NSViewRepresentable封装一个能监听自身可见性的NSView,用通知把状态传递给SwiftUI视图:

// 自定义NSView,监听可见性变化
class VisibilityTracker: NSView {
    override func viewDidAppear() {
        super.viewDidAppear()
        NotificationCenter.default.post(name: .popoverViewVisible, object: nil)
    }
    
    override func viewDidDisappear() {
        super.viewDidDisappear()
        NotificationCenter.default.post(name: .popoverViewHidden, object: nil)
    }
}

// 定义通知名
extension Notification.Name {
    static let popoverViewVisible = Notification.Name("popoverViewVisible")
    static let popoverViewHidden = Notification.Name("popoverViewHidden")
}

// SwiftUI视图实现
struct PulsingRectangle: View {
    @State private var pulseWidth: CGFloat = 200
    private let nc = NotificationCenter.default
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 12)
                .fill(.blue)
                .frame(width: pulseWidth, height: 60)
            // 嵌入追踪视图(透明不影响界面)
            VisibilityTrackerRepresentable()
                .opacity(0)
        }
        .onReceive(nc.publisher(for: .popoverViewVisible)) { _ in
            startPulse()
        }
        .onReceive(nc.publisher(for: .popoverViewHidden)) { _ in
            stopPulse()
        }
    }
    
    private func startPulse() {
        withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
            pulseWidth = 220
        }
    }
    
    private func stopPulse() {
        withAnimation(.linear(duration: 0)) {
            pulseWidth = 200
        }
    }
}

// NSViewRepresentable封装
struct VisibilityTrackerRepresentable: NSViewRepresentable {
    func makeNSView(context: Context) -> VisibilityTracker {
        VisibilityTracker()
    }
    
    func updateNSView(_ nsView: VisibilityTracker, context: Context) {}
}

方案3:用@StateObject结合AppKit生命周期传递状态

创建动画状态管理器,通过NSPopover所属的ViewController的生命周期回调控制动画开关,保持SwiftUI的数据驱动特性:

// 动画状态管理器
class AnimationController: ObservableObject {
    @Published var isAnimating = false
    
    func start() {
        guard !isAnimating else { return }
        isAnimating = true
    }
    
    func stop() {
        isAnimating = false
    }
}

// SwiftUI视图
struct PulsingRectangle: View {
    @State private var pulseWidth: CGFloat = 200
    @ObservedObject var animController: AnimationController
    
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue)
            .frame(width: pulseWidth, height: 60)
            .onChange(of: animController.isAnimating) { shouldAnimate in
                shouldAnimate ? startPulse() : stopPulse()
            }
    }
    
    private func startPulse() {
        withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
            pulseWidth = 220
        }
    }
    
    private func stopPulse() {
        withAnimation(.linear(duration: 0)) {
            pulseWidth = 200
        }
    }
}

// NSPopover的ViewController
class PopoverVC: NSViewController {
    let animController = AnimationController()
    
    override func loadView() {
        let hostingView = NSHostingView(rootView: PulsingRectangle(animController: animController))
        self.view = hostingView
    }
    
    override func viewDidAppear() {
        super.viewDidAppear()
        animController.start()
    }
    
    override func viewDidDisappear() {
        super.viewDidDisappear()
        animController.stop()
    }
}

方案选择建议

  • 方案1最简洁,适合大多数场景,无需额外封装;
  • 方案2精准对应视图可见性变化,不受场景状态其他因素干扰;
  • 方案3是AppKit与SwiftUI协作的优雅模式,兼顾原生生命周期回调和SwiftUI的数据驱动。

内容的提问来源于stack exchange,提问作者Bryan

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 04:25:41