Mac アプリでラベルを引き伸ばさずに測定するには、調べたいビューに onGeometryChange を付けます。古いデプロイターゲットでは、背景に GeometryReader を配置します。モディファイアを置く位置によって、読み取る幅が決まります。

この解説では、合成ラベルをパディング込みで測定し、その結果が周囲のレイアウトに影響しないようにします。また、ラベルの現在の幅、理想の幅、コンテナの幅を区別する方法も説明します。

GeometryReader を使うとレイアウトが広がるのはなぜですか?

SwiftUI のレイアウトは、提案と応答で構成されます。親がスペースを提案し、子が計算したサイズを返します。フレームを追加すると、ビュー階層に別のビューが加わるため、ラベルとそのフレームの幅は異なる場合があります。Apple はこの仕組みをシンプルなビューのレイアウトで説明しています。

GeometryReader は、柔軟な優先サイズを持つコンテナです。そのため、コンパクトなラベルを GeometryReader で囲むと、親に提示されるレイアウト要素が変わります。測定しているのはリーダーのコンテナであり、テキストに必要な幅よりかなり広いスペースを占めることがあります。Apple のGeometryReader のドキュメントを参照してください。

観察する場合は、既存のビューに測定を付けます。背景のリーダーを使う必要がある場合は、配置が重要です。Apple の説明によると、背景は適用先のビューのサイズを受け取ります。そのため、測定領域はラベルによって決まります。ビューに背景を追加するを参照してください。

実装を選ぶ前に、インターフェースで知りたいことを決めましょう。

知りたいこと 適切な方法
現在のレイアウトで、このラベルの幅はいくつですか? ラベルのジオメトリを観察します。
ラベルを囲むパディング付きバッジの幅はいくつですか? パディングの後で観察します。
ラベルを含む行の幅はいくつですか? 行またはそのフレームを観察します。
テキスト全体が希望する幅はいくつですか? 理想サイズを別途調べます。
複数の表示候補のうち、どれが収まりますか? ViewThatFits を検討します。
複数の子ビューをまとめて測定し、配置するにはどうすればよいですか? カスタム Layout を検討します。

この区別により、よくある誤りを防げます。制約によって幅が決まり、切り詰められている可能性もあるラベルの幅を、文字列全体の表示に必要なスペースだと見なしてしまう誤りです。

macOS 13 以降で現在の幅を測定する

Apple の現行ドキュメントでは、単一値を扱うアクションのオーバーロードである onGeometryChange(for:of:action:) は、macOS 13 以降で利用可能とされています。ジオメトリプロキシを同値比較可能な値に変換し、その値が変化したときにアクションを呼び出します。この API を含む SDK を使い、ここに示す1引数のアクションを使用してください。onGeometryChange(for:of:action:)を参照してください。

Xcode で SwiftUI を使った macOS アプリプロジェクトを作成し、デプロイターゲットを macOS 13 以降に設定します。生成されたアプリとコンテンツビューの宣言を、この完全なプログラムに置き換えてください。ターゲット内の @main アプリ宣言は1つだけにします。このサンプルで使うラベルはすべてローカルで生成されます。

import SwiftUI
import Foundation

@main
struct WidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            WidthDemoView()
        }
    }
}

struct WidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var offeredWidth: Double = 260
    @State private var showLongLabel = false

    private var label: String {
        showLongLabel
            ? "Sample collection of blue circles"
            : "Blue circles"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(label)
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .onGeometryChange(for: CGFloat.self) { proxy in
                    proxy.size.width
                } action: { newWidth in
                    measuredWidth = newWidth
                }
                .frame(
                    width: CGFloat(offeredWidth),
                    alignment: .leading
                )

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .monospacedDigit()
            .frame(width: 340, alignment: .leading)

            Slider(value: $offeredWidth, in: 120...340)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

オブザーバーは proxy.size.width を読み取ります。Apple は GeometryProxy.size をコンテナビューのサイズと定義しています。ここでは、観察対象のビューにテキストとパディングが含まれ、その後に続くフレームが、独立して制御できる幅の提案を与えます。GeometryProxy.sizeを参照してください。

測定値は別の読み取り表示に反映されます。測定対象のバッジの幅は設定しません。デモ領域と読み取り表示の幅を固定することで、診断用テキストによって周囲のスタックがサイズ変更されることなく、結果を確認しやすくしています。

CGFloat だけを返すことで、幅に絞った観察にもなります。高さだけが変化しても、変換後の値は変わりません。ジオメトリは頻繁に変化するため、必要なデータだけに変換することを Apple は推奨しています。onGeometryChange(for:of:action:)を参照してください。

これらの例は、取得した API 宣言に照らして確認しました。利用可能な環境は Swift ツールチェーンのない Linux のため、ここではコンパイルも実行もしていません。Mac 上の Xcode でビルドし、インターフェースを確認してください。

測定用モディファイアはどこに置けばよいですか?

モディファイアチェーンは、ビューが順にラップされていくものとして読み取ります。測定は、含めたい最後のサイズ変更モディファイアの直後に置きます。Apple のレイアウトの説明では、フレームがビューをラップする仕組みや、パディングによってコンテンツが利用できるスペースが変わる仕組みが説明されています。シンプルなビューのレイアウトを参照してください。

この例での測定位置 測定対象
2つのパディングモディファイアの前 テキストビューの現在のレイアウト幅
2つのパディングモディファイアの後 パディングを含むバッジの現在の幅
明示的な幅のフレームの後 外側のフレームの幅
周囲の VStack スタックの幅

例として、現在の提案のもとでテキストビューの幅が96ポイントだとします。左右それぞれに12ポイントのパディングを加えると、パディング込みの幅は120ポイントになります。外側のフレームの幅260ポイントは、それとは別の測定値です。これらの数値はモディファイアの境界を説明するためのもので、このサンプル文字列を実測した結果ではありません。

予想外の値が出た場合は、オブザーバーと同じモディファイアチェーンの位置に一時的に枠線を付けます。その境界が、意図した測定対象と一致するか確認してください。行全体に付けたリーダーは、表示されているラベルがその一部しか占めていなくても、行の幅を正しく返すことがあります。

macOS 12 のターゲットでは背景リーダーを使う

macOS 12 をデプロイターゲットにする場合は、背景の GeometryReader を使い、プリファレンスを介して幅を通知します。以下で使う末尾クロージャ形式の background API は、macOS 12 以降で利用できます。変更対象のビューがレイアウトを主導するようにしたい場合、背景を使うことを Apple は推奨しています。background(alignment:content:)を参照してください。

これは単独で動作する別のプログラムです。2つ目のアプリ宣言を追加するのではなく、前の例をこれに置き換えてください。

import SwiftUI
import Foundation

private struct BadgeWidthKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }

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

