SwiftUI 実践 tips 集

# はじめに
[MIHO](https://apps.apple.com/jp/app/miho/id1494629088) というキャラボイスアプリを SwiftUI と Combine を用いて開発しました。互換性の欄が iOS13 以降となっていることが確認できるかと思います（証拠とは言えませんが）。
このアプリを開発するにあたって学んだことをここにまとめたいと思います。

# TapGesture & ContentShape
`List` を用いた UI で、セルをタップすると別の View に遷移する処理は公式のチュートリアルでも確認できます。非常にシンプルです。
では、「**セルをタップしたときに音声を再生させる**」のように、セルをタップしたときに独自の処理を走らせる場合はどうしたら良いでしょうか。解決方法としては `Button` を使う方法と `onTapGesture` を使う方法が考えられます。
今回は `onTapGesture` を使う場合を紹介します。

```swift
List(VoiceModel.allCases) { item in
    VoiceRowView(voice: item)
        .onTapGesture {
            viewModel.action()
        }
}
```

使い方は上記の通り、非常にシンプルです。

```swift
struct VoiceRowView: View {
    var body: some View {
        HStack(spacing: 8) {
            Image("image") 
            Text("Apple")
            Spacer()
        }
    }
}
```

次に、そのセルのレイアウトが上記のように `Spacer` を用いた実装になっていたとします。
この場合、左から画像・文字列と並び、右側には空白ができることになります（もちろん文字数が多い場合は右端まで埋まる）。ここで勘がいい方は気がつくかもしれませんが、この右側の空白部分ではタップ反応を受け取ることができません。

```swift
List(VoiceModel.allCases) { item in
    VoiceRowView(voice: item)
        .contentShape(Rectangle())
        .onTapGesture {
            self.playView.viewModel.prepare(item: item)
        }
}
```

`onTapGesture` を使うという条件下で、この問題の解決方法は上記のようになります。 `.contentShape(Rectangle())` と指定することでセルの形を長方形であると定義します。こうすることによって空白があったとしてもセル全体でアクションを受け取ることが可能になります。

参考文献：
[How to read tap and double-tap gestures](https://www.hackingwithswift.com/quick-start/swiftui/how-to-read-tap-and-double-tap-gestures)
[SwiftUI can't tap in Spacer of HStack](https://stackoverflow.com/questions/57191013/swiftui-cant-tap-in-spacer-of-hstack)

# GeometryReader
View のサイズを画面サイズから計算して指定したい場合があると思います。今回は、 16:9 の View を作成する例とともにご紹介します。

```swift
var body: some View {
    GeometryReader { geometry in
        AVPlayerView(bundleDataName: "header")
            .frame(width: geometry.size.width, height: (9 * geometry.size.width) / 16)
    }
}
```

上記が UI 設計例になります。 `GeometryReader` を用いることで **その View の width と height** を取得することが可能です。 ただ、これが端末の画面サイズを示すものではないことに注意してください（Safe Area のことも意識してあげてくださいね）。

参考文献：
[How to provide relative sizes using GeometryReader](https://www.hackingwithswift.com/quick-start/swiftui/how-to-provide-relative-sizes-using-geometryreader)

# Sheet & Alert
画面遷移でプッシュ遷移は `List` を用いることでシンプルに実装が可能です。もう一つの遷移方法として存在する「モーダル遷移」を実現できるのが `sheet` です。ここでは、先程も説明した `TapGesture` とともに実装する例でご紹介します。

```swift
@State private var isShowDetail: Bool = false

var body: some View {
    GeometryReader { geometry in
        ...
    }
    .onTapGesture {
        self.isShowDetail = true
    }
    .sheet(isPresented: $isShowDetail) {
        VoiceDetailView()
    }
}
```

様々な実装パターンが考えられますが、上記のような処理でモーダル遷移が実現できます。仕組みとしては、まず画面遷移を行うかどうかを示すフラグを宣言しておき、 `Button` や `TapGesture` によるアクションキャッチ時にフラグを書き換えてあげます。そして、そのフラグの変化を検知した `sheet` が画面遷移を行うといった動きになります。

```swift
@State private var isShowAlert: Bool = false

var body: some View {
    GeometryReader { geometry in
        ...
    }
    .onTapGesture {
        self.isShowAlert = true
    }
    .alert(isPresented: $isShowAlert) {
        Alert(
            title: Text("Title"),
            message: Text("message"),
            dismissButton: .default(Text("OK"))
        )
    }
}

```

ついでに Alert の表示方法も同じ考え方なので載せておきます。非常にシンプルですね。

# NavigationViewStyle
`List` を用いて iPad 対応する場合に気にしないといけないことに Style の存在があります。特に条件を指定しない場合、iPad で `List` は設定画面のような表示になります。左側に項目一覧、そして右側に詳細画面となるおなじみの UI です。
セルをタップして詳細画面に遷移するような場合は、この標準 UI に適応させればいいのですが、今回はセルのタップで画面遷移は発生しないので、 iPad でも iPhone と同様の UI で表示しなければなりません。

ここで登場するのが `navigationViewStyle()` です。

```swift
NavigationView {
    List(VoiceModel.allCases) { item in
        ...                    
    }
}
.navigationViewStyle(StackNavigationViewStyle())
```

このように `navigationViewStyle()` に `StackNavigationViewStyle()` を指定することで、 iPad でもリストが画面全面に表示されるようになります。

# *Representable
私の GitHub や Qiita 記事、勉強会資料などで何度も登場してきた *Representable ですが、今回はちょっと実践的な内容となります。
今回は SwiftUI にはないパーツである `UIProgressView` を SwiftUI に対応させる例でご紹介します。

```swift
struct ProgressView: UIViewRepresentable {
    @Binding var progress: Float
    var progressTintColor: UIColor

    func makeUIView(context: Context) -> UIProgressView {
        return UIProgressView().apply { this in
            this.progressTintColor = progressTintColor
        }
    }

    func updateUIView(_ uiView: UIProgressView, context: Context) {
        uiView.setProgress(progress, animated: true)
    }
}
```

これが実装例になります。 `UIProgressView` は `progress` の値を変化させてあげなければいけません。そのため、単純に `UIViewRepresentable` に準拠させてあげるだけでは動作してくれません。
そこで、 `@Binding` を用いて外部の値変化を監視できるようにしています。

```swift
#if DEBUG
struct ProgressViewPreviews: PreviewProvider {
    static var previews: some View {
        ProgressView(progress: .constant(0.5), progressTintColor: .blue)
            .previewLayout(.fixed(width: 300, height: 10))
    }
}
#endif
```

ただ、先程作成した `ProgressView` をプレビュー可能にするとき、困ることがあります。 `@Binding var progress: Float` に渡す初期値です。対応方法は上記実装例にもあるように `.constant()` に値をセットしたものを指定するという感じになります。

参考文献：
[How to use UIKit in SwiftUI](https://sarunw.com/posts/uikit-in-swiftui/)
[SwiftUI @Binding Initialize](https://stackoverflow.com/questions/56685964/swiftui-binding-initialize)

# さいごに
説明間違えやアドバイス、ご指摘などありましたら遠慮なくコメントいただけると嬉しいです！
最後まで読んでいただき、ありがとうございます！！