SwiftUIのスクロールビューを短いコンテンツに合わせるには、内部コンテンツを測定し、その測定値と指定した上限の小さい方にビューポートの高さを設定します。長いコンテンツは、その上限内でスクロールできます。この手順では、自分のMacアプリで小さなラベルパネルにこの動作を実装します。

短いスクロールビューに空白が残るのはなぜですか?

まず、コンテンツの高さと、それを表示するビューポートの高さという2つの寸法を分けて考えます。AppleはScrollViewを、基になるコンテンツの一部を表示し、スクロールに応じて表示範囲を調整するものと説明しています。ラベルが少し並んでいるだけでは、希望するビューポートの高さは決まりません。AppleのScrollViewのドキュメントで、この違いを説明しています。

コンパクトなデスクトップパネルでは、明示的なサイズ規則を定めます: viewportHeight = min(contentHeight, maximumHeight)。スクロールに含めたいパディングも含め、実際に割り当てられる幅でコンテンツを測定します。その結果を外側のスクロールビューに適用します。Swiftのmin(_:_:)は小さい方の値を返し、SwiftUIのframe(width:height:alignment:)は高さの引数を指定すると固定の高さを設定します。Swiftのmin(_:_:)、SwiftUIの固定フレーム。

例として、上限が240ポイントだとします。コンテンツの高さが86ポイントならビューポートは86ポイントになり、410ポイントならビューポートは240ポイントになり、残りのコンテンツにはスクロールしてアクセスできます。これらは説明用の値であり、以下の例を測定した結果ではありません。

変更 制御するもの 望ましいコンパクトな高さを設定できるか?
測定したコンテンツの高さに上限を適用し、スクロールビューに設定する ビューポートの高さ はい。以下に示す制約付きレイアウトの場合
.scrollBounceBehavior(.basedOnSize) コンテンツサイズに応じたバウンス動作 いいえ
ラベルに設定する.fixedSize(horizontal: false, vertical: true) コンテンツの理想的な垂直サイズ 測定には役立ちますが、外側のビューポートには引き続き高さ規則が必要です

Appleは.basedOnSizeをバウンス設定として、fixedSize(horizontal:vertical:)を選択した寸法においてビューを理想サイズに固定するものとして説明しています。どちらも、コンテンツからビューポートの高さを決めるものではありません。スクロールのバウンス動作、軸ごとの固定サイズ。

スクロール高さに上限を設けたコンパクトなパネルを作る

単一値のonGeometryChange(for:of:action:)オーバーロードを使えるmacOS SDKを備えた、現行バージョンのXcodeを使用します。Appleの現在のドキュメントでは、このオーバーロードはmacOS 13.0以降で利用可能とされています。以下の完全な例では、macOS 13.3で利用可能になったscrollBounceBehavior(_:axes:)も使用するため、macOS 13.3以降を対象とします。ジオメトリ変更コールバック、バウンス修飾子の利用可能バージョン。

SwiftUIを使用するmacOSアプリのプロジェクトを作成します。生成されたアプリ宣言とコンテンツビューを、次の単一ファイルのプログラムに置き換え、ターゲット内の@main宣言は1つだけにします。ラベルにはすべて、ローカルで作成したサンプル文字列を使っています。Show more labelsトグルで、短いコンテンツと長いコンテンツを切り替えられます。

import SwiftUI

@main
struct CompactLabelsApp: App {
    var body: some Scene {
        WindowGroup {
            CompactLabelsView()
        }
    }
}

struct CompactLabelsView: View {
    @State private var showMore = false
    @State private var viewportHeight: CGFloat = 240