@main
struct LegacyWidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            LegacyWidthDemoView()
        }
    }
}

struct LegacyWidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var showLongLabel = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(showLongLabel ? "Blue circles collection" : "Blue circles")
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .background {
                    GeometryReader { proxy in
                        Color.clear.preference(
                            key: BadgeWidthKey.self,
                            value: proxy.size.width
                        )
                    }
                }
                .onPreferenceChange(BadgeWidthKey.self) { newWidth in
                    measuredWidth = newWidth
                }

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .frame(width: 340, alignment: .leading)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

PreferenceKey は値を祖先へ伝え、onPreferenceChange はその値の変化を受け取ります。リーダーはコンテンツクロージャ内で状態を設定するのではなく、測定値を出力します。Apple のPreferenceKeyとonPreferenceChange(_:perform:)のドキュメントを参照してください。

このリデューサーは、値を出力するバッジが1つであることを前提としています。同じキーを複数の子ビューで使うと、それらの値が結合され、この実装では最後にリデュースされた値が保持されます。複数のラベルを個別に追跡するには、1つのスカラー値で複数のビューを表すのではなく、キー付きの測定値コレクションを設計してください。

測定値がレイアウトから独立していることを確認するには?

最初のプログラムを使って、次の手順で確認します。

  1. Blue circles と十分に大きな幅の提案で開始します。色付きのバッジと読み取り表示を比べます。
  2. スライダーをドラッグします。外側のフレームは独立して変化し、十分なスペースがある間はバッジをコンパクトなままにできます。
  3. Change sample label を選び、提案幅を小さくします。文字列全体の理想幅を期待するのではなく、現在の制約を受けたバッジを確認してください。
  4. 明示的なフレームの下にオブザーバーを移動します。今度はそのフレームの幅が報告されるはずです。
  5. バッジの測定値を別の用途に使う前に、オブザーバーをフレームの上に戻します。

観察した幅を、そのビュー自身の幅を決める計算式に含めないでください。たとえば、観察した幅にパディングを加えた値をそのビューのフレームに設定すると、大きくなった結果が繰り返しレイアウトに反映される可能性があります。これは作成した依存関係から直接生じます。観察によって状態が変わり、状態によってサイズが変わり、新しいサイズによって再び観察が行われます。

実際に必要なのが完全なラベルとコンパクトな代替表示のどちらを選ぶかであれば、macOS 13 以降の ViewThatFits を使うと、制約された軸に理想サイズが収まる最初の子ビューを選べます。これにより、幅を保存する必要がなくなる場合があります。ViewThatFitsを参照してください。

複数の子ビューをまとめて測定して配置する必要がある場合は、macOS 13 以降でカスタム Layout を使います。測定メソッドと配置メソッドを使うと、コンテナがサブビューに問い合わせてから配置できます。この判断をレイアウト処理の中に保てます。Layoutを参照してください。

理想サイズや AppKit のテキスト計測値で同じことができますか?

テキストの理想幅を保ちたい場合に限り、.fixedSize(horizontal: true, vertical: false) を使います。Apple の説明によると、これを使うとテキストが親の境界を超えることがあります。測定のために追加すると、表示中のビューのサイズ決定方法が変わるため、元の制約付きレイアウトは保たれません。fixedSize(horizontal:vertical:)を参照してください。

文字数も幅の測定には適しません。Swift の String.count は拡張書記素クラスタで表される Character の数を数えます。文字組み上の寸法は返しません。その数に推定した文字幅を掛けても、SwiftUI ビューの測定にはなりません。文字列と文字を参照してください。

AppKit の描画処理では、NSAttributedString.size() は、その属性付き文字列の描画に必要なサイズを返し、端数を含む場合もあります。ビューのサイズ設定にこの結果を使う場合は、切り上げるよう Apple は勧めています。これはテキスト描画についての問いに答えるものであり、SwiftUI ビューのパディングや外側のフレームは含みません。size()を参照してください。

既存の TextKit 1 のレイアウトでは、NSLayoutManager.usedRect(for:) は、テキストコンテナ内で現在レイアウト済みのグリフが占める領域を報告します。それ自体でグリフの生成やレイアウトを行うわけではありません。レイアウトを先に完了する必要がある場合は、ensureLayout(for:) を呼び出してください。これらの API は設定済みのテキストコンテナを測定するため、その結果は SwiftUI のジオメトリとは分けて扱ってください。usedRect(for:)とensureLayout(for:)を参照してください。

SwiftUI の Mac インターフェースにすでに表示されているラベルについては、必要な境界でそのジオメトリを観察し、読み取り表示を独立させ、コンテンツと利用可能なスペースの両方を変えて試してください。これにより、調べたいレイアウトを保ちながら、役立つ幅を得られます。

参考資料