GeometryReader
Hello, world!
中央にHello, world!
が表示され、特に違和感もない。
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
+GeomeyryReader
Geometry Reader に対して入れ子にするとデフォルトの上下左右の中央揃えのレイアウトが消える。
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
Text("Hello, world!")
.padding()
}
}
}
+ScrollView
ScrollView に対しても入れ子にすると上のようになる。見た目は全く変わらないがスクロールができる。
ちなみに GeometryReader に対して青、ScrollView に対して赤の背景色を与えると以下のようなレイヤー構成になっている。
誤った使い方
以下は誤った使い方でScrollView
はGeometryReader
を入れ子にするように記述するのが正しい。
このように書くとText
の部分にしか ScrollView が適用されなくなる。
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ScrollView {
Text("Hello, world!")
.padding()
}
}
}
}
LazyVGrid を適用してみる
検証用のソースコードとして以下のものを考えた。
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
Circle()
.background(Color.yellow.opacity(0.3))
}
})
これを見て、一体どんな View が生成されると思うだろうか?
恐らくデバイスの幅に応じて最小 50、最大 100 の円が四つ並んだものが三列あると想像した方が多いだろう。というよりも、そういうものを想定してこのコードを書いたと言って良い。
ちなみに、円には背景色として不透明度 30%の黄色を指定しているがCirlce
はstroke
を指定しない限りは背景色が真っ黒になるので黄色と黒が混ざって結局黒になることが想定される。
実際に書いてみた
ところが期待に反してそうはならない。
領域自体は横幅 100 ピクセルが確保されているようなのだが、円自体は大きくなっていない。
もちろん、円自体にframe
等で幅を指定してやれば変化はするだろうが、そうなると大きいデバイスだとスカスカで、小さいデバイスだとキツキツ(ひょっとしたらはみ出してしまうかもしれない)になってしまう。
それではとてもレスポンシブデザインとは言えない。
で、これをやると GeometryReader の値が常に ScrollView のサイズと一致してしまうのでForEach
の中の円の大きさについては全くわからない。この書き方だと意味がない気がするのだが…
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
Circle()
.background(Color.yellow.opacity(0.3))
}
})
}
.background(Color.red.opacity(0.3))
}
.background(Color.blue.opacity(0.3))
}
}
width | height | |
---|---|---|
GeometryProxy | 414.0 | 818.0 |
ScrollView+GeometryReader+LazyVGrid
何も変化がないし、GeometryReader の背景色が一段目にしか適応されていないのも違和感がある。
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
GeometryReader { geometry in
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
Circle()
.background(Color.yellow.opacity(0.3))
}
})
}
.background(Color.blue.opacity(0.3))
}
.background(Color.red.opacity(0.3))
}
}
ScrollView+LazyVGrid+GeometryReader
こうすればCircle
に対してGeometryReader
が効いているので個別のCircle
の大きさをGeometryProxy
から知ることができる。
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
GeometryReader { geometry in
Circle()
.background(Color.yellow.opacity(0.3))
}
.background(Color.blue.opacity(0.3))
}
})
}
.background(Color.red.opacity(0.3))
}
}
width | height | |
---|---|---|
GeometryProxy | 97.5 | 10.0 |
試しに Circle に対する GeomeyryProxy の値を取得してみたところ、横幅 97.5、縦幅 10.0 であることがわかった。縦幅も 97.5 であればよかったのだが、LazyVGrid
はあくまでも横幅に対するグリッドなので縦幅に関しては何も弄らないという方針なのだろう(もちろんそれが正しい挙動である)
つまりLazyVGrid
はあくまでも横幅を自動的に調整する仕組みであって、何も指定しなければ縦幅は最小の 10.0 に固定されるということだ。
そしてCircle
はその中で自身を最大化しようとするのでサイズが 10 の円しか表示されないのだと思う。これを解消するためには「横幅制限の許す限り、円を最大化する」という処理を書けば良い。
contentMode を利用する
そこでcontentMode(.fit)
またはcontentMode(.fill)
を利用する。
これは元々は単にアスペクト比を維持するためだけのプロパティのはずなのだがLazyVGrid
内で利用すると横幅に合わせてオブジェクトを最大化することができる。
ただし、拡大率は全く調整できないのでLazyVGrid
の範囲内で許す限り最大まで大きくなってしまう。ちょっと横幅をもたせたい場合にはpadding()
を利用するなどしよう。
View に対しても通用するのか
例えば以下のようにUserView
を 12 個並べるような場合を考えよう。
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
UserView()
}
})
}
.background(Color.red.opacity(0.3))
}
}
struct UserView: View {
var body: some View {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.blue, lineWidth: 3)
.overlay(Text("Nyamo"))
}
}
これはどのように表示されるだろうか?
恐らくその予想はあたっていて、上のようにやはり縦幅が 10 に固定されてしまいぺっちゃんこの View になってしまう。
これもcontentMode(.fit)
で解決できるだろうか?実はできてしまった(とても嬉しい)
というわけでLazyVGrid
を使ってコンテンツを正方形内に表示したい場合にはcontentMode
を利用するようにしましょう。
正方形じゃない場合はどうするのか
例えば、デバイスのサイズに関係なく 4:3 のサイズのボタンを表示したいとしよう。
今回利用したcontentMode
は縦幅を強制的に縦幅と同じにするコードなのでそのままでは利用できない。では、どうするか?
横幅がわかるんだからそこから縦幅を計算させればよいだろうと思うが、そう簡単ではない。
struct UserView: View {
var body: some View {
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.blue, lineWidth: 3)
.overlay(Text("Nyamo"))
.frame(width: geometry.size.width, height: geometry.size.width * 0.75, alignment: .center)
}
.aspectRatio(contentMode: .fill)
}
}
単にこのようにRoundRectangle
にframe
の値を突っ込んだだけだと、LazyVGrid
がその値を読み込めないため「わいの中身、CGSize(97.5, 10.0)で定義してるし間隔そんなにあけなくていいよな」と誤解するので上の図のように詰まってしまう。
詰まらせないためにはUserView()
に対してframe
の値を指定しなければいけない。だがUserView
の大きさを知っているのはGeometryReader
の入れ子内だけである、困った。
一応の解決策としてはUserView()
自体にcontentMode
を指定する方法がある。これをやれば詰まらなくはなるが、どんなに縦幅が小さい View でも常に横幅と同じだけ間隔があいてしまう。
暫定的な対応
struct UserView: View {
var body: some View {
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.blue, lineWidth: 3)
.overlay(Text("Nyamo"))
.frame(width: geometry.size.width, height: geometry.size.width * 3/2, alignment: .center)
}
.aspectRatio(contentMode: .fit)
}
}
第一、縦幅の方が横幅よりも長くなったときには使えない。根本的な解決にはなっていない。
aspectRatio
の引数を利用する
正方形以外を利用したい場合にはaspectRatio
の引数を利用する方法がある。
例えば、3:2 のアスペクト比のボタンを用意したい場合には.frame(width: geometry.size.width, height: geometry.size.width * 3/2, alignment: .center)
でアスペクト比を 3:2 にしてからその情報をUserView
に対して伝えてやれば良い。
注意点としては SwiftUI のアスペクト比は「横の縦に対する比」のようになっているので、3:2 のアスペクト比の場合はその逆数の 2/3 を引数として与えなければいけない。
struct UserView: View {
var body: some View {
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.blue, lineWidth: 3)
.overlay(Text("Nyamo"))
.frame(width: geometry.size.width, height: geometry.size.width * 3/2, alignment: .center)
}
.aspectRatio(2/3, contentMode: .fit)
}
}
そしてこの方法を利用することで無事に理想的な表示方法に成功することができた。
ここまでのまとめ
- LazyVGrid で最大サイズを指定しても自動で大きさを変えてくれない
- 指定したサイズ内でオブジェクトの大きさを変えたいときは
aspectRation(contentMode)
を利用する - 正方形の View であればそれだけで解決する
- 指定したサイズ内でオブジェクトの大きさを変えたいときは
- 正方形でない場合は
aspectRatio()
の引数にアスペクト比の逆数を入力する- このときは
GeometryReader
が必要になる GeometryReader
はForEach
の中に書くこと
- このときは
LazyVGrid の仕様とおまけ
スペースを空ける
そのままLazyVGrid
を最大化すると ScrollView の上部に張り付いてしまう。
張り付いたからといって問題があるわけではないのだが、これでは余裕が全くないためにちょっと不便を感じるかもしれない。
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
Circle()
.aspectRatio(contentMode: .fill)
}
})
.padding()
}
.background(Color.red.opacity(0.3))
}
}
その時は上のようにLazyVGrid
にpadding()
をつけてやると良い。すると自動的にスペースが空いて、それに応じてオブジェクトも小さくなる。
中央揃え
上下を揃える
今までは縦幅が必ず同じものを想定していたが、場合によっては上のようにテキストの長さが変わることで縦幅のサイズが可変になる場合が考えられる。
このとき、上のように幅が小さいものは最も長いものに合わせる形にしたいのだろうが、可能だろうか?
というわけで、適当に円とテキストを組み合わせる View を作成してみた。
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
VStack(alignment: .center, spacing: nil, content: {
Circle()
.aspectRatio(contentMode: .fit)
Text(Range(0 ... Int.random(in: 0 ... 10)).map({ _ in "A" }).joined())
})
}
})
.padding()
}
.background(Color.red.opacity(0.3))
}
}
.fit
.fit
の場合は円の大きさは固定で理想的な状態になったが、上下に対して中央揃えになってしまっているためこれではダメで修正が必要になる。
struct ContentView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 50, maximum: 100)), count: 4), alignment: .center, spacing: nil, pinnedViews: [], content: {
ForEach(Range(0...11)) { _ in
VStack(alignment: .center, spacing: nil, content: {
Circle()
.aspectRatio(contentMode: .fit)
Text(Range(0 ... Int.random(in: 0 ... 10)).map({ _ in "A" }).joined())
Spacer() // 追加
})
}
})
.padding()
}
.background(Color.red.opacity(0.3))
}
}
というわけで下にSpacer()
をつけることで、無理やり上に揃えることができる。
SwiftUI LazyVGrid Position Top
とかで調べてもでてこなかったので、これ以外に方法があるのかは不明なのだがとりあえずこれでできそう。
.fill
.fill
の場合は最大まで円を大きくしようとするのでそもそも円の大きさが変わってしまった。
よって、こちらは使えないことがわかる。
GeometryReader で位置揃え
一番最初にも述べたようにGeometryReader
を利用するとGeometryReader
内で View の位置を揃えようとするため何もしなければ.topLeading
のような状態になり左上に View が寄ってしまう。
これを中央にしたいわけなのだが、どのデバイスでも必ず中央にするにはどうすればよいのかという問題である。
要件
- どのデバイスでも相対的に同じ位置に表示する
- ボタンなどは下側に表示したいのだが、それにも対応する
この仕様を達成するにはposition(x: y:)
を利用するのが最も手っ取り早い。何故ならGeometryProxy
で対象の View の幅や高さは簡単に取得できるためです。
.position
について
これは View の中央をposition()
で指定された場所に移動させるという効果を持ちます。
幅 400、高さ 300 のGeometryReader
の領域を赤く表示すると次のようになります。もし仮にCircle
のposition
として(0, 0)
をしてすれば円の中心が(0, 0)
に移動するので上のような図の状態になるはずです。
何もしないとき
単にGeometryReader
に円を表示しただけだとこのように左上に寄っただけになります。
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(alignment: .center, spacing: nil, content: {
Spacer(minLength: 100)
HStack(alignment: .center, spacing: nil, content: {
Spacer(minLength: 100)
GeometryReader { geometry in
Circle()
.strokeBorder(Color.blue, lineWidth: 5)
.frame(width: 80, height: 80, alignment: .center)
}
.background(Color.red.opacity(0.3))
})
})
}
}
(0, 0)
を指定したとき
このように予想図通りになります。
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(alignment: .center, spacing: nil, content: {
Spacer(minLength: 100)
HStack(alignment: .center, spacing: nil, content: {
Spacer(minLength: 100)
GeometryReader { geometry in
Circle()
.strokeBorder(Color.blue, lineWidth: 5)
.frame(width: 80, height: 80, alignment: .center)
.position(x: 0, y: 0) // 追加
}
.background(Color.red.opacity(0.3))
})
})
}
}
GeometryProxy
GeometryProxy
の frame には.global
と.local
の二つのプロパティがあります。
frame | global | local |
---|---|---|
View | root | その View 自身 |
.global
は rootView を表し、.local
はその View 自身を指します。
frame | 意味 |
---|---|
minX | 0 |
midX | 中央 |
maxX | 端 |
minY | 0 |
midY | 中央 |
maxY | 端 |
更に特殊な六つのプロパティを持ちます。どんな意味なのかは[SwiftUI] GeometryReader で View のサイズを知るで詳しく解説されています。
まあ図を見ればそこまで難しくは感じないと思います。真ん中に表示したかったら変にコードを書かなくてもmidX
で十分だということです。
実際に実装してみると、このように簡単に書くことができます。
ボタンのときの注意
ボタンを作成するときにposition
の設定を誤ると表示されているボタンと実際に押せる位置がズレるというとんでもないバグが起きます。
なので以下のコードを参考にしてください。overlay
ではなくbackground
を利用するのが良いです。
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
Button(action: {}, label: {
Text("Login")
.frame(width: min(geometry.size.width * 0.4, 400), height: 60, alignment: .center)
.background(RoundedRectangle(cornerRadius: 20).strokeBorder(Color.blue, lineWidth: 5))
})
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).maxY - 80)
}
.background(Color.red.opacity(0.3))
}
}