SwiftUIで子ビューのframeを取得(非推奨)
こちらの手法は自分の調べ不足で出てきた変則な手法ですので、標準的な手法をお勧めしますが、PreferenceKey
を使いたくない場合、この手法の出番で、参考として残します。
原理
GeometryReader
で親ビューのframeを取得することは可能という前提から考えると、子ビューを親ビューにすればできます。問題はGeometryReader
が子ビューに入って、frameを親に伝えるため@Binding
を使う必要があり、子ビューが複雑になります。
子ビューを調整したいですが、子ビューそのものを変更したくない場合、modifierの出番です。overlay(_:alignment:)
やbackground(_:alignment:)
を使えば問題が解決できます。
.overlay(
GeometryReader { proxy in
Color.clear
}
)
変わった手法で、EmptyView
を使いたいですが、EmptyView
は特殊1で使えません。
運用
GeometryReader
を使い、GeometryProxy
からframeを取得することが可能になりましたが、どう使うのは次の問題になります。
まずはframeの座標系を指定する必要があります。UIViewの場合はconvert(_:from:)
で指定できますが、SwiftUIはテキストで指定します。
VStack {
...
subview
.overlay(
GeometryReader { proxy -> Color in
let frame = proxy.frame(in: .named("parent"))
return .clear
}
)
...
}
.coordinateSpace(name: "parent")
具体的なframeを取得できましたので、@State
にセットすれば終わりですが、GeometryReader
はView
ですので、直接にセットすると下記に警告が出て、動きもおかしくなります。
Modifying state during view update, this will cause undefined behavior.
SwiftUIの都合で、回避しなければなりませんが、DispatchQueue.main.async(execute:)
で簡単に回避できます。
.overlay(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
frame = proxy.frame(in: .named("container"))
}
return .clear
}
)
もっと改善する余地2はありますが、これで子ビューのframeを取得し、利用できるようになります。
具体例
例えばUISegmentedControl
のように選択された部分が変わった場合、背景をアニメーション付きで移動する部品を作ります。
private var background: some View {
...
background
.frame(width: selectedFrame.width, height: selectedFrame.height)
.offset(x: selectedFrame.minX, y: selectedFrame.minY)
return background
}
var body: some View {
HStack {
...
}
.background(background, alignment: .topLeading)
}
上記の方法で1個目の部分のframeを取得して、このように背景をレイアウトすればできます。
別の部分を選択された時、インデックスを変えて同じことをやればできますが、そもそも選択されたときは描画する時ではなく、タップされた時ですのでがDispatchQueue.main.async(execute:)
使う必要がありません。
.overlay(
GeometryReader { proxy in
Button(action: {
withAnimation {
selectedFrame = proxy.frame(in: .named("parent"))
}
}, label: {
Color.clear
})
}
)
overlay(_:alignment:)
を使い、frameを取得する手法は変わりませんが、DispatchQueue.main.async(execute:)
がなくなったし、描画時と変更時が分けて、変更時だけアニメーションをつけることができます。
普通の場合これで終わりますが、もしもっと親の方がanimation(_:)
つけている場合、withAnimation(_:_:)
でコントロールできなくなります。そのためもうちょっと対応する必要があります。
@State var selectedFrame: CGRect = .zero {
didSet {
if oldValue != .zero {
selectionChanged = true
}
}
}
@State var selectionChanged = false
この上、animation(_:)
でアニメーションを上書きします。
private var background: some View {
...
background
.frame(width: selectedFrame.width, height: selectedFrame.height)
.offset(x: selectedFrame.minX, y: selectedFrame.minY)
.animation(selectionChanged ? .easeOut : nil)
return background
}
これで初回表示がアニメーションなしになります。しかし、一回選択すると、向きの変更が発生したてきもアニメーション付きになってしまいます。幸いなことにDispatchQueue.main.async(execute:)
で解決できます。
private var background: some View {
...
background
.frame(width: selectedFrame.width, height: selectedFrame.height)
.offset(x: selectedFrame.minX, y: selectedFrame.minY)
.animation(selectionChanged ? .easeOut : nil)
DispatchQueue.main.async {
selectionChanged = false
}
return background
}
こうやって、アニメーションを先に始めさせて、それからアニメーションをnil
にすることで、選択した時だけアニメーションをつけることができます。
とはいえ、このアプローチはアニメーションをnil
にしても、発生しているアニメーションに影響したいという前提に依存していますので、今後できなくなる可能性があります。ですので、animation(_:)
をつける必要があるような仕方がない場合以外はこのアプローチを避けましょう。
まとめ
overlay(_:alignment:)
やbackground(_:alignment:)
でframeを取得DispatchQueue.main.async
で@State
に反映