SwiftUI マルチプラットフォームでtoolbar対応

# 環境
Xcode Version 13.4.1
での動作環境になります。

# 概要
SwiftUIにはtoolbarというmodifierがあります。

https://developer.apple.com/documentation/swiftui/view/toolbar(content:)-5w0tj

このtoolBarにはToolbarItemをはじめとしたContentが設定でき、これを設定することにより、画面にボタンなどを配置することができます。
ToolbarItemの特徴はToolbarItemPlacementが指定できる点です。

https://developer.apple.com/documentation/swiftui/toolbaritemplacement

toolbarには直接ボタンの位置を指定することもできますが、キャンセルボタンとして配置(.cancellationAction)、重要なアクションとして配置(.primaryAction)、など　役割に基づいた配置指定ができます。
これを使いこなせば、SwiftUIでプラットフォームごとに適切なボタン配置ができることになります。

# toolbarでの困りごと
例えば、名前を入力するための画面が存在するとします。
そこにはキャンセルボタンと追加ボタンが存在し、名前を入力するテキストボックスが存在します。
そのため、toolbarにはcancellationActionとしてCancelボタン、primaryActionとしてAddボタンが配置されるようにコードを作成しました。

```swift
import SwiftUI

struct ContentView: View {
    @State private var name = ""
    var body: some View {
        Form {
            TextField("Name", text: $name)
                .padding()
        }
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel", action: {})
            }
            ToolbarItem(placement: .primaryAction) {
                Button("Add", action: {})
            }
        }
    }
}
```

このコードを動かすと、macOS用アプリ、watchOS用アプリではtoolbarに指定したボタンが表示されます。
watchOSではprimaryActionのアイテムは画面を引っ張ることで表示されます。

![macOS画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/52d6c91c-4501-06b5-526f-e0d58ea71570.png)

![watchOS画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/829ec152-e381-f846-0e5f-82d15d038849.png)

が、iOS、iPad、tvOSにはボタンが表示されません。

![Simulator Screen Shot - iPod touch (7th generation) - 2022-07-28 at 16.00.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/e4902fe8-ffeb-6a25-fd40-79decf292e81.png)

![Simulator Screen Shot - Apple TV - 2022-07-28 at 16.01.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/17a734f0-fdd9-8f5c-3351-dedc2bf27c1a.png)

これは、iOS及びtvOSはprimaryActionに設定したものがナビゲーションバーの後端側(右)、cancellationActionがナビゲーションバーの先頭側(左)に表示されるようになっているため、ナビゲーションバーの実装のない上記ビューは表示されないことになっています。

つまりiOSおよびtvOSではprimaryAction及びcancellationActionの意図としてボタンを表示したい場合はNavigationViewの実装が必須となります。

なので、NavigationViewの実装をしてみます。

```swift
struct ContentView: View {
    @State private var name = ""
    var body: some View {
        NavigationView {
            Form {
                TextField("Name", text: $name)
                    .padding()
            }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel", action: {})
                }
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", action: {})
                }
            }
        }
    }
}
```

こうすると確かにiPhoneではCancelボタンとAddボタンが表示されるようになりました。
しかし、iPadではこの入力欄が左側、右側に何もないビューが表示される、という分割表示になりました。
macOSも同様です。

![Simulator Screen Shot - iPad (9th generation) - 2022-07-28 at 16.15.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/1d4e6452-767a-6821-f854-19e5266e5af6.png)

![スクリーンショット 2022-07-28 16.16.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687960/07616d79-eae2-0ce0-cab3-2f411fe8c94c.png)

iPadでこの現象を防ぐためには `.navigationViewStyle(.stack)` を配置することで防ぐことが可能ですが、macOSはstackに対応しておらず、そもそも今回の主題としてはNavigationLinkを配置したいわけではなく、キャンセルと追加ボタンを持った画面を作成したいだけなので、その観点でシンプルなツールバー実装ができないかと考えてみました。

# 結論
以下のようなカスタムmodifier `multiPlatformToolbar` を使うことで解決?しました。

```swift
struct MultiPlatformToolbar<T: ToolbarContent>: ViewModifier {
    let title: String
    let toolbarContent: () -> T
    func body(content: Content) -> some View {
#if os(OSX) || os(watchOS)
        content
            .toolbar(content: toolbarContent)
#else
        NavigationView {
            content
                .navigationTitle(title)
                .toolbar(content: toolbarContent)
        }
        .navigationViewStyle(.stack)
#endif
    }
}

extension View {
    func multiPlatformToolbar<Content>(title: String, @ToolbarContentBuilder content: @escaping () -> Content) -> some View where Content : ToolbarContent {
        modifier(MultiPlatformToolbar(title: title, toolbarContent: content))
    }
}
```

```swift
import SwiftUI

struct ContentView: View {
    @State private var name = ""
    var body: some View {
        Form {
            TextField("Name", text: $name)
                .padding()
        }
        .multiPlatformToolbar(title: "title") {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel", action: {})
            }
            ToolbarItem(placement: .primaryAction) {
                Button("Add", action: {})
            }
        }
    }
}

```
multiPlatformToolbarの内部でOSごとにNavigationが必要か見て、toolbarを構築しています。
toolbarのmodifierをmultiPlatformToolbarに置き換えることにより、それぞれのプラットフォームでtoolbarにボタンが配置されるようになりました。
navigationTitleを内部で実装しているのは、これがないとtvOSでボタンが表示されないためです。

# まとめ
[SwiftUI On All Devices](https://developer.apple.com/videos/play/wwdc2019/240/)とある通り、SwiftUIはマルチプラットフォームに対応してますが、細かいところは結構大変です。