🦋

SwiftUI: NSPanelをScene経由で表示する

2024/11/02に公開

macOS SequoiaからAlertSceneが追加され、ようやくViewに紐づかないアラートをSwiftUIの基盤上で表示することができるようになりました。しかし、依然として自由度は低いですし、macOS Sonoma以下では使えない手段ですので困りものです。

ということで、SwiftUIの隠しAPI等を駆使してAppKitのモーダルをScene経由で表示する方法を見つけました。最終的には以下のような感じでフローティングなNSPanelを表示できる実装例を示します。WindowGroupの方のToggleと連動してPanelの表示/非表示が切り替わりますし、Panel側を×ボタンで閉じれば、連動してToggleの状態も切り替わります。

import SwiftUI

@main
struct SampleApp: App {
    @State var isPresented: Bool = false

    var body: some Scene {
        WindowGroup {
            Toggle(isOn: $isPresented) {
                Text("Panel")
            }
        }
        PanelScene(isPresented: $isPresented, type: FloatingPanel.self) {
            Text("Hello World!")
                .fixedSize()
                .padding()
        }
    }
}


実行例

SwiftUIのViewを注入できるNSPanel準拠のprotocolを定義する

HostingPanel
protocol HostingPanel: NSPanel {
    init<Content: View>(@ViewBuilder content: @escaping () -> Content)
}

定義したprotocolに準拠しNSPanelを継承したカスタムPanelを作る

FloatingPanel
final class FloatingPanel: NSPanel, HostingPanel {
    init<Content: View>(content: () -> Content) {
        super.init(
            contentRect: .zero,
            styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        level = .floating
        isOpaque = false
        isMovableByWindowBackground = true
        titlebarAppearsTransparent = true
        titleVisibility = .hidden
        animationBehavior = .utilityWindow
        contentView = NSHostingView(rootView: content())
    }
}

カスタムPanelの表示/非表示を司るマネージャーclassを作る

NSWindowDelegateを使ってPanelが閉じた時にpanel = nilしているのがポイント。

PanelSceneRepresentable
@MainActor final class PanelSceneRepresentable<Panel: HostingPanel>: NSObject, NSWindowDelegate {
    var panel: Panel?
    var closeAction: () -> Void

    init(closeAction: @escaping () -> Void) {
        self.closeAction = closeAction
    }

    func open<Content: View>(@ViewBuilder content: @escaping () -> Content) {
        if panel == nil {
            panel = .init(content: content)
            panel?.delegate = self
            panel?.center()
            panel?.orderFrontRegardless()
        }
    }

    func close() {
        panel?.close()
    }

    func windowWillClose(_ notification: Notification) {
        if let window = notification.object as? NSWindow, window === panel {
            panel = nil
            closeAction()
        }
    }
}

カスタムSceneModifierを作る

Xcode上で_から始まる隠しAPIを漁っていたら_SceneModifierを見つけたのでちょいと拝借。
onChange(of:initial:)initialが良い仕事をしてくれます。
また、closeActionisPresentedとカスタムパネル表示状態を同期しているのも重要です。

PanelSceneModifier
@MainActor struct PanelSceneModifier<Panel: HostingPanel, ViewContent: View>: @preconcurrency _SceneModifier {
    @State var sceneRepresentable: PanelSceneRepresentable<Panel>
    @Binding var isPresented: Bool
    @ViewBuilder let viewContent: () -> ViewContent

    init(isPresented: Binding<Bool>, viewContent: @escaping () -> ViewContent) {
        sceneRepresentable = .init(closeAction: {
            isPresented.wrappedValue = false
        })
        _isPresented = isPresented
        self.viewContent = viewContent
    }

    func body(content: SceneContent) -> some Scene {
        content.onChange(of: isPresented, initial: true) { _, newValue in
            if newValue {
                sceneRepresentable.open(content: viewContent)
            } else {
                sceneRepresentable.close()
            }
        }
    }
}

カスタムSceneを作る

これまたXcode上で_から始まる隠しAPIをあさっていたら_EmptySceneを見つけたので利用します。さらに、AppleのDeveloperリファレンスを読み漁っていたらModifiedContentなるものを発見。もしかしてViewModifierだけじゃなくて_SceneModifierでも動くんじゃね?って思ったら案の定動いたので使わせてもらいます。

PanelScene
public struct PanelScene<Panel: HostingPanel, Content: View>: Scene {
    @Binding var isPresented: Bool
    @ViewBuilder let content: () -> Content

    public init(isPresented: Binding<Bool>, type: Panel.Type, @ViewBuilder content: @escaping () -> Content) {
        _isPresented = isPresented
        self.content = content
    }

    public var body: some Scene {
        ModifiedContent(
            content: _EmptyScene(),
            modifier: PanelSceneModifier<Panel, Content>(isPresented: $isPresented, viewContent: content)
        )
    }
}

ということで、isPresentedで表示/非表示を切り替えられるSceneを作れました。やったね!

Discussion