[Swift] Mac CatalystでAppkit固有のAPIを使用する

# 0. はじめに

UIKitをベースとしたiOS向けAppを、Macでも動かしたい・・・。これを可能にしたのが`Mac Catalyst`です。これにより、一つのTargetでiPhone, iPad, Macの3つのプラットホームで動くアプリが作成できます。

しかし、2Kや4Kの広いデスクトップと、複数のアプリを同時に起動し使うことができるのは、Macだけです。iPadでも`Split View`や`Slide Over`に対応すれば、2つのアプリは同時に表示できますが、PCであるMacには敵いません。

`Mac Catalyst`もUIKitを使ってプログラムを作成するため、Macで動くといってもMacOS固有な`AppKit`は（普通には）使えません。

今回は`Plugin`という仕掛けを使って、`Mac Catalyst`の場合だけ`AppKit`のAPIを使えるようにする方法を紹介します。

# 1. プロジェクトに`Mac Catalyst`を指定する

iOSのAppプロジェクトを普通に作成すると、Macのところは`Designed for iPad`になっているので、これを削除して、新たに`Mac Catalyst`を追加する。


![scr010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/035cbe8d-4fb1-1bd7-8904-d5209636cb7e.png)
![scr020.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/fbd9eb27-765c-393e-0352-b03617c287c0.png)
![scr030.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/630a4c35-550f-8af2-1102-c1131866604e.png)
![scr050.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/ca92e0be-f472-f8f7-4376-a1516f5cdd25.png)

- シングルウィンドウにする

これはアプリの性質によるが、シングルウィンドウアプリの場合は、`Enable Multiple Windows`を`NO`に変更していくとよい。`YES`のままだと、ウィンドウを<font color='red'><b>×</b></font>で閉じてもアプリは起動したままのため、メニューから終了させる必要がある。

![scr060.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/7f67afe0-5cc7-f257-b491-eb12c5f7c326.png)



# 2. `Plugin`を使う方法

