😸

GeometryReader:良いもの、それとも悪いもの?

2024/03/07に公開

GeometryReader:良いもの、それとも悪いもの?

GeometryReader は SwiftUI が誕生した当初から存在しており、多くのシナリオで重要な役割を果たしています。しかし、初めから開発者の中には否定的な意見を持つ人もおり、できるだけ避けるべきだと考えていました。特に最近の SwiftUI のアップデートで GeometryReader を代替できる API がいくつか追加された後、この見解はさらに強まりました。この記事では、GeometryReader の「よくある問題」を分析し、それが本当にそんなに悪いものなのか、そして「予想外」と批判されるその振る舞いが、実際には開発者の「予想」自体に問題があるためなのかどうかを考察します。

原文は私の ブログ で公開されています。Swift、SwiftUI、Core Data、SwiftData に関する最新のアップデートや優れた記事をお見逃しなく。Fatbobman's Swift Weekly に登録して、毎週の洞察と貴重なコンテンツを直接メールボックスにお届けします。

GeometryReader へのいくつかの批判

開発者からの GeometryReader への批判は主に以下の二つの点に集中しています:

  • GeometryReader はレイアウトを破壊する:この見解は、GeometryReader が利用可能な全スペースを占めるため、全体のレイアウト設計を破壊する可能性があると考えています。
  • GeometryReader が正確な幾何情報を取得できない:この見解は、ある状況下で GeometryReader が正確な幾何情報を取得できない、またはビューが視覚的に変化していない場合に、取得した情報が不安定になる可能性があると考えています。

さらに、いくつかの開発者は次のように考えています:

  • GeometryReader に過度に依存すると、ビューのレイアウトが硬直化し、SwiftUI の柔軟性の利点が失われます。
  • GeometryReader は SwiftUI の宣言的プログラミングの理念を破壊し、ビューのフレームを直接操作する必要があり、命令的プログラミングに近づきます。
  • GeometryReader が幾何情報を更新する際のリソース消費が大きく、不必要な再計算やビューの再構築を引き起こす可能性があります。
  • GeometryReader を使用するには、フレームを計算して調整するための大量の補助コードを書く必要があり、これによりコーディング量が増え、コードの可読性や保守性が低下します。

これらの批判がすべて根拠のないものではありません。かなりの部分が SwiftUI のバージョンアップデート後に新しい API を通じて改善されたり解決されたりしています。しかし、GeometryReader がレイアウトを破壊する、正確な情報を取得できないという意見は、通常、開発者の GeometryReader への理解不足や不適切な使用によるものです。これから、これらの点について分析し議論していきます。

GeometryReader とは

上述の否定的な見解について深く掘り下げる前に、まず GeometryReader の機能とこの API を設計した理由を理解する必要があります。

これが Apple の公式ドキュメントにおける GeometryReader の定義です:

A container view that defines its content as a function of its own size and coordinate space.

厳密に言えば、上述の記述に完全に同意しているわけではありません。これは事実上の誤りがあるからではなく、そのような表現がユーザーに誤解を招く可能性があるからです。実際には、「GeometryReader」という名前がその設計目的をよりよく反映しています:ジオメトリ情報のリーダー

具体的に言えば、GeometryReader の主な役割は、親ビューのサイズやフレームなどのジオメトリ情報を取得することです。公式ドキュメントにある「その内容を定義する(defines its content)」という表現は、GeometryReader の主要な機能が子ビューに積極的に影響を与えること、あるいは取得したジオメトリ情報が主に子ビュー用であると誤解されがちですが、実際には、それをジオメトリ情報を取得するツールとして見るべきです。これらの情報を子ビューに適用するかどうかは完全に開発者に委ねられています。

もし最初から以下のような方法で設計されていたなら、その誤解や誤用を避けることができたかもしれません。

@State private proxy: GeometryProxy

Text("Hello world")
    .geometryReader(proxy: $proxy)

View Extension に基づくアプローチに変更した場合、geometryReader の役割を次のように記述できます:それは適用されるビューのサイズ、フレームなどの幾何情報を提供し、ビューが自身の幾何情報を効果的に取得するための手段です。このような記述は、幾何情報が主に子ビューに適用されるという誤解を効果的に避けることができます。

Extension の方式を採用しない理由として、設計者は以下の二つの要因を考慮した可能性があります:

  • Binding を通じて情報を上方向に伝達することは、現在の公式 SwiftUI API の主要な設計方針ではありません。
  • 幾何情報を上層のビューに伝達することは、不必要なビューの更新を引き起こす可能性があります。一方、情報を下方向に伝達することで、更新が GeometryReader のクロージャ内でのみ行われることを保証できます。

