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という仕組みで取得していますので、PreferenceKeyValueを変更する必要があります。

struct BoundsAnchorPreferenceKey: PreferenceKey {

    static var defaultValue: Anchor<CGRect>? = nil

    static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
        value = nextValue()
    }
}

anchorPreference(key:value:transform:)のドキュメントに何も書いてありませんが、取得できる値についてAnchor.Sourceで確認できます。

最後、親ビュー側でGeometryReaderを使い、GeometryProxyでframeに変換します。

GeometryReader { proxy in
    let frame = proxy[anchor]
}

具体例

UISegmentedControl

例えばUISegmentedControlのように選択された部分が変わった場合、背景をアニメーション付きで移動する部品を作ります。

選択された部分のboundsだけを取得するには、ifにより違うViewになることを対処する必要があります。AnyViewはもちろんできるし、ZStackで対処する方法が見たことがあるかもしれません。ZStackで対処できる根本的な理由はfunction builderのおかげですので、下記のようなextensionでも対処できます。

extension View {
    @ViewBuilder func modifier<T>(shouldModify: Bool, modifier: (Self) -> T) -> some View where T : View {
        if shouldModify {
            modifier(self)
        } else {
            self
        }
    }
}
.modifier(shouldModify: index == selectedSegmentIndex) { content in
    content
        .anchorPreference(key: BoundsAnchorPreferenceKey.self, value: .bounds) { $0 }
}

これで選択された部分のboundsだけを取得できるようになりました。

次は選択された部分の背景を作ります。

まさにこのような需要のために設計されたようなmodiferがあります。

.backgroundPreferenceValue(BoundsAnchorPreferenceKey.self) { anchor in
    anchor.map { anchor in
        GeometryReader { proxy in
            buildSelectedBackground(by: proxy[anchor])
        }
    }
}

Anchor<CGRect>?nilに対してまたAnyView(EmptyView())とかで対処する必要があると思いきや、Optional.map(_:)で普通に対処できます。

かつanimation(_:)つけている場合でも、nilのおかげで、背景は初期状態は.zeroから取得できたframeになるのではなく、背景なしから背景ありになりますので、変わった手法の方にある不要なアニメーションがありません。

まとめ

  1. anchorPreference(key:value:transform:)でboundsを取得
  2. GeometryReaderを使い、GeometryProxyでframeに変換
    • 優先にbackgroundPreferenceValue(_:_:)overlayPreferenceValue(_:_:)を検討し、仕方がない場合onPreferenceChange(_:perform:)を使う

参考

感謝

変わった手法に対して違和感を感じていましたが、よりいいやり方がわからなくて、記事にしましたが、auramagiさんにこの標準的な手法を教えていただきました。