【SwiftUI】GeometryReader の基本と特徴的な挙動【入門編】

# はじめに

最近、仕事でも個人開発でも SwiftUI を触る機会が増えてきており、学習を進めています。

本記事では、`GeometryReader` について学習した内容と、
個人的に特徴的だと感じた挙動について、備忘録として記載しています。

間違っている点などあればご指摘いただけると幸いです🙇

# GeometryReader とは？

まず、`GeometryReader` の基本について以下に列挙しています。

1. `GeometryReader` は View の一種です
    - 他の View と同じように、View 階層内に宣言的に記述して使用します
1. `GeometryReader` は `GeometryProxy` から「自身(== `GeometryReader` )のサイズ」や「自身の座標(x, y)」等の情報を取得できます
1. `GeometryReader` は「`GeometryProxy` をもとに、1つ以上の View をレイアウトするための ContainerView である」と理解しています
    - 「どんなときに `GeometryReader` を使うのか？」といえば、「`GeometryProxy` から取得できる情報をもとにレイアウトを組みたい場合」だといえると思います

以下に具体的なコードを記載し、実際の動作を確認してみます。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {

            // 1. GeometryReader 自身も View のため、View 階層内に宣言的に記述可能です

            GeometryReader { proxy in
                // 2. ↑この「proxy」が「GeometryProxy」のインスタンスであり、
                //    ここから取得できる情報をもとに、例えば以下のようにレイアウトを組むことができます。
                //    また、以下は @ViewBuilder クロージャ内なので、次のように View を宣言的に列挙することができます

                Rectangle()
                    .fill(.red)
                    // 3. GeometryProxy から GeometryReader 自身のサイズを取得可能です。
                    //    ここでは Rectangle の高さを GeometryReader の半分の高さに指定しています
                    .frame(height: proxy.size.height / 2)

                Text("サンプル")
                    // 3. GeometryProxy から GeometryReader 自身の座標情報を取得可能です。
                    //    ここでは Text の中心位置が GeometryReader の中心位置になるように指定しています
                    .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY)
            }

        }
    }
}
```

上記コードの実行結果：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/682a98f3-f9c6-f89e-4299-1f2775ffc8e3.png" width=250>

なお、`GeometryReader` が複数の View を受け取れるのは、イニシャライザのクロージャが以下の通り `@ViewBuilder` として定義されているためです。

```swift
init(@ViewBuilder content: @escaping (GeometryProxy) -> Content)
```

## GeometryProxy から取得できる情報

`GeometryProxy` から取得できる情報はいくつかありますが、
ここでは個人的によく使いそうな2種類を記載しています。


* `GeometryReader` 自身のサイズ
  * `proxy.size: CGSize`
* `GeometryReader` 自身の座標位置
  * `proxy.frame(in: .local): CGRect`
      * ローカル座標
  * `proxy.frame(in: .global): CGRect`
      * グローバル座標

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

以下で具体的にみていきます。

### GeometryReader 自身のサイズを取得する

ここでは `GeometryProxy` からサイズを取得するサンプルコードを載せています。

まず、以下では `VStack` 内に `GeometryReader` を2つ置き、`GeometryProxy` から取得したサイズを `Text` に表示しています。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            VStack {
                // GeometryReader 自身に .frame でサイズを指定していないため、親から提案されたサイズがそのまま自身のサイズとなる
                GeometryReader { proxy in
                    Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
                }
                .border(.red)
                
                GeometryReader { proxy in
                    Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
                }
                .border(.green)            
        }
    }
}
```

上記コードの実行結果：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/fa8b933b-2224-602d-e851-b3304e1bdf68.png" width=250>


また、`GeometryReader` に `.frame` で直接サイズを指定している場合、
`proxy.size` には `.frame` で指定した値が入ってきます。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                // proxy.size には (300, 300) が入ってくる
                Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
            }
            // ここで GeometryReader のサイズを指定
            .frame(width: 300, height: 300)
            .border(.red)
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/9937237c-3d1f-34fd-33dc-390785f4e7c9.png" width=250>

ただし、以下のように `.padding().frame()` というふうにして余白領域を追加している場合は少し挙動が変わります。
`.padding` で追加した余白領域を取り除いた実際の `GeometryReader` のサイズが `GeometryProxy` で取得できるようです。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                // 「余白領域 + GeometryReader のサイズ == (300, 300)」となる
                // -> 余白領域を除いた GeometryReader のサイズは (280, 280) なので、これが proxy.size から取得できる
                Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
            }
            .padding(10)
            .frame(width: 300, height: 300)
            .border(.red)
            
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/87d0681c-2d62-232f-7c9f-73b53d2684b2.png" width=250>

### GeometryReader 自身の座標位置を取得する