このように、GeometryReader の設計は、特定の設計哲学に基づいており、その機能と利用方法は、開発者が意図した通りに適切に使用することで最大の効果を発揮するようになっています。そのため、GeometryReader の使用方法やその振る舞いを正しく理解し、適切に適用することが重要です。

GeometryReader はレイアウトコンテナですか、そのレイアウトロジックは何ですか?

はい、GeometryReader は布局容器として機能しますが、その振る舞いは他のものと少し異なります。

現在、GeometryReader は布局容器の形で存在し、その布局ロジックは以下の通りです:

  • 複数のビューを含むコンテナであり、デフォルトのスタックルールは ZStack に似ています。
  • 親ビューから提案されたサイズ(Proposed size)を、自身の必要サイズ(Required Size)として親ビューに返します。
  • 親ビューの提案サイズを自身の提案サイズとして子ビューに渡します。
  • 子ビューの原点(0,0)を GeometryReader の原点に配置します。
  • 理想的なサイズ(Ideal Size)は (10,10) です。

幾何情報の取得機能を考慮しない場合、GeometryReader の布局行動は以下のコードと非常に似ています。

GeometryReader { _ in
  Rectangle().frame(width: 50, height: 50)
  Text("abc").foregroundStyle(.white)
}

ほぼ等しいです:

ZStack(alignment: .topLeading) {
    Rectangle().frame(width: 50, height: 50)
    Text("abc").foregroundStyle(.white)
}
.frame(
    idealWidth: 10,
    maxWidth: .infinity,
    idealHeight: 10,
    maxHeight: .infinity,
    alignment: .topLeading
)

簡単に言うと、GeometryReader は親ビューが提供するすべてのスペースを占有し、すべての子ビューの原点をコンテナの原点(つまり左上隅)に合わせます。この非典型的なレイアウトロジックは、私がそれをレイアウトコンテナとして直接使用することをお勧めしない理由の一つです。

GeometryReader はアラインメントガイドの調整をサポートしていません。したがって、上記の説明では原点が使用されています。

しかし、これは GeometryReader をビューコンテナとして使用できないという意味ではありません。特定の状況では、他のコンテナよりも適している場合があります。例えば:

struct PathView: View {
    var body: some View {
        GeometryReader { proxy in
            Path { path in
                let width = proxy.size.width
                let height = proxy.size.height

                path.move(to: CGPoint(x: width / 2, y: 0))
                path.addLine(to: CGPoint(x: width, y: height))
                path.addLine(to: CGPoint(x: 0, y: height))
                path.closeSubpath()
            }
            .fill(.orange)
        }
    }
}

Path を描画する際、GeometryReader が提供する情報(サイズ、原点)は私たちのニーズにぴったり合っています。そのため、スペースを満たす必要があり、原点に揃えられた子ビューには、GeometryReader をレイアウトコンテナとして使用するのが非常に適しています。

GeometryReader は、子ビューが要求するサイズを完全に無視します。この点で、その処理方法は子ビューを扱う overlay や background の方法と一致しています。

上記の GeometryReader のレイアウトルールの説明で、その理想的なサイズが(10,10)であることを指摘しました。この意味を完全に理解していない読者もいるかもしれませんが、理想的なサイズとは、親ビューが提案するサイズが nil(指定されていないモード)の場合に、子ビューが返す要求サイズを指します。この GeometryReader の設定を理解していないと、開発者はあるシナリオで、GeometryReader が期待どおりにすべてのスペースを埋めていないと感じるかもしれません。

例えば、次のコードを実行すると、高さが 10 の四角形しか得られません:

struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            GeometryReader { _ in
                Rectangle().foregroundStyle(.orange)
            }
        }
    }
}

https://cdn.fatbobman.com/image-20231030192917562.png

ScrollView が子ビューに提案サイズを送信する際の処理ロジックは、多くのレイアウトコンテナとは異なります。非スクロール方向では、ScrollView はその次元の利用可能な全サイズを子ビューに提供します。一方、スクロール方向では、子ビューに提供する提案サイズは nil です。GeometryReader の理想的なサイズが(10,10)であるため、スクロール方向で ScrollView に返す必要サイズは 10 になります。この点で、GeometryReader の振る舞いは Rectangle と一致しています。したがって、開発者の中には GeometryReader が期待通りに利用可能な全スペースを埋めていないと考える人もいるかもしれません。しかし実際には、その表示結果は完全に正しく、これが正しいレイアウト結果です。

