SwiftUI: NSWindowをSwiftUIのインターフェースで呼び出す
macOS SequoiaからはSwiftUIでもWindow系のAPIが充実してきて簡単なmacOSアプリなら結構要求を達成できるようになってきています。しかし、特殊なWindowの設定や制御となるとNSWindow
に頼らざるを得ない状況がまだ続いています。そんな中、アプリ開発では処理の責務分離が肝要なためViewとロジックはしっかりと線引きしたいのですが、NSWindow
やNSAlert
などの呼び出しはどうしてもViewとロジックが癒着しがちです。SwiftUIのインターフェース(つまりScene
)として独自のNSWindow
が提供できれば、この問題の解消に近づきます。ということで、NSWindow
をSwiftUIのScene
で扱う方法を編み出しました。
SwiftUIのAPIを調査してみると(onmyway133/arm64-apple-ios.swiftinterface)、ModifiedContent
や_SceneModifier
や_EmptyScene
などが見つかります。結論を言うと、これらを使えばViewModifier
と同じ要領で独自のウインドウを持つScene
が作れます。
struct MyScene: Scene {
@Binding var isPresented: Bool
var body: some Scene {
ModifiedContent(
content: _EmptyScene(),
modifier: MySceneModifier(isPresented: $isPresented)
)
}
}
@MainActor struct MySceneModifier: @preconcurrency _SceneModifier {
@State private var sceneRepresentable = MySceneRepresentable()
@Binding var isPresented: Bool
init(isPresented: Binding<Bool>) {
_isPresented = isPresented
}
func body(content: SceneContent) -> some Scene {
content.onChange(of: isPresented, initial: true) { _, newValue in
if newValue {
sceneRepresentable.open()
} else {
sceneRepresentable.close()
}
}
}
}
@MainActor class MySceneRepresentable {
var window: NSWindow?
func open() {
guard window == nil else { return }
window = NSWindow()
window?.orderFrontRegardless()
}
func close() {
window?.close()
window = nil
}
}
注意しないといけないポイントとして
-
Scene
にはonChange(of:)
はあるがonAppear()
やtask()
がない -
_EmptyScene
の場合(?)Scene
のbody
が呼び出されるにはApp
のプロパティをBinding
で伝搬させないといけない
などがありました。
ともかく、これでSwiftUIのインターフェースでNSWindow
の表示/非表示ができるようになりますから、あとはカスタマイズしたNSWindow
を渡してあげれば良さそうです。
import SwiftUI
@main
struct SampleApp: App {
@State var isPresented = false
var body: some Scene {
MyScene(isPresented: $isPresented)
}
}
ここで、「いやいや、どうやってisPresented
を切り替えるのよ。Appのプロパティの値更新も難しいじゃない。」という話になります。なので、DynamicProperty
を使ったProperty Wrapperを作ってどこからでも表示/非表示の切り替え要求ができるようにします。
方針としてはシンプルにNotificationCenter
を使ってWindowの制御をリクエストする独自の通知を送受信して、その都度Property Wrapperの値が更新されるようにします。
extension Notification: @retroactive @unchecked Sendable {}
extension Notification.Name {
static let didRequestWindowAction = Notification.Name("didRequestWindowAction")
}
@MainActor @propertyWrapper struct WindowState: DynamicProperty, Sendable {
@State var store: WindowStateStore
var wrappedValue: Bool {
get { store.isPresented }
nonmutating set { store.isPresented = newValue }
}
var projectedValue: Binding<Bool> {
.init(
get: { wrappedValue },
set: { wrappedValue = $0 }
)
}
init(wrappedValue: Bool) {
store = .init(isPresented: wrappedValue)
}
}
@MainActor @Observable class WindowStateStore {
@ObservationIgnored private var task: Task<Void, Never>?
var isPresented: Bool
init(isPresented: Bool) {
self.isPresented = isPresented
task = Task {
for await notification in NotificationCenter.default.publisher(for: .didRequestWindowAction).values {
guard let userInfo = notification.userInfo,
let windowAction = userInfo["windowAction"] as? String else {
continue
}
self.isPresented = windowAction == "open"
}
}
}
deinit {
task?.cancel()
}
}
struct WindowSceneMessenger {
static func requestOpen {
NotificationCenter.default.post(
name: .didRequestWindowAction,
object: nil,
userInfo: ["windowAction": "open"]
)
}
static func requestClose {
NotificationCenter.default.post(
name: .didRequestWindowAction,
object: nil,
userInfo: ["windowAction": "close"]
)
}
}
import SwiftUI
@main
struct SampleApp: App {
@WindowState var isPresented = false
var body: some Scene {
MyScene(isPresented: $isPresented)
}
}
// どこかのコンテキストで
WindowSceneMessenger.requestOpen()
宣伝
これらを毎回実装するのは面倒なのでライブラリ化しました。
上述の大雑把な実装だと複数のWindowに対応できないですし、ウインドウを閉じたときにisPresented
の値が連動してfalse
にならないなどの問題があるので、そこら辺も対応してあります。
使い方のおすすめはREADMEにもありますが、独自のWindowの中でNSHostingView
を用いてコンテンツをSwiftUIのViewにしてしまうことです。
class MyWindow: NSWindow {
init<Content: View>(@ViewBuilder content: () -> Content) {
super.init(
contentRect: .zero,
styleMask: [.closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
level = .floating
contentView = NSHostingView(rootView: content())
}
}
@main
struct SampleApp: App {
@WindowState("SomeWindowKey") var isPresented: Bool = true
var body: some Scene {
WindowScene(isPresented: $isPresented, window: MyWindow(content: {
ContentView()
}))
}
}
// どこかのコンテキスト
WindowSceneMessenger.request(windowAction: .open, windowKey: "SomeWindowKey")
SwiftUIベースだけどNSWindow
に頼りたいと言う場合はぜひ使ってみてください。
Discussion