モチベーション
SwiftUIを勉強している中で、「これを実現するにはどうしたらよいんだろう?」 と調べていくと
多くの場合に、 GeometryReader に行き着く。
折角なので、GeometryReader を記事をまとめる事により、理解を深めていく。
Qiitaでは、チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその1(GeometryReader編) の良質な記事を見つけて自分なりに改造したりしたのでそれは後半に例として載せたいと思う。
環境
PC: Catalina (10.15)
Xcode: 11.0 (11A420a)
iOS: 13.0
実行環境: iPhone11 シミュレーター
GeometryReader て何?
GeometryReaderはViewの一種であり、親のViewのサイズと自身の親のViewに対する位置を知るためのものである。
これだと何を言っているのかわからない気がするので簡単な例をつかって解説していく。
余談ですが、 SwiftUIはplaygroundで確認することも可能です。今回はこれを使って解説してみます。
import UIKit
import SwiftUI
import PlaygroundSupport // playground用のImport
struct DivisionView: View {
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
Text("Left")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.green)
Text("Right")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.blue)
}
}
}
}
// UIHostingControllerを使ってplayground上に表示している
PlaygroundPage.current.liveView = UIHostingController(rootView: DivisionView())
GeometryReader
のClosure式の中にHStackで、LeftとRightのコンテンツを並べている。
GeometryReaderの定義を見ると、上記のソースの geometry
が GeometryProxy
であることがわかる。
public struct GeometryReader<Content> : View where Content : View {
public var content: (GeometryProxy) -> Content
@inlinable public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content)
/// Acts as a proxy for access to the size and coordinate space (for
/// anchor resolution) of the container view.
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct GeometryProxy {
/// The size of the container view.
public var size: CGSize { get }
/// Resolves the value of `anchor` to the container view.
public subscript<T>(anchor: Anchor<T>) -> T { get }
/// The safe area inset of the container view.
public var safeAreaInsets: EdgeInsets { get }
/// The container view's bounds rectangle converted to a defined
/// coordinate space.
public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
}
Acts as a proxy for access to the size and coordinate space (for anchor resolution) of the container view.
コンテナ(親のView)のサイズや子がコンテナのどの位置(座標)にあるかにアクセスするためのproxyである。
つまりツートンの帯の親ViewがBody(つまり画面のView)であるため
Text("Left")
// geometry.size.widthが親のViewの横幅であることがわかる。それを半分にしている。
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.green)
各々のViewが画面の横幅の半分のサイズになるのでツートンの帯が表示されることがわかる。
上記の例では、GeometryReaderを使わずに、以下のように書き換える事も可能である。
ここでは最もシンプルな例として使用しました。
struct DivisionView: View {
var body: some View {
HStack(spacing: 0) {
Text("Left")
.frame(width: UIScreen.main.bounds.width / 2, height: 200)
.background(Color.green)
Text("Right")
.frame(width: UIScreen.main.bounds.width / 2, height: 200)
.background(Color.blue)
}
}
}
活用例
上記の例では、ほとんど実用性がないものであったので、チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその1(GeometryReader編) で書かれている例を見ていくことにする。
付箋紙
付箋や吹き出しはアプリの表現の幅を広げるので活用できそう!
Pathを2つ組み合わせて、下に折り目のある付箋紙風のデザインを実現している。
アイディア次第でシンプルに作れる良い例ですね。
import UIKit
import SwiftUI
import PlaygroundSupport
struct StickyNoteSamplesView: View {
var body: some View {
Group {
Text("Hello World!")
.frame(width: 120, height: 120)
.background(StickyNoteView())
Spacer().frame(height: 10)
Text("GeometryReaderのお勉強。これをマスターすると実現できることが広がりそう。")
.frame(width: 200, height: 200)
.lineLimit(10)
.fixedSize(horizontal: true, vertical: false)
.background(StickyNoteView())
}
}
}
struct StickyNoteView: View {
var color: Color = .green
var body: some View {
GeometryReader { geometry in
ZStack {
Path { path in
let w = geometry.size.width
let h = geometry.size.height
let m = min(w/5, h/5)
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: h))
path.addLine(to: CGPoint(x: w-m, y: h))
path.addLine(to: CGPoint(x: w, y: h-m))
path.addLine(to: CGPoint(x: w, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.fill(self.color)
Path { path in
let w = geometry.size.width
let h = geometry.size.height
let m = min(w/5, h/5)
path.move(to: CGPoint(x: w-m, y: h))
path.addLine(to: CGPoint(x: w-m, y: h-m))
path.addLine(to: CGPoint(x: w, y: h-m))
path.addLine(to: CGPoint(x: w-m, y: h))
}
.fill(Color.black).opacity(0.4)
}
}
}
}
PlaygroundPage.current.liveView = UIHostingController(rootView: StickyNoteSamplesView())
先の例では、Container = 親View であったため、 geometry.size.width
が画面の横幅を返却していたが、
Text("Hello World!")
.frame(width: 120, height: 120)
.background(StickyNoteView())
こちらの例では、
backgroundのViewとして、 StickyNoteView()
を指定するため、 StickyNoteViewのコンテナ(親View)が Text("Hello World!")
となる。
自分はこの親子関係に気づくまで、時間を要したので強調しておく。
StickyNoteView
の中で、Pathにより付箋の外枠をお絵かきしているが、その中で、canvasの縦横幅の基準値となる。
let w = geometry.size.width
let h = geometry.size.height
はTextの横幅・縦幅のため、Textのframeで指定した横幅に沿った付箋紙が実現されている。
つまりは、ここではコンテナのサイズを使ったGeometryReaderの活用例となる。
中心のボールを拡大
既存コードから少し変えている部分があるのでそれを含めて解説していく。
この例は、上とは異なり、コンテナに対する自身の座標をGeometryReaderから取得した活用例となる。
これを見たときこんなに短いコードで実現していることに衝撃を受けました。
// 水平方向のScrollViewで球体の大きさを変更するアニメーション
// ※offsetの定義を省略するためにオリジナルのものから少しロジックを改造しております
// https://qiita.com/takaf51/items/a67db8bbc42a4c82b1f0
let halfScreenWidth = UIScreen.main.bounds.width / 2
let magnification: CGFloat = 1.8 // 円の拡大倍率
struct ContentView: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
horitontalBalls
}
}
var horitontalBalls: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(0...10, id: \.self) { _ in
GeometryReader { geometry in
Circle()
.frame(width: 100, height: 100)
.foregroundColor(Color.red)
// ScrollViewのanimationとしてgeometryReaderの利用が可能
// geometory.frame(in: .global).midXで円のx座標の中心点を取得し、
// 円が画面の水平方向の中心に来た時にmagnificationで指定した倍率のサイズの
// 最大サイズに変更し、左右にずれるほど小さく(最小値は1倍)なるようにサイズを指定
//
// 円が中心に来たときは、 abs(geometry.frame(in: .global).midX - self.halfScreenWidth)が0となり
// 最大値1.8倍で表示する
.scaleEffect(
max(1,
-abs(geometry.frame(in: .global).midX - self.halfScreenWidth) / self.halfScreenWidth * self.magnification
+ self.magnification
)
)
}
.frame(width: 100, height: self.magnification * 100)
.padding()
}
}
}
}
}
今回は、コンテナ(親View)に対する自身の位置をGeometryReaderから取得した例となります。
Debug View Hierarchy を使って ボールの親を確認していきます。
ScrollViewのContainerが親Viewであることがわかります。
geometry.frame(in: .global).midX
はボールの中心がScrollViewのContainerに対してどこにあるかを知るためのものであり、
max(1,
-abs(geometry.frame(in: .global).midX - self.halfScreenWidth) / self.halfScreenWidth * self.magnification
+ self.magnification
)
maxは2つの引数の大きい方を返却するということなので、最小でも、オリジナルのframeの frame(width: 100, height: 100)
となることがわかる。
/// Returns the greater of two comparable values.
///
/// - Parameters:
/// - x: A value to compare.
/// - y: Another value to compare.
/// - Returns: The greater of `x` and `y`. If `x` is equal to `y`, returns `y`.
@inlinable public func max<T>(_ x: T, _ y: T) -> T where T : Comparable
(geometry.frame(in: .global).midX - self.halfScreenWidth)
の部分は、画面の中心点からどれだけ遠ざかったかを表現しており、absで囲んでいるのは、中心より、左or右でも計算上整数として扱いためである。
今回はGeometryReaderについての解説のため、この程度の解説とさせていただき、不明な方は図に書きながら理解してもらいたい。
個人的に気になるのは、スクロールするたびにすべてのボールのサイズ計算処理が走り、描画処理のコストはかかるのではないか? と思われる。←ここは有識者の意見を頂けるとありがたい。
カードの3Dアニメーション
こちらはYoutubeであがっていた、横に並んだカードの3Dエフェクト部分を切り出したものとなる。
オリジナルの方が見栄えが良いのでそちらを見ることをオススメする。
この人のSwiftUIのアニメーション周りの動画はサブスクリプションで見れそうなので時間を見つけて学習してみたい。
SwiftUI Scroll Animation using GeometryReader
ソースコードのコアな部分は本当にこの数行である。これだけでこのアニメーションを実現しているのはスゴイ!!
現在のUIKitを使った開発が過去の遺物になる未来が簡単に想像できる。
var cardList: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(0...30, id: \.self) { value in
GeometryReader { geometry in
CardView(message: "カード\(value)")
.rotation3DEffect(Angle(degrees: (Double(geometry.frame(in: .global).minX) - 40) / -15), axis: (x: 0, y: 10, 0))
}
.frame(width: 140, height: 250)
}
}
.padding(40)
}
}
少し深堀り
GeometryReader { geometry in }
の内と外では実行タイミングが異なる。
ツートンの帯の下に長方形のViewを配置したときに、以下のように画面が重なってしまう。
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
halfDivision
Rectangle().frame(width: UIScreen.main.bounds.width, height: 50).foregroundColor(.yellow)
}
}
var halfDivision: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
Text("Left")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.green)
Text("Right")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.blue)
}
}
}
これは、 GeometryReader{ }
の内部でframeにより高さを指定しているが、 GeometryReader
自身には高さを指定しないためである。GeometryReader{ }
の外側を表示するタイミングで高さが決定していないため上記のようになる。
これを簡単に解消するには、以下のようなコードにする。
GeometryReader { geometry in
HStack(spacing: 0) {
Text("Left")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.green)
Text("Right")
.frame(width: geometry.size.width / 2, height: 50)
.background(Color.blue)
}
}.frame(height: 50)
なお、高さは横幅だけ指定して、高さ指定を外側のみにすると、帯が重なることはないが、Left、Rightの帯の背景色が縮まってしまう。はやり2箇所指定する必要がある。
帯の隙間に間が空く問題は ScrollViewの要素間の隙間を埋めるを参照してください。
まとめ
GeometryReaderは、親Viewのサイズや自身の座標を知ることができるので、アニメーションやインタラクティブなUIを実現するには欠かせない技術となる。自身のViewに対する親Viewが何かを把握して実装することが肝になりそうだ。
StackOverflowにはより実践的な例があるので引き続き勉強して記事にしていけたらと思う。
UIKitでは実現できたけどSwiftUIではどうやるんだろう?てことは色々あるので、その際の解決方法の一つとして、 GeometryReader
が頭に浮かんだら記事をまとめた甲斐がある。