🚥

titleBarのないmacOSアプリを作ろうとしてハマったあれこれ

2022/12/30に公開約5,300字

AppKit, SwiftUIでtitleBarのないアプリを作ろうと試行錯誤したので、記録とともに知見を残しておきます。

やりたいこと

今回作るmacOSアプリではWindowをカスタマイズしたい。具体的にはmac標準アプリのスティッキーズのような見た目を作りたい。それにはtitleBarを消し去る必要がある。


Apple: Human Interface Guidlinesより画像を引用

titleBarを完全に消すにはAppKit一択

まずはSwiftUIで出来るならそうしたいので、試してみた。SwiftUIでアプリを作り出すとデフォルトのtitleBarや閉じるなどのボタン(Controls)がある。これらはNSWindowにあるので消していく。

// !!これは実現できなかったコード!!
@main
struct AwesomeApp: App {
 
     var body: some Scene {
         WindowGroup {
             ContentView()
                 .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: { _ in
                     NSApp.mainWindow?.titlebarAppearsTransparent = true
                     NSApp.mainWindow?.styleMask.insert(.fullSizeContentView)
                     
                     // 左上の閉じる・最小化・全画面 のボタンを非表示にする
                     NSApp.mainWindow?.standardWindowButton(.zoomButton)?.isHidden = true
                     NSApp.mainWindow?.standardWindowButton(.closeButton)?.isHidden = true
                     NSApp.mainWindow?.standardWindowButton(.miniaturizeButton)?.isHidden = true
                 })
         }
         // SwiftUIでのWindowをいじるモディファイアは以下の2つ
         .windowStyle(HiddenTitleBarWindowStyle())
         .windowToolbarStyle(.unifiedCompact)
     }
 }

しかしSwiftUIだとControlsとtitleを消すくらいまではできたが、どうやってもバー自体が消せない。というのも、NSWindowのtitleBarを消すプロパティがwindowの初期化時にしか値を入れられないためである。

そもそもウィンドウはNSWindowなので、SwiftUIのViewからいじるよりも、アプリ起動時にNSWindowを直接触った方が良いなという判断になり、この部分はAppKitで作ることにした。

AppKitでtitleBarのないウィンドウを作る

ここでは Main.storyboard というファイルを作る。Targets→Info→Custom macOS Application Target PropertiesでKeyが Main storyboard file base name (macOS) , Valueが Main の項目を追加する。これをすることでアプリ起動時にこのStoryboardで定義したものが最初に開くようになる。
AppKitでtitleBarを消すのはとても簡単で、Storyboardでチェックを一つ外すだけ。こうすることでNSWindowの初期化時にtitleBarを消してくれる。

titleBarのないウィンドウの中にあるテキスト入力系のViewがfocus不可能になる

AppKitのNSTextViewやSwiftUIのTextFieldで、テキストが入力できなくなる。しかし他のボタンやスイッチは操作が可能。色々調べたところ、titleBarを非表示にしてる場合はNSWindowのcanBecomeKeyをtrueにしなければならないことが判明した。このプロパティはinit時にしか変えられないのとStoryboardで設定項目がないので、Storyboardで作ったwindowに以下のようなカスタムクラスを指定する。(StoryboardのUser Defines Runtime Attributesで設定すればStoryboardだけで完結することも可能かも)

import AppKit
 
 class CustomWindow: NSWindow {
     override var canBecomeKey: Bool { true }
 }

AppKitで作ったウィンドウの中にSwiftUIのビューを入れる

上の手順で作成したwindowのcontentViewControllerにあたるViewController(ここではEditorViewController)で、NSHostingControllerを使ってSwiftUIのViewを表示する。

ウィンドウのサイズが変更できない場合

ウィンドウのサイズが変更できない場合、NSHostingControllerで表示するViewを固定サイズにしていないか確認する。NSWindowのwindow contentの直下(ここではEditorViewController)で、このようにNSHostingControllerの中身のSwiftUI Viewのframeを指定するとwindowのサイズが変更できなくなる。windowはwindow contentのsizeに合わせるので、この場合サイズを変更しようとしてもwindow contentが固定サイズであるためwindowのサイズが変更できない。そのため、サイズを設定する代わりに以下のような制約を設定する。

ダメな例

 class EditorViewController: NSViewController {
     override func viewDidLoad() {
         super.viewDidLoad()
         let hostingController = NSHostingController(rootView: EditorView().frame(width: self.view.bounds.width - 16.0, height: self.view.bounds.height - 16.0)) // これはNG
         self.addChild(hostingController)
         hostingController.view.translatesAutoresizingMaskIntoConstraints = false
         self.view.addSubview(hostingController.view)

良い例

 class EditorViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let hostingController = NSHostingController(rootView: EditorView())
        self.addChild(hostingController)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(hostingController.view)
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 8.0),
            hostingController.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8.0),
            self.view.bottomAnchor.constraint(equalTo: hostingController.view.bottomAnchor, constant: 8.0),
            self.view.trailingAnchor.constraint(equalTo: hostingController.view.trailingAnchor, constant: 8.0)
        ])

Windowをドラッグできるようにする

titleBarを消すとwindowをドラッグするためにマウスで掴む領域がなくなるので、自分で作る必要がある。それには、まずNSViewにNSTrackingAreaを追加し、mouseDragged(event:)が呼ばれるようにする。mouseDraggedの中でviewがあるwindowのperformDrag(event:)を呼び出す。

class EditorViewController: NSViewController {
...
    override func viewWillAppear() {
        super.viewWillAppear()
	let trackingArea = NSTrackingArea(rect: self.view.bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
	self.view.addTrackingArea(trackingArea)
    }
...
    override func mouseDragged(with event: NSEvent) {
        super.mouseDragged(with: event)
        if self.view.bounds.height -  event.locationInWindow.y < 100.0 { // windowの上部100以内
            guard let window = self.view.window else {
                return
            }
            window.performDrag(with: event)
        }
    }

以上でtitleBarを消した上でWindow周りの基本的な挙動が正常に動くようになったと思う。WindowはAppKitで実装したが、中身はSwiftUIで作り込んでいける構造が出来上がったので、あとは作りたい中身をどんどん実装していきたいと思う。

Discussion

ログインするとコメントできます