SwiftUIのViewThatFitsを使ってレスポンシブWebデザインっぽいレイアウトを実装してみる

Webの世界では多くのWebページがレスポンシブWebデザインで実装されています。
例えば[Qiitaのホームページ](https://corp.qiita.com/products/)も下記のようにブラウザの横幅に応じてデザインが変わります。
![スクリーンショット 2022-06-19 13.09.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42150/38906b91-d4bd-306a-9ed4-5e532484ebae.png)
こちらのデザインは、横幅が小さいときは縦に並び、長いときは横に並ぶようになっています。

iOS SwiftUIの世界でもWWDC22で発表された`ViewThatFits`を使ってWeb同様のレスポンシブデザインを実装できるようになったので試してみました。

## ViewThatFitsとは

[ViewThatFits](https://developer.apple.com/documentation/swiftui/viewthatfits)はWWDC22で新たに発表されたSwiftUIのレイアウトです。
Appleの例では以下のようなViewが紹介されています。
![ViewThatFits-1@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42150/878bc45e-cd55-c520-dcd1-96824aeef040.png)
この3つのViewは全て同じことを表現しているのですが、横幅がそれぞれ異なります。
紹介されているコードは以下になります。
```swift
struct UploadProgressView: View {
    @State var uploadProgress: Double

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Text("\(uploadProgress.formatted(.percent))")
                ProgressView(value: uploadProgress)
                    .frame(width: 100)
            }
            ProgressView(value: uploadProgress)
                .frame(width: 100)
            Text("\(uploadProgress.formatted(.percent))")
        }
    }
}
```

```swift
VStack {
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 200)
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 100)
    UploadProgressView(uploadProgress: 0.75)
        .frame(maxWidth: 50)
}
```
2つ目のコードで`maxWidth`をそれぞれ200、100、50で指定しています。
それぞれのwidthに応じて、`ViewThatFits`内で定義されたViewの中から最適なものが選ばれ、表示されているのが分かります。

このようにSwiftUI内でif文のような条件分岐を書くことなく、レイアウトを定義できるのが`ViewThatFits`となります。

## Qiitaのホームページのようなデザインを実装してみる

冒頭で紹介したQiitaのホームページのようなデザインを`ViewThatFits`を使って実装してみます。
実際に実装したものがこちらです。
<img width="400" alt="16.48.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42150/e9a59633-296c-9745-4cc1-846fb254c8ba.png">
![スクリーンショット 2022-06-19 16.48.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42150/5aa3c9bf-91fd-4757-2a2d-e2ee7fc8662f.png)
端末の向きがPortraitかLandscapeかでレイアウトが違うのが分かります。
`ViewThatFits`を使って横幅が狭いときは1つ目のデザインでレイアウトされ、広いときは2つ目のデザインでレイアウトされるように実装しています。
実際のコードは以下のようになります。

```swift
struct ContentView: View {
    var body: some View {
        VStack {
            ProductView(productType: .qiita)
            ProductView(productType: .qiitaTeam)
        }
        .padding()
    }
}
```
`ContentView`では2つの`ProductView`をVStack内に配置しています。
ここでは、端末の横幅などを気にすることなく、配置しているだけです。

`ProductView`は以下のようになります。
```swift
struct ProductView: View {
    let productType: ProductType
    var body: some View {
        GeometryReader { geometry in
            ViewThatFits(in: .horizontal) {
                // 画像とテキストが横に並ぶレイアウト
                HStack(spacing: 20) {
                    Image(productType.imageName)
                        .resizable()
                        .scaledToFill()
                        .frame(width: geometry.size.width / 2, height: geometry.size.width / 4)
                    VStack(alignment: .leading) {
                        Text(productType.title)
                            .font(.title)
                        Text(productType.caption)
                    }
                }
                .frame(minWidth: 400) // このレイアウトが使われる閾値

                // 画像とテキストが縦に並ぶレイアウト
                VStack(spacing: 20) {
                    Image(productType.imageName)
                        .resizable()
                        .scaledToFill()
                        .frame(width: geometry.size.width, height: geometry.size.width / 2)
                    VStack(alignment: .leading) {
                        Text(productType.title)
                            .font(.title)
                        Text(productType.caption)
                    }
                }
            }
        }
    }
}
```

```swift
enum ProductType {
    case qiita
    case qiitaTeam

    var imageName: String {
        switch self {
        case .qiita: return "Qiita"
        case .qiitaTeam: return "QiitaTeam"
        }
    }

    var title: String {
        switch self {
        case .qiita: return "Qiita"
        case .qiitaTeam: return "Qiita Team"
        }
    }

    var caption: String {
        switch self {
        case .qiita: return "Qiita は、エンジニアに関する知識を記録・共有するためのサービスです。"
        case .qiitaTeam: return "Qiita Team は、チームを強くするための 社内向け情報共有サービスです。"
        }
    }
}
```
`ProductView`では、`ViewThatFits`の中に端末の横幅ごとのレイアウトを実装しています。
1つ目が横幅400以上のときに使われるレイアウトで、2つ目がそれ以下のときに使われるレイアウトです。
また、今回は`GeometryReader`を使っているので、横幅400でレイアウトは分かれますが、それ以外の横幅でも横幅に応じたレイアウトがされるように実装しています。

今回は横幅に応じてレイアウトを変えたので`ViewThatFits(in: .horizontal)`と定義しています。
例ではiPhoneの向きだけを紹介しましたが、iPadへの対応も同様に可能です。

## まとめ
WWDC22で紹介された`ViewThatFits`を実際の例を用いて紹介しました。
今まで面倒だった端末の向き対応やiPad対応がやりやすくなると思います。
実際に使えるのはiOS16以降ですが、SwiftUIがどんどん強化されていき、元々のUIKitを使わなくても柔軟にデザインできるSwiftUIに期待が高まります。