🦋

SwiftUI: NSWindowをSwiftUIのインターフェースで呼び出す

2024/12/17に公開

macOS SequoiaからはSwiftUIでもWindow系のAPIが充実してきて簡単なmacOSアプリなら結構要求を達成できるようになってきています。しかし、特殊なWindowの設定や制御となるとNSWindowに頼らざるを得ない状況がまだ続いています。そんな中、アプリ開発では処理の責務分離が肝要なためViewとロジックはしっかりと線引きしたいのですが、NSWindowNSAlertなどの呼び出しはどうしても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の場合(?)Scenebodyが呼び出されるには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()

宣伝

これらを毎回実装するのは面倒なのでライブラリ化しました。

https://github.com/kyome22/WindowSceneKit

上述の大雑把な実装だと複数の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