SwiftUI: NSPanelをScene経由で表示する
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を定義する
protocol HostingPanel: NSPanel {
init<Content: View>(@ViewBuilder content: @escaping () -> Content)
}
定義したprotocolに準拠しNSPanelを継承したカスタムPanelを作る
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
しているのがポイント。
@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
が良い仕事をしてくれます。
また、closeAction
でisPresented
とカスタムパネル表示状態を同期しているのも重要です。
@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
でも動くんじゃね?って思ったら案の定動いたので使わせてもらいます。
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