Mac CatalystでiOSアプリとMacアプリを同時に作る

# はじめに

**iOSアプリがmacOSで動くようになる** [*1](https://jp.techcrunch.com/2019/06/04/2019-06-03-ios-apps-will-run-on-macos-with-project-catalyst/)

今年、Appleから発表された**Mac Catalyst** [*2](https://developer.apple.com/jp/mac-catalyst/)。Mac Catalystとは、iOSアプリのソースコードを流用してMacアプリを開発できるというツールセットです。TwitterがMac Catalystを使ってMac版デスクトップアプリを復活させる[*3](https://jp.techcrunch.com/2019/10/12/2019-10-11-twitter-comes-back-to-the-mac/) など、既に商用アプリでも使われはじめています。今回はこのMac Catalystを使ってiOSアプリとMacアプリを開発し、その使い方を確認しました。

# アプリ概要

今回作ったものは、とても簡単な手書きノートアプリです。App Storeにリリースしているので、以下リンクからインストールできます。なお、Mac Catalyst対応アプリはmacOS 10.15以上のPCでインストールできます。

[Blue Sketch - iOSアプリ](https://apps.apple.com/us/app/blue-sketch/id1491913803?l=ja&ls=1)
[Blue Sketch - Macアプリ](https://apps.apple.com/us/app/blue-sketch/id1492010457?l=ja&ls=1&mt=12)

1stバージョンをリリースするまでの開発期間は3日+App Store申請に1日です。

iPad版とMac版のスクリーンショットを以下に載せます。

**キャンバス画面:** この青いキャンバス上に白線を書くことができます。
![Qiita1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/2c65bfb7-5efb-1e73-f398-c162f3ca42e2.png)

**UIAlertController:** Mac版は上からニョキッと出てくる感じです。
![Qiita2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/966a541f-eff6-f11a-4559-016271fec033.png)

**UIActivityViewController:** 画像をエクスポートするために使っていますが、Mac版でも普通に動きます。
![Qiita3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/a215cf98-98ec-47c2-53af-ca84687e78fb.png)

ご覧の通り、UIKitがそのままmacOSで使える感じです。

## 使用ライブラリ

|Name|Version|内容|
|:--|:--|:--|
|[Default](https://github.com/Nirma/Default)|3.0.0|UserDefaultsのラッパー|
|[MKAToggleButton](https://github.com/HituziANDO/MKAToggleButton)|1.2.3|自作のマルチステートButtonライブラリ|
|[R.swift](https://github.com/mac-cain13/R.swift)|5.1.0|リソースファイルをいい感じに扱うライブラリ|
|[XCGLogger](https://github.com/DaveWoodCom/XCGLogger)|7.0.0|ロガー|

すべてCocoaPodsでインストールしています。

最初はモバイルDBに[Realm](https://realm.io/)を使おうかと思いましたが、Mac Catalystでうまくビルドができずやめました。今回は、UserDefaultsとファイル保存でユーザデータを管理しています。

その他、Firebaseや広告SDKも試してみたいですが、今回は未検証です。

# 開発

ここからはアプリ開発において得た知見を書いていきます。

## 開発環境

||Version|
|:--|:--|
|macOS|10.15.1|
|Xcode|11.3|
|Swift|5|

## Mac Catalyst対応

iOSアプリをMac Catalystに対応する手順は以下になります。

1. Mac Catalyst対応するTarget > General > Deployment Info > Macにチェック

    <img width="677" alt="スクリーンショット 2019-12-18 20.58.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/9cde67e8-a567-1c38-7989-ff7f3a170521.png">


1. My Macを選択して▶Runをクリック

    <img width="419" alt="スクリーンショット 2019-12-13 23.46.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/e9a16cf6-d897-42ac-7cb3-19487bb0cb66.png">


    ビルドを開始します。無事ビルドが通れば成功です。

## Macアプリ向けにカスタマイズ

ビルドが通ればMacアプリとして使うことはできますが、このままでは**デスクトップアプリケーションらしさ**が足りないため、いくつかのカスタマイズをします。

### AppIcon

Macアプリ用にアイコンを指定しなければ、iOSアプリのアイコンが使われます。しかし、Macアプリのアイコンは、iOSアプリのように角丸正方形である必要はありません。折角なので、Macアプリ用にデザインを少し変えてみたいと思います。

**手順**

1. Assets.xcassets > AppIconを開く
1. 右パネルのMac Allにチェック
1. Macアプリ用のアイコンホルダーができるので各画像ファイルをDrag & Drop

<img width="1118" alt="スクリーンショット 2019-12-18 21.13.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/24f77d27-8cfb-c31b-8a61-03200d9ab082.png">

### LaunchScreen

Mac CatalystはLaunchScreenに対応していないようです。何か設定方法があるのかもしれませんが、今回はiOSアプリのみ対応しています。

### ダークモード対応

特に手を加えることはなかったです。UIKitのダークモード対応がそのまま使えました。

### Mac Catalystかどうかの判定

Mac Catalystの時だけ処理したい場合は以下のように書けます。

```swift
#if targetEnvironment(macCatalyst)
print("Mac Catalystだよ")
#else
print("Mac Catalystじゃないよ")
#endif
```

ちなみに、`#available(iOS 13.0, *)`を使った判定はMac Catalystでも通ります。APIとしてはiOSだからということでしょうか。

```swift
if #available(iOS 13.0, *) {
    print("Mac Catalystも通るよ")
}
```

### タイトルバーとステータスバー

Macアプリにはタイトルバーがあります↓
<img width="789" alt="スクリーンショット 2019-12-18 21.31.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/2e8fd754-dd5b-ab4a-98d6-474439f77494.png">

タイトルバーを消してモダンなデザインにしてみます↓
<img width="788" alt="スクリーンショット 2019-12-18 21.27.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/05aff2f5-c43f-78b6-8d11-f4d8afbb07b0.png">

```swift:ViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()

    #if targetEnvironment(macCatalyst)
    if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
        appDelegate.window?.windowScene?.titlebar?.titleVisibility = .hidden
    }
    #endif
}
```

`titlebar`はMac CatalystにしかないAPIのため`#if targetEnvironment(macCatalyst) ~ #endif`で囲みます。

なお、Macアプリにはステータスバーが無いため、`statusBarFrame`は(0, 0, 0, 0)を返します。

```swift:ViewController.swift
var statusBarFrame: CGRect {
    if #available(iOS 13.0, *) {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
              let statusBarManager = appDelegate.window?.windowScene?.statusBarManager else {
            return .zero
        }
        return statusBarManager.statusBarFrame  // => (0, 0, 0, 0) on Mac Catalyst
    }
    else {
        return UIApplication.shared.statusBarFrame
    }
}
```

### メニューバー

Macアプリにはメニューバーがあります。🍎マークが並んでいるバーですね。デフォルトでは以下のようなメニューが並んでいます。

<img width="543" alt="スクリーンショット 2019-12-18 23.25.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/fbf3ff6a-2cf7-0155-06f8-e3d6e6e29197.png">

アプリで使わないメニューがあるので以下のようにカスタマイズします。

<img width="313" alt="スクリーンショット 2019-12-18 23.27.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/6845fe51-373e-c00f-62b0-3d8b2170e929.png">

UIResponderクラスにメニューを構築する`buildMenu(with:)`メソッドがあるので、これをオーバーライドします。

```swift:AppDelegate.swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    ...

    #if targetEnvironment(macCatalyst)
    override func buildMenu(with builder: UIMenuBuilder) {
        guard builder.system == UIMenuSystem.main else { return }

        // 初期メニューで不要なものを削除
        builder.remove(menu: .file)
        builder.remove(menu: .format)
        builder.remove(menu: .help)
        builder.remove(menu: .services)
    }
    #endif
}
```

また、ショートカットキーをメニューに割り当てることもできます。今回作ったアプリもUndo/RedoをそれぞれCommand+z/Command+Shift+zに割り当てています。

```swift:AppDelegate.swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    ...

    #if targetEnvironment(macCatalyst)
    override func buildMenu(with builder: UIMenuBuilder) {
        ...
        
        // Undo/Redoメニューの追加
        let undoAction = UIKeyCommand(title: "Undo",
                                      action: #selector(handleUndoMenu(_:)),
                                      input: "z",
                                      modifierFlags: [.command])
        let redoAction = UIKeyCommand(title: "Redo",
                                      action: #selector(handleRedoMenu(_:)),
                                      input: "z",
                                      modifierFlags: [.command, .shift])
        let editMenu   = UIMenu(title: "Edit",
                                image: nil,
                                identifier: "com.example.MyApp.editMenu",
                                options: .destructive,
                                children: [undoAction, redoAction])
        builder.insertSibling(editMenu, afterMenu: .application)
    }

    @objc func handleUndoMenu(_ command: UIKeyCommand) {
        // TODO: Undo処理
    }

    @objc func handleRedoMenu(_ command: UIKeyCommand) {
        // TODO: Redo処理
    }
    #endif
}
```

詳細は長くなるため、また別の機会に書きたいと思います。

### ボタンのマウスオーバー(Hover)対応

Macアプリでは、マウスカーソルをボタンに重ねた時のスタイルを設定できます。

![hover_button.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/b8c4926e-b5a4-160f-73b7-a008790fa0de.gif)

下記のコードは、マウスオーバー時にボタンのタイトルと枠線をハイライトする例です。

```swift:ViewController.swift

private let normalColor = UIColor.blue
private let hoverColor  = UIColor.cyan

private lazy var closeButton: UIButton = {
    let button = UIButton(frame: CGRect(x: 0, y: 0, width: 160.0, height: 44.0))
    button.setTitle("Close", for: .normal)
    button.setTitleColor(normalColor, for: .normal)
    button.layer.cornerRadius = 5.0
    button.layer.borderWidth = 1.0
    button.layer.borderColor = normalColor.cgColor
    button.addTarget(self, action: #selector(closeButtonClicked(_:)), for: .touchUpInside)

    if #available(iOS 13.0, *) {
        let hover = UIHoverGestureRecognizer(target: self, action: #selector(hovering(_:)))
        button.addGestureRecognizer(hover)
    }

    return button
}()

@available(iOS 13.0, *)
@objc func hovering(_ recognizer: UIHoverGestureRecognizer) {
    guard let button = recognizer.view as? UIButton else { return }

    switch recognizer.state {
        case .began, .changed:
            button.setTitleColor(hoverColor, for: .normal)
            button.layer.borderColor = hoverColor.cgColor
        case .ended:
            button.setTitleColor(normalColor, for: .normal)
            button.layer.borderColor = normalColor.cgColor
        default:
            break
    }
}

@objc func closeButtonClicked(_ sender: UIButton) {
    dismiss(animated: true)
}
```

なお、UIHoverGestureRecognizerは今のところiOSアプリには対応していないようです(指を重ねても何も起きません)。actionに指定した`hovering(_:)`メソッドが呼ばれていませんでした。

### ウィンドウサイズの変更に追従する

Macアプリは、ウィンドウ枠をマウスドラッグしてウィンドウサイズが可変なため、ドラッグ中のレンダリング処理を見直す必要がありました。ドラッグ中はUIViewControllerの`viewWillLayoutSubviews()`および`viewDidLayoutSubviews()`が繰り返し呼ばれるため、キャンバスの更新処理を追記しました。

```swift:CanvasViewController.swift
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // TODO: キャンバスの再計算
}
```

### UIScreen.main.bounds/nativeBoundsの値が謎

Macアプリの場合、`UIScreen.main.bounds`および`UIScreen.main.nativeBounds`の値が、アプリのウィンドウサイズでもなく、Macのディスプレイサイズとも異なる値を返してくるため、このプロパティに依存したコードを書いていると影響が出るかもしれません。`UIScreen.main.bounds`を使わないように修正する必要がありました。

## App Store申請

今回、iOSアプリをApp Storeへ、MacアプリをMac App Storeへそれぞれ申請しました。申請手順については以下の記事で紹介しています。
[iOSアプリとMac CatalystアプリのApp Store申請手順](https://qiita.com/hituziando/private/a7d50f34ca04e0b1e7de)

# まとめ

今回はView(見た目)の調整を中心にアプリを作りました。CameraやBluetooth、Audioなどハードウェアの利用、AWSやFirebaseといった外部サービス連携などは試せていませんが、少ない労力でiOS向けとmacOS向けのアプリを作ることができました。
特に、iOS開発に慣れた人がmacOS向けのアプリを開発する場合、Mac Catalystは短期間で高品質のアプリを作る有効な手段になるかもしれません。

# 参考

1. 「この秋からiOSアプリがmacOSで動くようになる」, TechCrunch
https://jp.techcrunch.com/2019/06/04/2019-06-03-ios-apps-will-run-on-macos-with-project-catalyst/

1. 「iPad用AppをMacへ」, Apple 
https://developer.apple.com/jp/mac-catalyst/

1. 「Twitter公式アプリのMac版がMac Catalystアプリとして復活」, TechCrunch
https://jp.techcrunch.com/2019/10/12/2019-10-11-twitter-comes-back-to-the-mac/