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
相关产品推荐
相关产品推荐

