SwiftUIで子ビューのframeを取得
UIKitの場合は適切なタイミングでUIView.frame
から取得できるし、Auto Layoutで制約つけていれば、具体的なframeを取得しなくても、大体な目的は達成できます。SwiftUIの場合はGeometryReader
で親ビューのframeを取得することは可能ですが、子ビューのframeを取得するに別の手法が必要です。
親の方に情報伝達
直接に子ビューのframeを取得することができませんので、子ビュー側で取得してから親に伝達する必要があります。
子の方に伝達するにはenvironmentという仕組みで、逆方向の場合はpreferenceという仕組みがあります。
Preferenceを使うにはPreferenceKey
を実装する必要があります。
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue()
}
}
子ビューに対してmodiferで値を設定します。
.preference(key: FramePreferenceKey.self, frame)
親ビュー側に対してもmodifierで値を取得します。
.onPreferenceChange(FramePreferenceKey.self) { frame in
childFrame = frame
}
子ビューの方で設定するframe
はどう取得するかはまだ解決していないのに、ちょっと複雑になりましたよね。私も複雑に感じて、変わった手法に手を出しました。
しかし、親の方に情報伝達用のPreferenceKey
便利さはこれからです。
Frameの取得
preference(key:value:)
はあくまで一番基本な使い方で、frameを取得するには別の使い方があります。
.anchorPreference(key: BoundsAnchorPreferenceKey.self, value: .bounds) { $0 }
こうやってboundsを変わった手法を使わなくても、直接にboundsを取得できます。
Anchorという仕組みで取得していますので、PreferenceKey
のValue
を変更する必要があります。
struct BoundsAnchorPreferenceKey: PreferenceKey {
static var defaultValue: Anchor<CGRect>? = nil
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
if let nextValue = nextValue() {
value = nextValue
}
}
}
anchorPreference(key:value:transform:)
のドキュメントに何も書いてありませんが、取得できる値についてAnchor.Source
で確認できます。
最後、親ビュー側でGeometryReader
を使い、GeometryProxy
でframeに変換します。
GeometryReader { proxy in
let frame = proxy[anchor]
}
具体例
例えばUISegmentedControl
のように選択された部分が変わった場合、背景をアニメーション付きで移動する部品を作ります。
まずは選択された部分のboundsを取得しましょう。
.anchorPreference(key: BoundsAnchorPreferenceKey.self, value: .bounds) {
index == selectedSegmentIndex ? $0: nil
}
選択されてない部分はnil
を返すことで無視されます。
次は選択された部分の背景を作ります。
まさにこのような需要のために設計されたようなmodiferがあります。
.backgroundPreferenceValue(BoundsAnchorPreferenceKey.self) { anchor in
anchor.map { anchor in
GeometryReader { proxy in
buildSelectedBackground(by: proxy[anchor])
}
}
}
Anchor<CGRect>?
のnil
に対してまたEmptyView()
とかで対処する必要があると思いきや、Optional.map(_:)
で普通に対処できます。
かつanimation(_:)
つけている場合でも、nil
のおかげで、背景は初期状態は.zero
から取得できたframeになるのではなく、背景なしから背景ありになりますので、変わった手法の方にある不要なアニメーションがありません。
まとめ
anchorPreference(key:value:transform:)
でboundsを取得GeometryReader
を使い、GeometryProxy
でframeに変換- 優先に
backgroundPreferenceValue(_:_:)
やoverlayPreferenceValue(_:_:)
を検討し、仕方がない場合onPreferenceChange(_:perform:)
を使う
- 優先に
参考
感謝
変わった手法に対して違和感を感じていましたが、よりいいやり方がわからなくて、記事にしましたが、auramagiさんにこの標準的な手法を教えていただきました。