こちらの手法は自分の調べ不足で出てきた変則な手法ですので、標準的な手法をお勧めしますが、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にセットすれば終わりですが、GeometryReaderViewですので、直接にセットすると下記に警告が出て、動きもおかしくなります。

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

例えば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(_:)をつける必要があるような仕方がない場合以外はこのアプローチを避けましょう。

まとめ

  1. overlay(_:alignment:)background(_:alignment:)でframeを取得
  2. DispatchQueue.main.async@Stateに反映

参考


  1. 個人的な推測ですが、EmptyView()はただプレースホルダー的なもので、最適化の段階や実際のレイアウトするとき取り除かれます。 

  2. AnyViewでラップしてあげて、EmptyViewを返すことや条件式により違うレイアウトするmodiferを作ることで、初回に以外にGeometryReaderをなくすこともできます。