[SwiftUI] GeomertyReader を用いて、親Viewの内側に等分に子Viewを配置する方法

親Viewのサイズに関わらず、均等にViewオブジェクトを配置する方法をGeomertyReaderを用いて実装します。geometry.sizeを活用することで、View内の相対的な座標を指定することが可能になるため、親Viewでのレイアウト変更に関わらず、意図したデザインでView Objectを配置することが可能になります。

実現したいこと

以下の図のように、黒丸を5つ斜めに均等に配置する方法を例にします。以下の画面はいずれもPreview画面で、左が子View、右が親Viewのものになります。親Viewでは子Viewの呼び出し時にフレームサイズを指定しているため、サイズはもとより、縦横比も変わることになります。

子View(ChildRectangle)でのPreview
親View(ContentView)でのPreview

View内にオブジェクトを等分に配置

まずは、等分に配置する方法を考えるうえで、単純に5つ黒丸を作成してみます。なお、今回は iPhone8 を例にしますので、以下の画面サイズとしています。これらの値は、それぞれ GeomertyReader で取得することができます。
width: 375
height: 647

//  ChildRectangle.swift

import SwiftUI

struct ChildRectangle: View {
    var body: some View {
        GeometryReader { geometry in
            ForEach(0..<5) {i in
                ZStack {
                    Circle()
                        .frame(width: 50, height: 50)
                        .position(x: CGFloat(CGFloat(i)*50), y: CGFloat(CGFloat(i)*50))
                }
            }
        }
        .background(Color.blue)
    }
}

座標軸の整理

次に、子View における座標を整理します。 geometry.size.height を使って幅(129.4), 高さ(647)が取得できます。それぞれ10等分した値が1番左上の黒丸の座標となります。2番め以降の黒丸は、1番目の黒丸に geometry.size.height, geometry.size.width の値をそれぞれ5分の1にした、37.5, 64.7の値を加えていけばよいことになります。

上記内容を実装した結果が以下になります。

struct ChildRectangle: View {
    var body: some View {
        GeometryReader { geometry in
            let x_init = geometry.size.width / 10
            let x_offset = geometry.size.width / 5
            let y_init = geometry.size.height / 10
            let y_offset = geometry.size.height / 5
            
            ForEach(0..<5) {i in
                ZStack {
                    Circle()
                        .frame(width: 50, height: 50)
                        .position(x: CGFloat(x_init + CGFloat(i)*x_offset), y: CGFloat(y_init + CGFloat(i)*y_offset))
                }
            }
        }
        .background(Color.blue)
    }
}

それでは、 ContentView から、この子View( ChildRectangle )を呼び出してみましょう。このとき、 ContentView 内で、 .frame modifier をつけて、高さを300ptに指定しています。

struct ContentView: View {
    var body: some View {
        ChildRectangle()
            .frame(height: 300)
    }
}
子View( ChildRectangle )でのPreview
親View( ContentView )でのPreview

ContentView 側で frame サイズを変更しても、きれいに5等分で表示されています。GeometryReade r(青網掛け)を使うことで、動的にViewの幅、高さを取得することができています。

GeometryReader 使わず、固定値で実装した場合

参考までに、固定値で実装した場合の例を紹介します。先程の子 View に、固定値で実装した赤丸を追加してみます。

struct ChildRectangle: View {
    var body: some View {
        GeometryReader { geometry in
            let x_init = geometry.size.width / 10
            let x_offset = geometry.size.width / 5
            let y_init = geometry.size.height / 10
            let y_offset = geometry.size.height / 5
            
            ForEach(0..<5) {i in
                ZStack {
                    Circle()
                        .frame(width: 50, height: 50)
                        .position(x: CGFloat(x_init + CGFloat(i)*x_offset), y: CGFloat(y_init + CGFloat(i)*y_offset))
                }
            }
            Circle()
                .fill(Color.red)
                .frame(width: 50, height: 50)
                .position(x: 37.5, y: 64.7)
        }
        .background(Color.blue)
    }
}

先程の座標の考え方から導き出した1番目の黒丸と同じ場所に赤丸を表示してみます。すると、以下の図の通り、子 View では先ほどの黒丸と重なりますが、親 View では、ずれてしまっています。これは、相対座標で表示していることは変わらないのですが、親 View から子 View のフレームサイズが変更されてしまっても、固定の値を使って position を指定しているためです。割合で設定する場合、固定値で設定する場合の差分を確認してみました。

子View( ChildRectangle )での Preview
親View( ContentView )での Preview

まとめ

今回のような View は GUI を使って実装できれば簡単そうですが、コードベースで実装しようとすると意外とうまく行かなかったりすることもあると思います。慣れるまでは、画や図をを書きながら理解していくのがよいと感じました。