    private var labels: [String] {
        if showMore {
            return (1...18).map { number in
                "Sample label \(number): a longer description that can wrap when the panel becomes narrower."
            }
        }
        return ["Sample label A", "Sample label B"]
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Show more labels", isOn: $showMore)

            ScrollView(.vertical) {
                VStack(alignment: .leading, spacing: 8) {
                    if labels.isEmpty {
                        Text("No labels to display")
                    } else {
                        ForEach(labels.indices, id: \.self) { index in
                            Text(labels[index])
                        }
                    }
                }
                .font(.body)
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(12)
                .onGeometryChange(for: CGFloat.self) { proxy in
                    min(proxy.size.height.rounded(.up), 240)
                } action: { newHeight in
                    viewportHeight = newHeight
                }
            }
            .frame(height: viewportHeight)
            .scrollBounceBehavior(.basedOnSize, axes: .vertical)
            .border(Color.secondary.opacity(0.4))

            Text("The label panel grows up to 240 points.")
                .font(.caption)
        }
        .padding(20)
        .frame(
            maxWidth: .infinity,
            maxHeight: .infinity,
            alignment: .topLeading
        )
        .frame(minWidth: 300, minHeight: 360)
    }
}

プログラムの内容は確認済みですが、この環境にはSwiftコンパイラもmacOSランタイムもないため、コンパイルや実行はしていません。既存のインターフェイスに組み込む前に、Xcodeでビルドし、以下の項目を確認してください。

測定をスクロールビューの内側で行う理由

ジオメトリコールバックは、パディングを適用した内側のスタックを監視します。外側のスクロールビューを測定すると、すでに設定したビューポートの高さが報告されます。これではコンテンツに必要な高さを調べるのではなく、循環するサイズ規則になってしまいます。

変換処理では、切り上げて上限を適用した高さだけを返します。Appleは、ジオメトリを必要な値に変換し、頻繁なジオメトリ変更による広範な更新を避けることを推奨しています。ここでは、スクロール位置の変化は計算に影響せず、コンテンツの高さが上限を超えても結果は240ポイントのままです。Appleのジオメトリ変更に関するガイダンス。

切り上げることで、測定したコンテンツよりわずかに低い高さを意図的に割り当てることを避けられます。初期値の240ポイントにより、測定結果が届く前も使えるビューポートが確保されます。これは測定したコンテンツの高さではなく、初期表示のための値です。コールバックが後からこの値を更新します。

幅と修飾子の順序が重要な理由

水平方向のサイズは柔軟に保ちます。例では.fixedSize(horizontal: false, vertical: true)を使い、ラベルが利用可能な幅を使いつつ、垂直方向は理想サイズを保てるようにしています。Appleは、理想寸法を固定するとビューが親の境界を超える場合があると警告しています。そのため、幅の狭いパネルで折り返すラベルに対し、両方の寸法を固定するのは適切な既定値ではありません。AppleのfixedSize(horizontal:vertical:)に関するドキュメント。

12ポイントのパディングは測定修飾子より前に適用するため、コンテンツの測定高さに含まれます。ボーダーは外側の高さを設定した後に適用され、ビューポートの境界を示します。この配置にすると、使われていない空間がスクロールパネルに属するのか、その周囲のウィンドウに属するのかを確認しやすくなります。

短いコンテンツ、折り返し、縮小を確認する

次の手順で、例の動作を確認し、アプリの実際のコンテナにパネルを配置した後も再度確認します。

  1. Show more labelsをオフにして起動します。ボーダーが2つのラベルとそのパディングを囲み、キャプションがすぐ下に表示されるはずです。
  2. Show more labelsをオンにします。パネルは240ポイントの上限まで拡大するはずです。スクロールして、最後のラベルまで表示できることを確認します。
  3. ウィンドウの幅を狭くしたり広くしたりします。ラベルが折り返されても、最後の行まで表示されるはずです。
  4. トグルを再びオフにします。パネルは短いコンテンツの高さに縮小するはずです。
  5. 短い配列を一時的に[]に変更します。明示的な表示するラベルはありませんというメッセージによって、コンテンツの高さが決まるはずです。
  6. 実際のパネルで使用するテキストスタイルと周囲のコントロールでも同様に確認します。

空の状態の表示は意図的なインターフェイス上の選択です。この例では、配列が空でもメッセージとパディングを表示します。ラベルがないときにパネル全体を取り除きたい場合は、ラベルがないときにスクロールビュー全体を条件付きで省略してください。これは、空でないパネルの高さを測定することとは別のプロダクト上の判断です。

周囲のサンプルウィンドウには最小高さが設定されています。このコードがサイズを調整するのはラベルのビューポートであり、ラベルを削除するたびにMacのウィンドウを縮小するよう要求するものではありません。キャプションの下に空間がある場合、それはボーダー付きスクロールビューの外側にある外部レイアウトに属します。

