[SwiftUI] ウィンドウ(SafeArea)のサイズを Environment で取得できるようにする

親 View からの提案サイズを知りたいときには GeometryReader を利用できます。

struct ChildView: View {
    var body: some View {
        GeometryReader { geometry in
            let _ = print(“⭐ \(geometry.size)) // 提案サイズを取得
            Text(Hello)
        }
    }
}

ウィンドウ(SafeArea)のサイズを取得したい場合、ルートとなる View でそれを行うことで実現できます。

@main
struct WindowSizeApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { geometry in
                ContentView(safeAreaSize: geometry.size) // ルートの View
            }
        }
    }
}

引数渡しの問題点

ウィンドウ(SafeArea)のサイズを取得したいのが、ContentView などの上位 View だけであれば引数渡しで十分ですが、より下位の View でも利用したくなった場合、引数渡しのバケツリレーがいたるところで発生し、コードの可読性やメンテナンス性を損ねるケースがあります。

struct ContentView: View {
    var safeAreaSize: CGSize

    var body: some View {
        ChildView(safeAreaSize: safeAreaSize) // 引数渡し
    }
}

struct ChildView: View {
    var safeAreaSize: CGSize

    var body: some View {
        GrandChildView(safeAreaSize: safeAreaSize) // 引数渡し
    }
}

子View で GeometryReader を利用する際の問題点

親 View からの提案サイズをその場で取得できれば十分なケースであっても、ScrollView や List といった内部コンテンツのサイズが可変なコンポーネント内で GeometryReader を使用するとサイズが不定になって潰れてしまうケースもあります。

struct ContentView: View {
    var body: some View {
        ScrollView {
            GeometryReader { geometry in
                VStack {
                    Text("Hello")
                    Text("World")
                }
                .border(.blue)
            }
            .border(.red)
        }
        .padding()
    }
}

この問題は、PreferenceKey を使用して GeometryReader のサイズを遅延計算することで解決できますが、

  • 必要なタイミングで毎回記述するのは面倒・シンタックスノイズ
  • 遅延計算なので、通常よりレンダリングコストが高い
  • 遅延計算なので、無限ループしないように注意が必要

といったデメリットは残ります。

struct ContentView: View {
    @State private var contentHeight: CGFloat?

    var body: some View {
        ScrollView {
            GeometryReader { geometry in
                VStack {
                    Text("width: \(Int(geometry.size.width))")
                        .font(.title)
                }
                .background {
                    // 1. コンテンツのサイズを計測
                    GeometryReader { local in
                        // 2. Preference で上位 View に伝える
                        Color.clear
                            .preference(key: ContentSizeKey.self, value: local.size)
                    }
                }
                // 3. Preference を受け取り、View の本来のサイズを遅延決定
                .onPreferenceChange(ContentSizeKey.self) { size in
                    contentHeight = size.height
                }
            }
            .frame(height: contentHeight)
            .border(.red)
        }
        .frame(maxWidth: .infinity)
    }
}

// Preference 用のキーを定義
private struct ContentSizeKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {}
}

以下のように View の extension に readSize という関数を用意すれば大幅に簡略化できますが、それでもボイラープレートは残りますし、遅延計算によるパフォーマンス低下といった問題は残ります。

struct XView: View {
    @State private var contentHeight: CGFloat?

    var body: some View {
        ScrollView {
            GeometryReader { geometry in
                VStack {
                    Text("width: \(Int(geometry.size.width))")
                        .font(.title)
                }
                .readSize(key: ContentSizeKey.self) { size in
                    contentHeight = size.height
                }
            }
            .frame(height: contentHeight)
            .border(.red)
        }
        .frame(maxWidth: .infinity)
    }
}

extension View {
    func readSize<K: PreferenceKey>(
        key: K.Type,
        perform: @escaping (CGSize) -> Void
    ) -> some View where K.Value == CGSize {
        background(
            GeometryReader { local in
                Color.clear
                    .preference(key: key, value: local.size)
            }
        )
        .onPreferenceChange(key) { size in
            perform(size)
        }
    }
}

