Mac Catalystで「常に手前に表示」を実現する

# はじめに

前回、`Mac Catalyst`で`Appkit`固有の**API**を使用する方法について、**SwiftUI**で書いた「逆ポーランド記法で入力する電卓アプリ」を例に紹介した。
[**前回の記事はこちら**](https://qiita.com/nak435/items/a407f235c1a48f9f5ecf)


今回は、同じアプリにて、`Mac Catalyst`の場合に『常に手前に表示（Always on Top）』を実装する方法について説明する。`Mac Catalyst`アプリで`AppKit`を使用する、より実用的な内容である。


# 1. AppKitによる`Always on Top`の実装

これの実装は、ごくごく簡単で、[mainWindow](https://developer.apple.com/documentation/appkit/nsapplication/1428723-mainwindow/)の[level](https://developer.apple.com/documentation/appkit/nswindow/1419511-level)プロパティを設定するだけ。

```swift:Appkit
guard let window = NSApplication.shared.mainWindow else { return }
if isOnTop {
    window.level = .floating    //最前面に固定
} else {
    window.level = .normal      //通常
}
```


<br />次に、『「常に手前に表示」する／しない』 の選択を、アプリのウィンドウタイトルバー（ツールバー）に置いたアイコンのクリックにて、トグル式に切り替えられるようにする。


|アイコン<br />（通常時）|![pin1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/1df62e7b-429c-ce81-3388-4845ba95e9c1.png)|
|-|:-|
|アイコン<br />（常に手前に表示）|![pin2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/213313a2-4c7f-1ebb-02ab-be834308d30e.png)|

アイコンのイメージデータは、上のような2種類を用意した。サイズは30×30程度。
<br />　




# 2. アプリのウィンドウタイトルバーにアイコンを置く

アプリのウィンドウタイトルバーにアイコンを置くためには、いくつかの手順を踏む必要があり、順に説明していく。

1. `NSToolbarDelegate`を実装する
2. `UIWindowSceneDelegate`を実装する


## 2.1 `NSToolbarDelegate`を実装する

- `NSToolbarDelegate`はAppKitのAPIであるため、`ToolbarDelegate`独自クラスでラップしてiOSでもコンパイルが通るようにする
- `ToolbarDelegate`クラスの**extension**で`NSToolbarDelegate`を実装する。このextensionはコンパイルオプションで`Mac Catalyst`のみ有効とする

新規にSwiftファイルを追加し、**ToolbarDelegate.swift**と名付ける。内容は以下の通り。

```swift:ToolbarDelegate.swift
class ToolbarDelegate: NSObject {
    private var pinned = false
}

#if targetEnvironment(macCatalyst)
extension ToolbarDelegate: NSToolbarDelegate {
    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
        toolbarItem.label = "Always on Top"
        toolbarItem.paletteLabel = "Always on Top"
        toolbarItem.toolTip = "Always on Top"
        toolbarItem.tag = toolbar.items.count
        toolbarItem.target = self
        toolbarItem.isEnabled = true
        toolbarItem.image = UIImage(named: "pin")
        toolbarItem.action = #selector(onClicked)
        return toolbarItem
    }
    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        let itemId = NSToolbarItem.Identifier("ALWAYS_ON_TOP")
        let identifiers: [NSToolbarItem.Identifier] = [ itemId ]
        return identifiers
    }
    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        return toolbarDefaultItemIdentifiers(toolbar)
    }

    @objc func onClicked(_ toolbarItem: NSToolbarItem) {
        pinned.toggle()
        toolbarItem.image = pinned ? UIImage(named: "pinned")?.withTintColor(.tintColor) : UIImage(named: "pin")
        loadPlugin(pinned)
    }
}
#endif
```

`NSToolbarDelegate`プロトコルの3つの関数で、以下を実装する。

- ツールバーの識別子を定義（ここでは`ALWAYS_ON_TOP`）
- 上記識別子に対応したツールバーアイテムを作成
- ツールバーアイテムにアイコン（イメージデータ）を設定
- ツールバーアイテムのアクション（`onClicked`）を設定
- `onClicked`（「常に手前に表示」する／しない切り替え）関数は、状態の切り替えと後述するプラグインのロードを行う

:::note warn
ここで、ツールバーアイテムに設定するイメージデータは、本来は`NSImage`のはずだが、なぜか`UIImage`を要求してくる。理由は不明だが、`UIImage`で実際に動作するため、深追いはやめた。
:::


## 2.2 `UIApplicationDelegate`を実装する

上で定義したツールバーの作成を、`UIWindowSceneDelegate`プロトコルの`scene`メソッドで行うのだが、`UIWindowSceneDelegate`を実装するためには、まず**AppDelegate**（`UIApplicationDelegate`）を実装する必要がある。

SwiftUIアプリの場合、**AppDelegate**や**SceneDelegate**は、プロジェクト作成時には普通は作られないため、次の内容で作成する。各**Delegate**は本来はSwiftファイルを分けるべきだが、今回は`AppMain`にまとめて書くことにする。

```swift:AppMain.swift（変更前：元々の内容）
@main
struct RPNCalcApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

勝手に`AppMain`と呼んでしまったが、元々は上のような内容。ここに`@UIApplicationDelegateAdaptor`ディレクティブを追加し、**AppDelegate**と**SceneDelegate**の実装クラスを定義する。

```diff_swift:AppMain.swift（変更後）
@main
struct RPNCalcApp: App {
+   @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

+ //以降を追加
class AppDelegate: NSObject, UIApplicationDelegate {
    func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        config.delegateClass = SceneDelegate.self
        return config
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    //中身はこの次で実装する
}
```


## 2.3 `UIWindowSceneDelegate`を実装する

コンパイルオプションで`Mac Catalyst`のみ有効として、ツールバーの作成と`ToolbarDelegate`のインスタンスを設定する。


```swift:SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var toolbarDelegate = ToolbarDelegate()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

#if targetEnvironment(macCatalyst)
        let toolbar = NSToolbar(identifier: "main")
        toolbar.delegate = toolbarDelegate
        toolbar.displayMode = .iconOnly
        toolbar.isVisible = true
        
        if let titlebar = windowScene.titlebar {
            titlebar.toolbar = toolbar
        }
#endif
    }
    func sceneDidDisconnect(_ scene: UIScene) {
    }
}
```

# 3. `Plugin.swift`を作り直す

[前回の記事で作成した`Plugin.swift`](https://qiita.com/nak435/items/a407f235c1a48f9f5ecf#pluginswiftのコードは以下の通り)の内容を以下のように変更する。（前回の`loadPlugin`メソッドは削除する）

```swift:Plugin.swift
import Foundation

@objc(Plugin)
protocol Plugin: NSObjectProtocol {
    init()
    
    func alwaysOnTop(isOnTop: Bool)
}
```


# 4. `MacPlugin.swift`を作り直す

[前回の記事で作成した`MacPlugin.swift`](https://qiita.com/nak435/items/a407f235c1a48f9f5ecf#macpluginswiftのコードは以下の通り)の内容を以下のように変更する。

```swift:MacPlugin.swift
import AppKit

class MacPlugin: NSObject, Plugin {
    required override init() {
    }
    
    func alwaysOnTop(isOnTop: Bool) {
        guard let window = NSApplication.shared.mainWindow else { return }
        if isOnTop {
            window.isOpaque = true
            window.alphaValue = 0.9
            window.level = .floating
        } else {
            window.isOpaque = false
            window.alphaValue = 1.0
            window.level = .normal
        }
    }
}
```

**mainWindow**の**level**プロパティの設定に加え、「常に手前に表示」にした場合は、裏に隠れるウィンドウがうっすら透けて見えるように、`window.alphaValue`値を`0.9`とした。


# 5. `loadPlugin`を作り直す

`ToolbarDelegate`クラスの**extension**内に、新たな`loadPlugin`を作成する。

```swift:ToolbarDelegate.swift
#if targetEnvironment(macCatalyst)
extension ToolbarDelegate: NSToolbarDelegate {

    : : （中略）
    
    private func loadPlugin(_ pinned: Bool) {
        let bundleFileName = "MacPlugin.bundle"
        guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(bundleFileName), let bundle = Bundle(url: bundleURL), let pluginClass = bundle.principalClass as? Plugin.Type else { return }
        let plugin = pluginClass.init()
        plugin.stayOnTop(isOnTop: pinned)
    }
}
#endif
```

# 6. `ContentView`内の前回の例を削除

[前回の例で`ContentView`に加えた処理](https://qiita.com/nak435/items/a407f235c1a48f9f5ecf#mac-catalystの場合は前項で作成したloadplugin関数を呼び出すことでnsalertで表示するように変更した)を削除（または、コメントアウト）して元に戻しておく。

```diff_swift:ContentView.swift

    : : （略）
    
                Button(action: {
- #if targetEnvironment(macCatalyst)
+ //#if targetEnvironment(macCatalyst)
                    let stacks = stack.isEmpty ? "(empty)" : stack.reversed().map { $0.string }.joined(separator: "\n")
-                   loadPlugin(stacks)
+                   //loadPlugin(stacks)
- #else
+ //#else
                    showingStack.toggle()
- #endif
+ //#endif
                }, label: {
                    Text("Show Stack")
                })

    : : （略）
    
```


以上で必要なすべての実装が完了した。

`Mac Catalyst`でビルドして実行すると、ウィンドウタイトルバーにアイコンが表示されるはずだ。

アイコンをクリックして、『「常に手前に表示」する／しない』 を試してもらいたい。<br />　


<iframe width="314" height="559" src="https://www.youtube.com/embed/7jTY2DIOvvg?rel=0" title="Mac Catalystで「常に手前に表示」を実現する" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>



<br /> _もちろん、iPhoneやiPadでビルドして実行しても、何も起きない（これまでと変化がない）ようになっている。_


# おわりに

今回説明した実装を使った、とある`Mac Catalyst`のMacアプリは、Appleの審査を通過した実績を持っている。
ぜひ参考にしてもらいたい。

#### 以上

https://qiita.com/nak435/items/4d9cc8a8078b163b9b28