`GeometryProxy` から取得した座標位置を画面に表示するサンプルコードを載せています。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                // ローカル座標の位置を表示
                Text("local.x: \(proxy.frame(in: .local).origin.x.description), local.y: \(proxy.frame(in: .local).origin.y.description)")
                
                // グローバル座標の位置を表示
                Text("global.x: \(proxy.frame(in: .global).origin.x.description), global.y: \(proxy.frame(in: .global).origin.y.description)")
                    .padding(.top, 30)
            }
            .frame(width: 300, height: 300)
            .border(.red)
        }
    }
}
```

上記コードの実行結果：

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/47e8900c-b406-ab65-55ff-0750aadfa3eb.png" width=250>

ローカル座標の場合、常に位置は (0, 0) になると思われるので、
`proxy.frame(in: .local)` はあまり使うことがなさそうに思えます。

一方でグローバル座標の場合は、「`RootView` に対して `GeometryReader` がどこに位置されているか」を取得することができるので、色々と応用できそうな気がします。


# GeometryReader の特徴的な挙動

最後に、今回 `GeometryReader` を触る中で、個人的に意識しておいた方がよさそうに感じた挙動を以下に記載します。

1. `GeometryReader` 内の View は左上を起点にレイアウトされる
1. 高さ未指定の `GeometryReader` を `ScrollView` の中に入れると 10pt の高さしか与えられない
1. `GeometryReader` のサイズは子 View のサイズにフィットするように調整されるわけではない
    - `VStack` や `HStack` と同じ感覚で使うと失敗する

## 1. GeometryReader 内の View は左上を起点にレイアウトされる

`VStack` や `HStack` では、子 View はデフォルトで中央（`center`）に配置されますが、
GeometryReader 内の View は左上 (`topLeading`) に配置されます。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                Rectangle()
                    .fill(.yellow)
                    .frame(height: 100)
                
                Text("sample")
                    .frame(height: 60)
            }
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/4035b1d5-ef33-cad6-26a4-23d63acda8e3.png" width=250>

`VStack` や `HStack` と同じように `center` に配置されるようにするには、
子Viewの `.position` (中心座標) を `GeometryReader` の中心座標に移動させる必要があります。


```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                Rectangle()
                    .fill(.yellow)
                    .frame(height: 100)
                    .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY)

                Text("sample")
                    .frame(height: 60)
                    .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY)

            }
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/fe95b092-9967-db5f-94a6-a525a1254a3e.png" width=250>


## 2. 高さ未指定の GeometryReader を ScrollView の中に入れると、10pt の高さしか与えられない

※ 縦スクロールの前提で記載しています

SwiftUI の View の中には「与えられた領域いっぱいに広がる」という性質を持つものがいくつかあります。
（`Spacer`, `Rectangle` など)

`GeometryReader` もこれらの View と同じ性質を持っています。

こうした性質を持つ View を高さ指定せずに `ScrollView` に入れると、10pt の高さしか与えられないようです。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ScrollView {
                GeometryReader { proxy in
                    Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
                }
                .border(.red)
            }
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/a3601ff5-29b9-dfb8-4e06-5e0f150d287d.png" width=250>

`ScrollView` は理論的に無限大の高さを取れるのでこのような挙動になると思われます。
このため、`ScrollView` 内で `GeometryReader` を使う場合は `.frame` で明示的に高さを指定する必要がありそうです。

## 3. `GeometryReader` のサイズは子 View のサイズにフィットするように調整されるわけではない

以下のように、`VStack` であれば子 View の高さに応じて `VStack` 自身の高さも自動的に調整されます。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            VStack {
                Text("サンプル")
                    .frame(height: 60)
                    .background(.green)
            }
            .border(.red)
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/e4e4d551-4f86-e3d7-5c70-127312c6f2dc.png" width=250>

しかし、`GeometryReader` の場合は、`GeometryReader` 自身のサイズは子 View のサイズに影響を受けないようです。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { proxy in
                Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
                    .frame(height: 60)
                    .background(.green)
                    .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY)
            }
            .border(.red)
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/600f019e-f2b8-dc4e-f25d-569d4b0b00a7.png" width=250>

特に `ScrollView` に入れる際には注意が必要です。

```swift
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ScrollView {
                GeometryReader { proxy in
                    Text("width: \(proxy.size.width.description), height: \(proxy.size.height.description)")
                        .background(.green)
                        .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY)
                }
                .border(.red)
            }
        }
    }
}
```

上記コードの実行結果：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179818/4b0b8bf6-cbb1-175f-0e15-6427ae5291cb.png" width=250>

`GeometryReader` 内の子 View (`Text`) がはみ出て表示されており、
子 View のサイズと親 View のサイズが独立していることがわかります。

# 参考文献

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

https://www.swiftanytime.com/blog/geometry-reader-in-swiftui

https://developer.apple.com/videos/play/wwdc2022/10056/

https://www.hackingwithswift.com/forums/swiftui/geometryreader-inside-scrollview/17794