iOS, SwiftUI, Swift

[SwiftUI] GeometryReaderでViewのサイズを知る

Viewサイズをどうやって知る?

UIKitではView自身の座標位置やサイズはUIViewのプロパティとしてframeプロパティやboundsプロパティが定義されそれで取得ができました。
SwiftUIのViewには座標位置やサイズを表すプロパティはありません。
ではそれらの情報を取りたいときにはどうすればいいのでしょうか?
そのための特別のViewがあります。GeometryReaderです。

GeometryReader

GeometryReaderは特別なViewで、自身のサイズと座標空間を返す関数をクロージャーとして保持しています。そのクロージャーを通して、自身のViewのサイズや座標位置やRootViewのサイズや座標位置も取得することができます。

GeometryReaderを使うとこんなことができるようになります。

  • ScrollViewのスクロールに合わせてコンテンツを操作する
    • スクロールに合わせてコンテンツのパララックス効果
    • ヘッダーをスクロールに合わせて拡大、縮小する
    • 横スクロールでコンテンツが真ん中に来たら目立たせる
  • View自身のサイズを取得する
    • 特定のビューのキャプチャ画像を取得する

UIの表現の幅が広がり便利なアプリが作れそうですね。

GeometryReaderの使い方

GeometryReaderでViewを作ってみます。

GeometryReader { geometry in
    Text("geometry: \(geometry.size.debugDescription)")
}

クロージャーの引数のgeometryGeometryProxyプロトコルに準拠したインスタンスです。
このインスタンスからViewのサイズや座標位置を取得できます。

例えば、自身のViewのサイズを知りたいならgeometry.sizeにアクセスをします。

----------2019-12-08-13.50.33-----

originで表示座標を知ろう

GeometryProxyプロトコルにはframeメソッドがあり、RootViewまたは自身の座標空間を取得できます。frameメソッドでViewの座標がどうなっているかを調べましょう。

ところでSwiftUIの座標はUIKitと同じく左上から始まります。

----------2019-12-08-15.14.10

デフォルトでは表示座標はセーフエリアを除いた領域、ステータスバーの下から始まります。これがorigin座標です。
X軸の右側に行くほどX座標の値は増え、Y軸の下側に行くほどY座標の値が増えます。


ちなみにviewに対してedgesIgnoringSafeAreaをするとステータスバー含めた領域からorigi座標が計算されます。

struct ContentView: View {
    var body: some View {
        Text("hello world")
            .edgesIgnoringSafeArea(.all)
    }
}

Viewの座標を確認するサンプルコードを見てみましょう。

struct CoodinateSpace: View {
    var body: some View {
        VStack {
            GeometryReader { geometry in
                RoundedRectangle(cornerRadius: 20)
                    .fill(Color.pink)
                    .overlay(VStack {
                        Text("X: \(geometry.frame(in: .global).origin.x) Y: \(geometry.frame(in: .global).origin.y) width: \(geometry.frame(in: .global).width) height: \(geometry.frame(in: .global).height)")
                            .foregroundColor(.white)

                    }.padding())
            }
            .frame(height: 400)
            Spacer()
        }
    }
}

上記コードの画面表示はこのようになります。

----------2019-12-08-15.17.18

geometry.frame(in: .global).origin でRootViewの座標空間を取得しています。
frameメソッドの引数にはCoordinateSpace型を指定できます。.globalはRootViewで.localはそのView自身を表します。
各originのX座標、Y座標、ビューのWidth, Heightの値をTextViewで表示しました。
それぞれ下記のような値となっています。

  • X: 0
  • Y: 44(セーフエリアの下から計算しているためステータスバーの高さ分の値が出ている)
  • Width: 414(自身のViewの幅=端末の横幅)
  • Height: 400(自身のViewの高さ.frame(height: 400)を指定しているから)

ちなみにgeometry.frame(in: .local).originは自身のView座標のoriginなのでいつも(0, 0)になります。これはUIViewのbounds.originがつねに(0,0)であることと同じです。

SwiftUIのレイアウトシステム

ここでSwiftUIのレイアウトシステムを振り返ってみましょう。
WWDC19の
Building Custom Views with SwiftUIではこのように解説されていました。

Viewの表示領域を決める際にはこのようなステップを通るそうです。

  1. 親が子に表示可能サイズを提案する
    • 「これだけ表示できるけどどう?」
  2. 子が自身のサイズを親へ送る
    • 「お気遣いありがとう。私はこれだけで十分です」
  3. 親が親の座標空間に子を配置する
    • 「それではお前をここに表示しよう」

この処理が大本のRootViewから始まり、すべての子ビューのサイズが決まり、配置をしていくとのことです。
(ちなみにRootViewはデフォルトでは端末画面のSafeAreaを抜いた領域です)

このシステムのいいところは

  • 子がサイズを決めるのでどこでも好きなタイミングでフレームを操作できる
  • サイズ決定を親に依存しなくてすむ

でそれぞれのビューが親を気にせずサイズを決められるところです。

globalは本当にRootView?

現状CoordinateSpace型にはドキュメントがありません。.globalのグローバルはRootViewの意味でいいのか疑問を持ちました。なので検証用のViewをつくってみました。

struct ContentView: View {
    var body: some View {
        ScrollView {
            VStack {
                Text("GeometryReader Get Global Origin")
                GeometryRectangle(color: Color.pink)
                GeometryRectangle(color: Color.red)
                .offset(x: 10, y: 0)
                ZStack {
                    GeometryRectangle(color: Color.blue)
                        .offset(x: 30, y: 0)
                }.offset(x: 10, y: 0)
                GeometryReader { geometry in
                    Text("geometry: \(geometry.size.debugDescription)")
                    //print(geometry.)
                }
            }
        }
    }
}