残る隙間や切れたラベルを調べる

パネルが高いままの場合は、まず測定修飾子の取り付け位置を確認してください。内部のパディングを適用した後のコンテンツスタックを監視する必要があります。次に、祖先ビューに明示的な高さや最小高さが指定され、パネルの周囲に空間が確保されていないかを調べます。Appleのドキュメントによると、フレームに指定した高さは固定寸法です。そのため、別の外側フレームによって、内側のビューポートが縮んでも空間が残ることがあります。SwiftUIのフレームサイズ設定。

コンテンツ自体が予想以上に高いままの場合は、測定対象のサブツリーから垂直方向への拡張を取り除きます。このような小さなラベルのパターンでは、垂直スペーサーや、最小高さがビューポートから決まるコンテンツフレームを避けてください。これらを使うと、コンテンツの高さがビューポートに依存し、サイズ規則に必要な独立した測定が損なわれます。

下部のパディングが切れている場合は、測定前にパディングが含まれていることを確認してください。組み込み後にテキストの折り返しが変わる場合は、新しい親ビューが実際にどの幅を割り当てているかを調べます。ある幅で測定した高さを、別の幅でも変わらない高さとして扱わないでください。

240ポイントの上限は、親インターフェイスに適した値にしてください。このサンプルでは、コントロール、パディング、キャプション、上限付きパネルを収められるだけの最小ウィンドウサイズを設定しています。より小さなコンテナでは、上限を下げるか、周囲のレイアウトを変える必要があります。この固定上限では、他のコントロールの下に残る空間を自動計算しません。

この方法は、あらかじめレイアウトしても問題ない少数のラベルに使用してください。AppleはLazyVStackを、画面上に描画する必要がある場合にのみ項目を作成するものと説明しています。そのため、内側のスタックを遅延スタックに置き換えても、コンテンツ全体を測定する方法がそのまま維持されるとは考えないでください。大規模なコレクションには、通常、安定したビューポートと遅延コンテンツを組み合わせる設計が適しています。AppleのLazyVStackに関するドキュメント。

AppKitとTextKitの測定はどんな場合に使いますか?

このSwiftUIのラベルパネルでは、描画されたSwiftUIサブツリーを測定します。AppKitでテキストを測定すると、表示するラベルと一致するよう、別のレイアウト設定も管理する必要が生じます。

アプリがすでにNSScrollViewを所有している場合は、ドキュメントとビューポートの区別をそのまま保ってください。AppleはdocumentViewをスクロール対象のビュー、contentSizeをスクロールビューのコンテンツビューのサイズと定義しています。名前に反して、contentSizeはドキュメント全体の測定値ではありません。AppKitのdocumentView、AppKitのcontentSize。

NSLayoutManagerを使った既存のTextKitレイアウトでは、usedRect(for:)はテキストコンテナ内ですでにレイアウトされたグリフの外接矩形を返します。Appleは、これを問い合わせてもグリフは生成されず、レイアウトも実行されないと明記しています。テキストコンテナ全体のレイアウトが必要な場合、事前にレイアウトを強制するためにドキュメントで指定されているメソッドはensureLayout(for:)です。TextKitのusedRect(for:)、TextKitのensureLayout(for:)。

それでも、グリフの矩形はテキストビュー全体のフレームではありません。Appleのドキュメントによると、textContainerInsetによってテキストコンテナの周囲に余白が加わり、コンテナ内には行フラグメントのパディングもあります。グリフの高さをそのままスクロールビューポートに適用せず、実際のテキストビューの設定を考慮してください。これらのメソッドはNSLayoutManagerを使う方法の説明であり、あらゆるテキストコンポーネントに使える汎用的なサイズ設定方法ではありません。AppKitのtextContainerInset。

小さなSwiftUIのラベルパネルに適した実用的な解決策は、幅を柔軟に保ち、パディングを含めた内側のコンテンツを測定し、外側のビューポートの高さに上限を設けることです。短い場合と長い場合の両方で結果を確認し、実際の親レイアウトに配置して、周囲の制約でも同じコンパクトな動作が保たれることを確認してください。

参考資料