したがって、このような状況では、通常、明確な値を持つ次元のサイズ(提案サイズに値がある)のみを使用し、それを基にして他の次元のサイズを計算します。

例えば、ScrollView 内で 16:9 の比率で画像を表示したい場合(たとえ画像自体の比率が異なる場合でも):

struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            ImageContainer(imageName: "pic")
        }
    }
}

struct ImageContainer: View {
    let imageName: String
    @State private var width: CGFloat = .zero
    var body: some View {
        GeometryReader { proxy in
            Image("pic")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .onAppear {
                    width = proxy.size.width
                }
        }
        .frame(height: width / 1.77)
        .clipped()
    }
}

https://cdn.fatbobman.com/image-20231030200535483.png

まず、GeometryReader を使用して ScrollView から提供される推奨幅を取得し、この幅に基づいて必要な高さを計算します。次に、frameを通じて GeometryReader が ScrollView に提出する要求サイズの高さを調整します。これにより、期待される表示結果を得ることができます。

このデモでは、Image が以前に提出されたスペースを満たし、原点に揃える要件を完全に満たしているため、GeometryReader をレイアウトコンテナとして直接使用することに全く問題はありません。

この章には、SwiftUI のサイズとレイアウトに関する多くの知識が含まれています。これについてまだ十分に理解していない場合は、以下の記事を読むことをお勧めします:SwiftUI Layout: The Mystery of SizeSwiftUI Layout: Cracking the Size CodeAlignment in SwiftUI: Everything You Need to Know

なぜ GeometryReader は正確な情報を取得できないのか

一部の開発者は、GeometryReader が正しいサイズを取得できない(常に 0,0 を返す)、または異常なサイズ(例えば負の数)を返してレイアウトエラーを引き起こすと不満を持っているかもしれません。

これを理解するには、まず SwiftUI のレイアウト原理を理解する必要があります。

SwiftUI のレイアウトは、協議プロセスです。親ビューは子ビューに提案サイズを提供し、子ビューは要求サイズを返します。親ビューが子ビューの要求サイズに基づいて子ビューを配置するかどうか、および子ビューが親ビューからの提案サイズに基づいて要求サイズを返すかどうかは、親ビューと子ビューの事前設定された規則に完全に依存します。例えば、VStack は垂直方向に子ビューに明確な値の提案サイズ、未指定の提案サイズ、最大提案サイズ、最小提案サイズの情報をそれぞれ送信し、異なる提案サイズにおける子ビューの要求サイズを取得します。VStack はビューの優先度とその親ビューからの提案サイズを組み合わせて、配置時に子ビューに最終的な提案サイズを提出します。

いくつかの複雑なレイアウトシナリオや、特定のデバイスやシステムバージョンでは、レイアウトが最終的な安定した結果を得るまでに複数の協議ラウンドを必要とする場合があります。特に、ビューが自身の位置やサイズを再決定するために GeometryReader が提供する幾何学的情報に依存する必要がある場合です。したがって、最終的な安定した結果を得る前に、GeometryReader が子ビューに新しい幾何学的情報を繰り返し送信することがあります。上記のコードで示された情報取得方法を引き続き使用する場合、変更後の情報を取得することはできません:

.onAppear {
    width = proxy.size.width
}

したがって、情報を正しく取得する方法は以下の通りです:

.task(id: proxy.size.width) {
    width = proxy.size.width
}

このように、データが変化しても、私たちはデータを継続的に更新できます。一部の開発者は、画面の向きが変わった時に新しい情報を取得できないと報告しており、その理由も同じです。task(id:)onAppearonChangeのシナリオをカバーしており、最も信頼性の高いデータ取得方法です。

さらに、特定の状況下で GeometryReader は負の数値を返す可能性があります。これらの負のデータを直接frameに渡すと、レイアウトの異常が発生する可能性があります(デバッグ状態では、Xcode が紫色のヒントで開発者に警告します)。したがって、このような極端なケースをさらに回避するために、データを渡す際に要件を満たさないデータをフィルタリングすることができます。

.task(id: proxy.size.width) {
    width = max(proxy.size.width, 0)
}

GeometryProxy が Equatable プロトコルに準拠していないため、そして情報更新によるビューの再評価を可能な限り減少させるために、開発者は必要な情報のみを渡すべきです。