struct GeometryRectangle: View {
    var color: Color
    var body: some View {
        GeometryReader { geometry in
            VStack {

                Button(action: {
                    let image = self.takeScreenshot(origin: geometry.frame(in: .global).origin, size: geometry.size)
                    print(image)
                }) {
                    RoundedRectangle(cornerRadius: 20)
                    .fill(self.color)
                    .overlay(
                        VStack {
                            Text("X: \(Int(geometry.frame(in: .global).origin.x)) Y: \(Int(geometry.frame(in: .global).origin.y)) width: \(Int(geometry.frame(in: .global).width)) height: \(Int(geometry.frame(in: .global).height))")
                                .foregroundColor(.white)
                            Text("size: \(geometry.size.debugDescription)")
                                .foregroundColor(.white)
                })}
            }

        }.frame(height: 100)
    }
}

GeometryRectangleという色付きの四角のViewに各geometryの情報を出力するコードです。

表示結果はこちら。

----------2019-12-08-19.29.03

3つの四角いViewを表示しているところでX座標が0,10,40となっています。

GeometryRectangle(color: Color.pink)
GeometryRectangle(color: Color.red)
.offset(x: 10, y: 0)
ZStack {
    GeometryRectangle(color: Color.blue)
        .offset(x: 30, y: 0)
}.offset(x: 10, y: 0)

3つ目の青いViewはZStackにネストしていますがZStackはx座標に10、offsetしており、GeometryRectangle自身もx座標に30、offsetしているので合計40の値となっています。一つ上の親ビューからの計算ではなくRootViewからの座標計算になっています。

ここからglobalを指定するとRootViewから座標計算されることがわかりました。

Min, Mid, Max座標

GeometryProxyプロトコルframeメソッドで取得できるものはorigin座標だけでなく、X、Y座標のMin,Mid,Max座標それぞれも取得できます。

----------2019-12-08-19.14.41

試しにコードで書いてみましょう。

var body: some View {
    VStack {
        Text("Global座標")
        GeometryReader { geometry in
            VStack(alignment: .leading, spacing: 10) {
                Text("Global座標")
                HStack {
                    Text("minX: \(Int(geometry.frame(in: .global).minX))")
                    Spacer()
                    Text("midX: \(Int(geometry.frame(in: .global).midX))")
                    Spacer()
                    Text("maxX: \(Int(geometry.frame(in: .global).maxX))")
                }
                HStack {
                    Text("minY: \(Int(geometry.frame(in: .global).minY))")
                    Spacer()
                    Text("midY: \(Int(geometry.frame(in: .global).midY))")
                    Spacer()
                    Text("maxY: \(Int(geometry.frame(in: .global).maxY))")
                }
                Text("Local座標")
                HStack {
                    Text("minX: \(Int(geometry.frame(in: .local).minX))")
                    Spacer()
                    Text("midX: \(Int(geometry.frame(in: .local).midX))")
                    Spacer()
                    Text("maxX: \(Int(geometry.frame(in: .local).maxX))")
                }
                HStack {
                    Text("minY: \(Int(geometry.frame(in: .local).minY))")
                    Spacer()
                    Text("midY: \(Int(geometry.frame(in: .local).midY))")
                    Spacer()
                    Text("maxY: \(Int(geometry.frame(in: .local).maxY))")
                }
                HStack {
                    Text("width: \(Int(geometry.size.width))")
                    Text("height: \(Int(geometry.size.height))")
                }
            }
            Spacer()
        }
        .padding()
        .background(Color.pink)
        .foregroundColor(Color.white)
        Spacer()
    }
}

画面はこうなります。

----------2019-12-08-19.17.14

補足説明を入れると

  • GeometryReader.padding()で上下左右にデフォルトパディングを追加。デフォルトの値は16
  • GlobalのMinXはパディングのデフォルト値が加わって16。しかしLocalは0。GlobalのMidX, MaxXはLocalの値にパディング分の16を足したものになっている。
  • Y座標もGlobalのMinYが96から始まっているのに対してLocalは0。GlobalのMidY, MazYはLocalのものに96を足したものになっている。

ScrollView

GeometryReaderで取得できるorigin座標はScrollViewでスクロールすると動的に変更されます。

Dec-08-2019-20-49-22

struct Scrool: View {
    var body: some View {
        ScrollView {
            VStack {
                GeometryRectangle(color: Color.pink)
                GeometryRectangle(color: Color.red)
                GeometryRectangle(color: Color.blue)
                Spacer()
            }
        }
    }
}

スクロールをするとgeometry.frame(in: .global).origin.yの値が変更されるのがgif画像からわかります。
これを検知することで

  • ScrollViewのスクロールに合わせてコンテンツを操作する
    • スクロールに合わせてコンテンツのパララックス効果
    • ヘッダーをスクロールに合わせて拡大、縮小する
    • 横スクロールでコンテンツが真ん中に来たら目立たせる
  • View自身のサイズを取得する
    • 特定のビューのキャプチャ画像を取得する

を実現することができます。
次回以降具体的なサンプルを紹介しますのでお楽しみに。

まとめ

  • Viewのサイズを知るにはGeometryReaderを使う
  • SwiftUIのレイアウトシステムは子Viewがサイズを決定する
  • RootViewのorigin座標、Min, Mid, Max座標の動き
  • ScrollViewでorigin座標が変更される-> これを検知していろいろな動きをつけることができる。

宣伝

一冊でSwiftUIの仕組みがわかる「SwiftUI実践入門」Boothで発売中です。

https://personal-factory.booth.pm/items/1579464

Author image

About Sato Takeshi

  • Tokyo, Japan