🚥

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

2022/12/30に公開

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をドラッグできるようにする(方法その1)

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)
        }
    }

Windowをドラッグできるようにする(方法その2・多分推奨はこっち)

方法その1だと、inActiveな時にウィンドウが移動できない。
他のアプリを選択してる状態で、自分のアプリのウィンドウをドラッグすれば位置が動かせるのが通常の動作だが、これができていなかった。以下のようにNSWindowのisMovableByWindowBackgroundをtrueにすることで解決できた。

window.isMovableByWindowBackground = true


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

Discussion