取得した幾何情報をどのように渡すか(例えば、上記で使用された@State や PreferenceKey を通じて)は、開発者のプログラミング習慣やシナリオの要件に依存します。

通常、overlaybackground内で GeometryReader + Color.clear を使用して幾何情報を取得し、伝達します。これは情報の取得精度(サイズ、位置が取得したいビューと完全に一致)を保証すると同時に、視覚的な影響を与えないことも保証します。

extension View {
    func getWidth(_ width: Binding<CGFloat>) -> some View {
        modifier(GetWidthModifier(width: width))
    }
}

struct GetWidthModifier: ViewModifier {
    @Binding var width: CGFloat
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    let proxyWidth = proxy.size.width
                    Color.clear
                        .task(id: proxy.size.width) {
                            $width.wrappedValue = max(proxyWidth, 0)
                        }
                }
            )
    }
}

注意:情報を PreferenceKey を通じて伝達したい場合は、overlay内で行うのが最善です。なぜなら、一部のシステムバージョンでは、backgroundから伝達されたデータがonPreferenceChangeで取得できない場合があるからです。

struct GetInfoByPreferenceKey: View {
    var body: some View {
        ScrollView {
            Text("Hello world")
                .overlay(
                    GeometryReader { proxy in
                        Color.clear
                            .preference(key: MinYKey.self, value: proxy.frame(in: .global).minY)
                    }
                )
        }
        .onPreferenceChange(MinYKey.self) { value in
            print(value)
        }
    }
}

struct MinYKey: PreferenceKey {
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
    static var defaultValue: CGFloat = .zero
}

特定の状況下で、GeometryReader を通じて取得した値が無限ループに陥り、それによってビューの不安定さやパフォーマンスの低下を引き起こすことがあります。例えば:

struct GetSize: View {
    @State var width: CGFloat = .zero
    var body: some View {
        VStack {
            Text("Width = \(width)")
                .getWidth($width)
        }
    }
}

https://cdn.fatbobman.com/unstable-Text-by-GeometryReader_2023-10-30_22.16.35.2023-10-30%2022_17_21.gif

厳密に言えば、この問題の根本原因は Text にあります。デフォルトフォントの幅が固定されていないため、安定したサイズの協議結果を形成することができません。解決策は非常にシンプルで、.monospaced()を追加するか、固定幅のフォントを使用することです。

Text("Width = \(width)")
    .monospaced()
    .getWidth($width)

文字が揺れる例は、SwiftUI-Lab の Safely Updating The View State という記事からのものです。

GeometryReader のパフォーマンス問題

GeometryReader が幾何情報を取得するタイミングを理解すれば、そのパフォーマンスへの影響を把握できます。ビューとしての GeometryReader は、評価、レイアウト、レンダリングの後でなければ、取得したデータをクロージャ内のコードに渡すことができません。これは、提供された情報を利用してレイアウトを調整する必要がある場合、少なくとも一連の評価、レイアウト、レンダリングプロセスを完了させ、その後でデータを取得し、これらのデータに基づいてレイアウトを再調整する必要があることを意味します。このプロセスは、ビューが複数回再評価および再レイアウトされる原因となります。

SwiftUI の初期バージョンでは LazyGrid などのレイアウトコンテナが欠けていたため、開発者は GeometryReader を使ってさまざまなカスタムレイアウトを実装するしかありませんでした。これは、ビューの数が多い場合、深刻なパフォーマンス問題を引き起こす可能性があります。

SwiftUI がいくつかの以前に欠けていたレイアウトコンテナを補完して以来、GeometryReader によるパフォーマンスへの大規模な影響は軽減されました。特に、Layout プロトコルに準拠するカスタムレイアウトコンテナを許可してからは、上述の問題は基本的に解決されました。GeometryReader とは異なり、layout プロトコルに準拠するレイアウトコンテナは、レイアウトフェーズで親ビューの推奨サイズとすべての子ビューの要求サイズを取得できます。これにより、幾何学データの反復的な受け渡しによって引き起こされる多数のビューの反復的な更新を避けることができます。

しかし、これは GeometryReader の使用時に注意が不要であるという意味ではありません。GeometryReader のパフォーマンスへの影響をさらに軽減するためには、以下の二点に注意する必要があります:

  • 幾何情報の変化の影響を受けるビューを少数に限定する
  • 必要な幾何情報のみを伝達する