元ネタは [『Mac CatalystでmacOS用アプリを作る』](https://www.docswell.com/s/itok/ZVR1V5-2021-10-15-170228) や [『How to Access the AppKit API from Mac Catalyst Apps』](https://betterprogramming.pub/how-to-access-the-appkit-api-from-mac-catalyst-apps-2184527020b5) ですが、2〜3年前の情報でXcodeのバージョンも変わっていて「そのまんま」とはいかないので、その辺を最新の`Xcode version 14`で、かつ、`SwiftUI`アプリで説明します。（Storyboardを使ったSwiftアプリでも内容的には同じ。）


### 2.1 macOS bundleターゲットを追加

新規にTargetを追加します。`macOS`の`Bundle`を選択し、ここでは`MacPlugin`と名付けました。

![scr080.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/842008e7-4076-e3f0-5b19-e2ef107193d9.png)
![scr090.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/5274003b-6729-378a-a62e-3bea9f2963ff.png)


### 2.2 アプリターゲットにプラグインを埋め込み、プラットフォームを`Mac Catalyst`に変更する

- アプリターゲットに前項で追加した`macOS bundle`を追加します。
（「Frameworks, Libraries, and Embededded Content」に`macOS bundle`を追加）

![scr100.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/3b0844b6-e4ec-952a-d516-a6cd49fd0999.png)
![scr110.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/2e387f57-f1de-84d8-81bc-bd0c1704adfc.png)
![scr120.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/766a50ef-0d45-ceab-a95d-9b0fabec2707.png)

- 追加した`macOS bundle`のFiltersを`Always Used`から`Mac Catalyst`に変更する。

![scr130.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/fdbd7e54-ecdf-039e-4793-a30c3e5f63cb.png)
![scr140.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/9c64ead9-f475-96fa-a6bd-82d505fa5c85.png)

- アプリターゲットのBuild Phasesの「Target Dependencies」と「Enbeded Plugins」に`MacPlugin`が追加され、Filtersが`Mac Catalyst`になっていることを確認する

![scr150.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/216e1d6c-d217-c064-014e-a151fcb08b00.png)


### 2.3 共有プロトコル`Plugin`を作成する

- 新規にSwift Fileを追加し、`Plugin.swift`と名付けた。
![scr160.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/b8e98072-f37a-1cad-e481-1db1a1cdaf03.png)

- 両方のターゲットに含まれていることを確認する。
![scr170.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/77ab9f5d-0bde-09cc-7669-9cffa400559f.png)

- PluginターゲットにSwiftコードを追加すると、XcodeはObjective-Cブリッジヘッダーの設定を提案してくるので受け入れます

![scr180.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/34757345-9375-e251-9c01-42e74267dc02.png)

#### ・`Plugin.swift`のコードは以下の通り

（_関数`showStack`がAppKit固有のAPIを使用する関数。ここはアプリごとに名前も、引数も、関数の個数や内容も変わる_）

```swift:Plugin.swift
import Foundation

@objc(Plugin)
protocol Plugin: NSObjectProtocol {
    init()
    
    func showStack(stacks: String)
}
```

![scr190.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/68d8be85-3c39-0d15-8db5-1b25298daa14.png)



### 2.4 共有プロトコルに準拠したクラスを作成する

- もう一つ新規にSwift Fileを追加し、`MacPlugin.swift`と名付ける。
![scr210.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/5520bc6f-8f63-2d5d-e87f-a8fe200e5ef7.png)

- Pluginターゲットにだけ含める。
![scr220.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/ca6f37dc-75c4-9b0a-2801-ba936defd80b.png)

#### ・`MacPlugin.swift`のコードは以下の通り

今回は`AppKit`のAPIである`NSAlert`クラスを使用した例である。

```swift:MacPlugin.swift
import AppKit

class MacPlugin: NSObject, Plugin {
    required override init() {
    }
    
    func showStack(stacks: String) {
        let alert = NSAlert()
        alert.alertStyle = .informational
        alert.messageText = "Contents of the Stack from AppKit!"
        alert.informativeText = stacks
        alert.addButton(withTitle: "OK")
        alert.runModal()
    }
}
```

![scr240.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/05ce9c35-40d3-77d0-81dc-ed32da002d13.png)

### 2.5 Pluginバンドルをロードし、`MacPlugin`の新しいインスタンスを作成する

```swift
func loadPlugin(_ stacks: String) {
    let bundleFileName = "MacPlugin.bundle"
    guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(bundleFileName) else { return }
    guard let bundle = Bundle(url: bundleURL) else { return }
    guard let pluginClass = bundle.principalClass as? Plugin.Type else { return }
    
    let plugin = pluginClass.init()
    plugin.showStack(stacks: stacks)
}
```

上の例では、`MacPlugin`インスタンスを作成し、`showStack`関数の呼び出しまで行っているが、両者を分ける実装でも可能。

この`loadPlugin`関数を`Mac Catalyst`のみ必要だとして、`MacPlugin.swift`に置くと、コンパイル時にエラーとなる。今回は`Plugin.swift`に同居させました。（独立したファイルでもよい。むしろそうすべき）
![scr230.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/2e8ae755-f81e-071d-9ea8-19bfbf899388.png)


以上で準備は終わり。

### 2.6 呼び出し側を作る

![scr250.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/6a1ec8a0-99f7-beeb-506d-dd7125242ee3.png)
`Show Stack`ボタンが押されたら、`alert`のトリガー変数を`true`してSwiftUIのアラートで表示していたところを、

#### `Mac Catalyst`の場合は、前項で作成した`loadPlugin`関数を呼び出すことで`NSAlert`で表示するように変更した。

![scr260.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/d752993f-a6f1-a65e-5b60-65e8335f213d.png)

- 結果は↓ご覧の通り

|適用前(iPhone)|<img width="250" alt="scr285.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/979ac96a-739a-3c6e-89cb-48c1c2c9541a.png" />|
|-|:-:|
|適用前(Mac)|<img width="286" alt="scr280.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/f9c5c492-9a40-138c-a051-19176aedb9c1.png" />|
|適用後(Mac)|<img width="300" alt="scr270.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/d69e69f6-3df1-c4b9-4db9-6b5fe0dfb2c2.png" />|

- `ContentVoew.swift`の全コードも載せておく。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    let MAX = 10_000_000_000.0
    let btnFace: [String] = ["7", "8", "9", "AC", "4", "5", "6", "÷", "1", "2", "3", "×", "0", "+", "−", "Enter", ]
    @State var stack: [Double] = []
    @State var num: Double = 0
    @State var display: String = "0"
    @State private var showingStack = false
    var body: some View {
        GeometryReader { proxy in
            let rectSize = min(proxy.size.width, proxy.size.height) / 6
            VStack {
                TextField("", text: $display)
                    .textFieldStyle(.roundedBorder)
                    .multilineTextAlignment(TextAlignment.trailing)
                    .minimumScaleFactor(0.5)
                    .font(.system(size: rectSize / 1.4, weight: .bold, design: .rounded))
                    .padding(.horizontal, 10)
                    .disabled(true)
                ForEach(0 ..< 4) { row in
                    HStack {
                        ForEach(0 ..< 4) { col in
                            Button(action: {
                                btnDown(position: row * 4 + col)
                            }, label: {
                                Text(btnFace[row * 4 + col])
                                    .minimumScaleFactor(0.2)
                                    .font(.system(size: rectSize / 1.4, weight: .bold, design: .rounded))
                                    .frame(width: rectSize, height: rectSize)
                            })
                            .buttonStyle(.bordered)
                        }
                    }
                }
                Button(action: {
#if targetEnvironment(macCatalyst)
                    let stacks = stack.isEmpty ? "(empty)" : stack.reversed().map { $0.string }.joined(separator: "\n")
                    loadPlugin(stacks)
#else
                    showingStack.toggle()
#endif
                }, label: {
                    Text("Show Stack")
                })
                .padding(.top, 20)
                .alert("Contents of the Stack", isPresented: $showingStack) {
                    Button("OK") {
                    }
                } message: {
                    let stacks = stack.isEmpty ? "(empty)" : stack.reversed().map { $0.string }.joined(separator: "\n")
                    Text(stacks)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
    func btnDown(position: Int) {
        switch position {
            case 3: // AC
                stack.removeAll()
                num = 0
                display = num.string
            case 7: // ÷
                if num != 0 { stack.append(num) }
                guard let (a, b) = popStack2() else { return }
                calc(/, a, b)
            case 11: // ×
                if num != 0 { stack.append(num) }
                guard let (a, b) = popStack2() else { return }
                calc(*, a, b)
            case 13: // +
                if num != 0 { stack.append(num) }
                guard let (a, b) = popStack2() else { return }
                calc(+, a, b)
            case 14: // −
                if num != 0 { stack.append(num) }
                guard let (a, b) = popStack2() else { return }
                calc(-, a, b)
            case 15: // enter
                if let d = Double(display) {
                    stack.append(d)
                    num = 0
                }
            case 99: // .
                if let _ = Double(num.string + ".") {
                    display += "."
                }
            default: // 0-9
                let c = btnFace[position]
                if let d = Double(num.string + c) {
                    if d < -MAX || d > MAX { return }
                    num = d
                    display = num.string
                }
        }
    }
    func popStack2() -> (Double, Double)? {
        if stack.count < 2 { return nil }
        let b = stack.removeLast()
        let a = stack.removeLast()
        return (a, b)
    }
    func calc(_ f: (Double, Double)->Double, _ a: Double, _ b: Double) {
        let d = f(a, b)
        stack.append(d)
        display = d.string
        num = 0
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension Double {
    var string: String {
        self == floor(self) ? "\(Int(self))" : "\(self)"
    }
}
```

_なお、`loadPlugin`関数をiPhoneやiPadプラットホームから呼び出したとしても、何も起きず無害である。_

今回は、アプリの説明は省きますが、「逆ポーランド記法で入力する電卓アプリ」です。<b>逆ポーランド記法（後置記法）</b>を知らないと、この電卓は何も答えてくれません。よく知らないという方は、↓こちらの記事を参照してください。

https://qiita.com/yumura_s/items/ddb0d143fb0e9a082891

『1+2×3＝7』。この電卓には『 1 _ 2 _ 3 × + 』（_はEnterを示す）と入力します。

# 3. おわりに


『Mac CatalystでAppKit固有のAPIを使用する』ためには、以上のような手順が必要で、SwiftUIでの説明はほぼ皆無でしたので、今後、スムーズに実施できるようにまとめておきました。

今回の例では`AppKit`の`NSAlert`クラスを使用しましたが、これ自身はあまり意味を持たない例です。次回の続編では『Macなら絶対便利』という使い方の例を紹介する予定です。**⇒**[**こちら**](https://qiita.com/nak435/items/fc26f7d98466a782803e)に公開しました。_2023.7.18_

以上です。