えいむーさんは明日も頑張るよ

GeometryReaderの挙動について学ぶ

価格

# 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 に対して赤の背景色を与えると以下のようなレイヤー構成になっている。

# 誤った使い方

以下は誤った使い方でScrollViewGeometryReaderを入れ子にするように記述するのが正しい。

このように書くと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%の黄色を指定しているがCirlcestrokeを指定しない限りは背景色が真っ黒になるので黄色と黒が混ざって結局黒になることが想定される。

# 実際に書いてみた

ところが期待に反してそうはならない。

領域自体は横幅 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)
    }
}

単にこのようにRoundRectangleframeの値を突っ込んだだけだと、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が必要になる
    • GeometryReaderForEachの中に書くこと

# 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))
    }
}

その時は上のようにLazyVGridpadding()をつけてやると良い。すると自動的にスペースが空いて、それに応じてオブジェクトも小さくなる。

# 中央揃え

# 上下を揃える

今までは縦幅が必ず同じものを想定していたが、場合によっては上のようにテキストの長さが変わることで縦幅のサイズが可変になる場合が考えられる。

このとき、上のように幅が小さいものは最も長いものに合わせる形にしたいのだろうが、可能だろうか?

というわけで、適当に円とテキストを組み合わせる 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の領域を赤く表示すると次のようになります。もし仮にCirclepositionとして(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 のサイズを知る (opens new window)で詳しく解説されています。

まあ図を見ればそこまで難しくは感じないと思います。真ん中に表示したかったら変にコードを書かなくても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))
    }
}

価格
    えいむーさんは明日も頑張るよ © 2021