上記の二点は、SwiftUI ビューのパフォーマンスを最適化する一貫した原則に従っています。つまり、状態変化の影響範囲をコントロールすることです。

SwiftUI の方法でレイアウトを行う

GeometryReader に対する否定的な見解から、一部の開発者はそれを避けるために他の方法を探すかもしれません。しかし、多くのシナリオでは、GeometryReader はそもそも最適な解決策ではないことを考えたことはありますか?避けるというよりは、もっと SwiftUI 的な方法でレイアウトを行うべきです。

GeometryReader は、ビューが利用可能なスペースの 25%の幅を占めるようにしたり、上記のように指定された縦横比に基づいて高さを計算するような、比率を限定するシナリオでよく使用されます。このような要件を扱う際には、特定の幾何学的データに依存するのではなく、もっと SwiftUI の思考方式に沿ったレイアウトソリューションを優先するべきです。

例えば、以下のコードを使用して、上述した画像の縦横比を制限する要件を満たすことができます:

struct ImageContainer2: View {
    let imageName: String
    var body: some View {
        Color.clear
            .aspectRatio(1.77, contentMode: .fill)
            .overlay(alignment: .topLeading) {
                Image(imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            }
            .clipped()
    }
}

struct GeometryReaderInScrollView: View {
    var body: some View {
        ScrollView {
            ImageContainer2(imageName: "pic")
        }
    }
}

aspectRatioを使用してアスペクト比に合わせたベースビューを作成し、その上にImageoverlayで配置します。さらに、overlayはアライメントガイドを設定することがサポートされているため、GeometryReader を使用するよりも、画像のアライメント位置をより便利に調整することができます。

また、GeometryReader はしばしば、2 つのビューの空間を特定の比率で分割するために使用されます。この種の要件に対しても、他の方法で対処することが可能です(以下のコードは幅の 40%と 60%を分配し、高さは最も高い子ビューに依存します):

struct FortyPercent: View {
    var body: some View {
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            // placeholder
            GridRow {
                ForEach(0 ..< 5) { _ in
                    Color.clear.frame(maxHeight: 0)
                }
            }
            GridRow {
                Image("pic")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .gridCellColumns(2)
                Text("Fatbobman's Swift Weekly").font(.title)
                    .gridCellColumns(3)
            }
        }
        .border(.blue)
        .padding()
    }
}

https://cdn.fatbobman.com/image-20231031103955150.png

ただし、特定の空間に 2 つのビューを特定の比率で配置する(子ビューのサイズを無視する)という要件に対しては、GeometryReader は今でも最適な解決策の一つです。

struct RatioSplitHStack<L, R>: View where L: View, R: View {
    let leftWidthRatio: CGFloat
    let leftContent: L
    let rightContent: R
    init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
        self.leftWidthRatio = leftWidthRatio
        self.leftContent = leftContent()
        self.rightContent = rightContent()
    }

    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 0) {
                Color.clear
                    .frame(width: proxy.size.width * leftWidthRatio)
                    .overlay(leftContent)
                Color.clear
                    .overlay(rightContent)
            }
        }
    }
}

struct RatioSplitHStackDemo: View {
    var body: some View {
        RatioSplitHStack(leftWidthRatio: 0.25) {
            Rectangle().fill(.red)
        } rightContent: {
            Color.clear
                .overlay(
                    Text("Hello World")
                )
        }
        .border(.blue)
        .frame(width: 300, height: 60)
    }
}

image-20231104145027741

この章は、開発者が GeometryReader の使用を避けるべきだと暗示しているのではなく、SwiftUI には他にも多くのレイアウト手法があることを思い出させるものです。

同じ要件に対して SwiftUI が提供する多様なレイアウト手法について学ぶために、Layout in SwiftUI WaySeveral Ways to Center Views in SwiftUI の二つの記事を読んでください。

内実と表象:異なるサイズデータ

SwiftUI では、いくつかの modifier がレイアウト後、レンダリングレベルでビューに適用される調整です。SwiftUI Layout: Cracking the Size Code の記事で、サイズに関する「内実と表象」の問題を探究しました。例えば以下のコード:

struct SizeView: View {
    var body: some View {
        Rectangle()
            .fill(Color.orange.gradient)
            .frame(width: 100, height: 100)
            .scaleEffect(2.2)
    }
}