private struct ContentSizeKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) {}
}

もし、この例のように端末の横幅を取得できれば十分というケースであれば、GeometryReader を利用するのは過剰に感じるケースもあるかもしれません。

Environment 経由でウィンドウサイズを取得できるようにする

このようなケースでは Environment 経由で取得できるようにしておくと便利かもしれません。

// Environmentキーの定義
struct SafeAreaSizeKey: EnvironmentKey {
    static let defaultValue: CGSize = .zero
}

// 読み書き用のプロパティを定義
extension EnvironmentValues {
    var safeAreaSize: CGSize {
        get { self[SafeAreaSizeKey.self] }
        set { self[SafeAreaSizeKey.self] = newValue }
    }
}

@main
struct WindowSizeApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { geometry in
                ContentView()
                    .environment(\.safeAreaSize, geometry.size) // ⭐ Environment に登録
            }
        }
    }
}

struct ContentView: View {
    @Environment(\.safeAreaSize) private var safeAreaSize // ✅ Environment から取得

    var body: some View {
        SizeView(size: safeAreaSize)
    }
}

struct ChildView: View {
    @Environment(\.safeAreaSize) private var safeAreaSize // ✅ Environment から取得
    ...
}

以下は検証用の簡単なマルチプラットフォームアプリですが、ウィンドウサイズの変更にもきちんと対応できていることが分かります。

import SwiftUI

@main
struct WindowSizeApp: App {
    var body: some Scene {
        WindowGroup {
            GeometryReader { geometry in
                ContentView()
                    .environment(\.safeAreaSize, geometry.size) // ⭐ 登録
            }
        }
    }
}

// メイン View
struct ContentView: View {
    @Environment(\.safeAreaSize) private var safeAreaSize // ✅ 取得
    @State private var isPresented: Bool = false

    var body: some View {
        VStack {
            Text("ContentView")
                .font(.headline)
            Spacer()
            SizeView(size: safeAreaSize)
            Button("Show") {
                isPresented = true
            }
        }
        .padding()
        .navigationTitle("Sunabalab Tech Blog")
        .frame(width: safeAreaSize.width, height: safeAreaSize.height)
        .background { Color.blue }
        .border(.gray)
        .overlay {
            if isPresented {
                OverlayView()
            }
        }
    }
}

// オーバーレイ用の View
struct OverlayView: View {
    @Environment(\.safeAreaSize) private var safeAreaSize // ✅ 取得

    // ウィンドウ(SafeArea)サイズの半分で表示
    var size: CGSize {
        .init(
            width: safeAreaSize.width * 0.5,
            height: safeAreaSize.height * 0.5
        )
    }

    var body: some View {
        VStack {
            Text("OverlayView")
                .font(.headline)
                .foregroundStyle(.black)
            Spacer()
            SizeView(size: size)
                .foregroundStyle(.red)
            Spacer()
        }
        .padding()
        .frame(width: size.width, height: size.height)
        .background { Color.white }
        .shadow(radius: 10)
    }
}

struct SizeView: View {
    var size: CGSize

    var body: some View {
        Text("width: \(Int(size.width)) | height: \(Int(size.height))")
    }
}

端末サイズの取得

T.B.D

おわりに

この記事では、GeometryReader を使ったウィンドウ(SafeArea)サイズの取得方法、引数渡しを使った場合、サイズを取得する際に各 VIew で GeometryReader を使う場合に問題点を挙げ、Environment を使ってどの子 View からも取得できるようにする方法を紹介しました。

しかし、必ずしも Environment を使ったり、各 View で GeometryReader を使用するのを控えるべきという意味ではありません。

引数渡しは記述量が増えがちである一方、コード上からデータの引き渡しが分かりやすいというメリットもありますし、端末サイズではなく実際の提案サイズが必要な場合は GeometryReader を利用する必要があるケースが殆どでしょう。

多くの場合、まずはより愚直なコードで書き進め、記述量・メンテナンス性・パフォーマンスなどの問題に気づいたタイミングで代替手段を検討することで、早すぎる最適化のパターンを避けることに繋がるでしょう。