レイアウト時、Rectangle の要求サイズは 100 x 100 ですが、レンダリング段階でscaleEffectによって処理された後、最終的に 220 x 220 の矩形が表示されます。scaleEffectはレイアウトの後に調整されるため、Layout プロトコルに準拠したレイアウトコンテナを作成しても、そのレンダリングサイズを知ることはできません。このような状況では、GeometryReader がその役割を果たします。

struct SizeView: View {
    var body: some View {
        Rectangle()
            .fill(Color.orange.gradient)
            .frame(width: 100, height: 100)
            .printViewSize()
            .scaleEffect(2.2)
    }
}

extension View {
    func printViewSize() -> some View {
        background(
            GeometryReader { proxy in
                let layoutSize = proxy.size
                let renderSize = proxy.frame(in: .global).size
                Color.clear
                    .task(id: layoutSize) {
                        print("Layout Size:", layoutSize)
                    }
                    .task(id: renderSize) {
                        print("Render Size:", renderSize)
                    }
            }
        )
    }
}

// OUTPUT:
Layout Size: (100.0, 100.0)
Render Size: (220.0, 220.0)

GeometryProxysizeプロパティはビューのレイアウトサイズを返し、frame.sizeを通じて返されるのは最終的なレンダリングサイズです。

visualEffect:GeometryReader を使用せずに幾何情報を取得

開発者がしばしば部分的なビューの GeometryProxy を取得する必要があることを考慮し、GeometryReader を繰り返し封入することが煩雑になりがちなため、WWDC 2023 で Apple は SwiftUI に新しい modifier を追加しました:visualEffect

visualEffectは、開発者が現在のレイアウトを破壊することなく(祖先や子孫を変更せずに)、クロージャ内でビューの GeometryProxy を直接使用し、ビューに特定の modifier を適用することを可能にします。

var body: some View {
    ContentRow()
        .visualEffect { content, geometryProxy in
            content.offset(x: geometryProxy.frame(in: .global).origin.y)
        }
}

visualEffectは、安全性と効果を保証するために、VisualEffect プロトコルに準拠する modifier のみがクロージャ内で使用されることを許可します。簡単に言えば、SwiftUI は「表象」(レンダリング層面)にのみ作用する modifier が VisualEffect プロトコルに準拠するようにし、レイアウトに影響を与える可能性のあるすべての modifier(例:frame、padding など)のクロージャ内での使用を禁止しています。

以下のコードを通じて、使用可能な modifier の種類に制限を設けないvisualEffectの簡易模倣バージョンを作成することができます:

public extension View {
    func myVisualEffect(@ViewBuilder _ effect: @escaping @Sendable (AnyView, GeometryProxy) -> some View) -> some View {
        modifier(MyVisualEffect(effect: effect))
    }
}

public struct MyVisualEffect<Output: View>: ViewModifier {
    private let effect: (AnyView, GeometryProxy) -> Output
    public init(effect: @escaping (AnyView, GeometryProxy) -> Output) {
        self.effect = effect
    }

    public func body(content: Content) -> some View {
        content
            .modifier(GeometryProxyWrapper())
            .hidden()
            .overlayPreferenceValue(ProxyKey.self) { proxy in
                if let proxy {
                    effect(AnyView(content), proxy)
                }
            }
    }
}

struct GeometryProxyWrapper: ViewModifier {
    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    Color.clear
                        .preference(key: ProxyKey.self, value: proxy)
                }
            )
    }
}

struct ProxyKey: PreferenceKey {
    static var defaultValue: GeometryProxy?
    static func reduce(value: inout GeometryProxy?, nextValue: () -> GeometryProxy?) {
        value = nextValue()
    }
}

visualEffectと比較して:

struct EffectTest: View {
    var body: some View {
        HStack {
            Text("Hello")
                .font(.title)
                .border(.gray)

            Text("Hello")
                .font(.title)
                .visualEffect { content, proxy in
                    content
                        .offset(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
                        .scaleEffect(0.5)
                }
                .border(.gray)
                .foregroundStyle(.red)

            Text("Hello")
                .font(.title)
                .myVisualEffect { content, proxy in
                    content
                        .offset(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
                        .scaleEffect(0.5)
                }
                .border(.gray)
                .foregroundStyle(.red)
        }
    }
}

https://cdn.fatbobman.com/image-20231031145420378.png

結論

SwiftUI の機能がますます充実していくにつれて、GeometryReader を直接使用する場面はますます少なくなるかもしれません。しかし、間違いなく、GeometryReader はまだ SwiftUI で重要なツールの 1 つです。開発者はそれを適切なシナリオに適用する必要があります。

Discussion