NSTableViewセル内でSwiftUIのテキストを折り返すには、ホスティングビューに明確な幅を与え、Auto Layoutでセルに対する高さを設定し、自動行高を有効にします。この手順では、その構成で小さなMac用ラベル一覧を作成します。

ここで扱うのは、特定の課題です。幅が変わっても内容を表示できる高さを保つ行で、長さの異なるサンプルラベルを自分のアプリに表示します。この例の対象はmacOS 13以降で、ビュー形式のテーブルを使います。

SwiftUIとNSTableViewはどのようにサイズを共有するのか?

サイズ決定には3つの層が関わります。テーブルがセルの矩形領域を割り当てます。Auto Layoutがセルと、埋め込まれたNSHostingViewを接続します。SwiftUIは、提案された領域にラベルをどのように収めるかを決めます。Appleによると、NSHostingViewはレイアウト中にSwiftUIコンテンツのサイズ設定の希望をAppKitに伝える橋渡し役です。NSHostingView

テキストの折り返しでは、高さを決める前に幅を確定させる必要があります。広い列では1行で済むラベルも、狭い列では複数行になることがあります。ホスティングビューの初期フレームを設定しても、テーブルセルのレイアウトがその後も維持されるとは限りません。Appleのドキュメントでは、テーブルがセルのデリゲートから返されたビューのフレームを設定すると説明されています。tableView(_:viewFor:row:)

そのため、以下の実装では一貫したサイズ設定を採用します。テーブルが横方向の領域を用意し、ホストを左右のインセット付きでセルの端に制約し、SwiftUIのラベルは提案された幅を受け入れつつ、理想的な縦サイズを保ちます。usesAutomaticRowHeightsが有効なら、テーブルはAuto Layoutを使って行の高さを計算します。usesAutomaticRowHeights

intrinsicContentSizeとfittingSizeを同じ測定値として扱わないでください。AppleはfittingSizeを、ビュー自身とサブビューの制約を満たす最小サイズと定義しています。したがって、意図した幅の制約なしにこれを問い合わせても、現在の列幅でセルをどれだけ高くするべきかは分かりません。fittingSize

macOS 13以降向けのラベルテーブルを作成する

XcodeでSwiftUIインターフェースを使ったmacOS Appプロジェクトを作成し、デプロイメントターゲットをmacOS 13以降に設定します。生成されたアプリ宣言を最初のファイルの内容に置き換え、残りの2つのSwiftファイルを同じアプリターゲットに追加します。この3つのファイルを合わせると、完全なプログラムになります。@main宣言は1つだけにしてください。

macOS 13が必要なのは、NSHostingView.sizingOptionsを明示的に使うためです。ホスティングビュー自体はmacOS 10.15から利用でき、自動テーブル行高はmacOS 10.13から利用できます。より古いデプロイメントターゲットでは、sizingOptionsの周囲に利用可能性チェックを追加するか、このカスタマイズを省いた実装が必要です。NSHostingView、sizingOptions、usesAutomaticRowHeights

アプリとサンプルデータを作成する

最初のファイルLabelSizingApp.swiftでは、3つのラベルと、最後のラベルを変更するボタンを用意します。これにより、初期レイアウトとコンテンツ更新の両方を確認できます。

import SwiftUI

@main
struct LabelSizingApp: App {
    @State private var expanded = false

    private var labels: [String] {
        [
            "Amber tile",
            "Blue tile with a longer descriptive label that can wrap.",
            expanded
                ? "Green tile with several descriptive words. This longer label makes it easier to inspect wrapping when the window becomes narrower."
                : "Green tile"
        ]
    }

    var body: some Scene {
        WindowGroup {
            VStack(alignment: .leading, spacing: 12) {
                Button("Toggle longer label") {
                    expanded.toggle()
                }
                LabelTable(labels: labels)
            }
            .padding()
            .frame(minWidth: 300, minHeight: 240)
        }
    }
}

ホスティングビューをセルに接続する

HostedLabelCell.swiftでは、SwiftUIコンテンツが利用可能な幅を受け入れ、理想的な高さを要求します。軸を指定するfixedSizeモディファイアが重要です。水平方向の軸を固定すると、テキストは理想的な幅を保とうとするため、親ビューの境界を超えることがあります。fixedSize(horizontal:vertical:)

import AppKit
import SwiftUI

struct RowLabel: View {
    let text: String

    var body: some View {
        Text(verbatim: text)
            .font(.system(size: 14))
            .fixedSize(horizontal: false, vertical: true)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.vertical, 8)
    }
}

@MainActor
final class HostedLabelCell: NSTableCellView {
    private let host = NSHostingView(
        rootView: RowLabel(text: "")
    )

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)

        host.translatesAutoresizingMaskIntoConstraints = false
        host.sizingOptions = [.intrinsicContentSize]
        addSubview(host)

        NSLayoutConstraint.activate([
            host.leadingAnchor.constraint(
                equalTo: leadingAnchor, constant: 12
            ),
            host.trailingAnchor.constraint(
                equalTo: trailingAnchor, constant: -12
            ),
            host.topAnchor.constraint(equalTo: topAnchor),
            host.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("This sample creates cells programmatically.")
    }

    func configure(text: String) {
        host.rootView = RowLabel(text: text)
    }
}

4つのエッジ制約によって、ホストとセルが接続されます。埋め込みホストで自動サイズ変更マスクの制約変換を無効にすると、明示的な制約がホストを制御できます。これは、制約に動的な調整が必要なビューについてAppleが推奨している方法です。外側のセルの配置は、引き続きテーブルが担当します。translatesAutoresizingMaskIntoConstraints

ここでは、sizingOptionsによって、ホストの最小サイズおよび最大サイズの制約を要求せずに、固有コンテンツサイズに基づくサイズ設定を維持します。Appleのドキュメントでは、これらは個別に選択できる動作とされています。このプロパティに空のオプションセットを設定すると、コンテンツ由来の制約は作成されません。固有コンテンツサイズに基づく設定を維持するのは、このコンテンツ主導のセルに対する実装上の選択であり、すべてのホスティングビューに共通する設定ではありません。sizingOptions

自動行高を有効にして再利用セルを設定する

スクロールビュー、テーブル、デリゲートを作成するため、LabelTable.swiftを追加します。このプログラムでは、手動の行高デリゲートを実装せずに自動サイズ設定を使用します。

import AppKit
import SwiftUI

@MainActor
struct LabelTable: NSViewRepresentable {
    let labels: [String]

    func makeCoordinator() -> Coordinator {
        Coordinator(labels: labels)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let table = NSTableView()
        let column = NSTableColumn(
            identifier: Coordinator.cellID
        )
        column.title = "Label"
        column.width = 360
        column.minWidth = 120
        column.resizingMask = .autoresizingMask
        table.addTableColumn(column)

        table.headerView = nil
        table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
        table.intercellSpacing = NSSize(width: 0, height: 0)
        table.usesAutomaticRowHeights = true
        table.dataSource = context.coordinator
        table.delegate = context.coordinator

        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.documentView = table
        return scrollView
    }

    func updateNSView(
        _ scrollView: NSScrollView,
        context: Context
    ) {
        guard let table = scrollView.documentView as? NSTableView,
              context.coordinator.labels != labels else {
            return
        }

        context.coordinator.labels = labels
        table.reloadData()
    }

    @MainActor
    final class Coordinator: NSObject,
        NSTableViewDataSource, NSTableViewDelegate {
        static let cellID = NSUserInterfaceItemIdentifier("LabelCell")
        var labels: [String]

        init(labels: [String]) {
            self.labels = labels
            super.init()
        }

        func numberOfRows(in tableView: NSTableView) -> Int {
            labels.count
        }

        func tableView(
            _ tableView: NSTableView,
            viewFor tableColumn: NSTableColumn?,
            row: Int
        ) -> NSView? {
            let cell = (
                tableView.makeView(
                    withIdentifier: Self.cellID,
                    owner: self
                ) as? HostedLabelCell
            ) ?? HostedLabelCell(frame: .zero)

            cell.identifier = Self.cellID
            cell.configure(text: labels[row])
            return cell
        }
    }
}

デリゲートはセルを新たに作成する前に再利用を試し、その後、毎回現在のラベルを割り当てます。これは、セルビューの作成順序に関するAppleの推奨に沿っています。makeView(withIdentifier:owner:)は再利用されたビューを返すことも、一致するビューがない場合にnilを返すこともあります。tableView(_:viewFor:row:)、makeView(withIdentifier:owner:)

サンプルでは入力配列が変わったときだけ再読み込みします。3行の例では全体を再読み込みする方法が分かりやすいものの、Appleのドキュメントによると、再読み込みでは表示中の行ビューとセルビューが破棄され、再取得されます。より大きなアプリケーションでは、適切により限定的な更新方法を選んでください。AppKitビューの作成と更新はメインアクター上で行います。Swiftのドキュメントでは、MainActorはメインディスパッチキューと同等のエグゼキューターを使うと説明されています。reloadData()、MainActor

クリッピングや行高の過剰をどう診断するか?

まず幅を確認し、次に高さの伝播、最後に更新を調べます。この順序はレイアウト上の依存関係に沿っており、互いに関係のない設定を同時に変更せずに済みます。

症状 最初に確認すること 有効な修正方法
長いテキストがセルの横にはみ出す 水平方向の理想サイズが有効になっていないか? この折り返しラベルにはfixedSize(horizontal: false, vertical: true)を使用します。
テキストは折り返すが、最後の行がクリップされる ホストはセルの上下両端に接続されているか? 上下の制約を元に戻し、競合する固定高を削除します。
どの行も同じ高さのまま 自動サイズ設定が有効か、手動の高さ実装があるか? テーブルの設定を確認し、意図したサイズ設定方法を1つ使います。
短いテキストのセルが不必要に高い コンテンツに固定高や余分な縦方向のスペースがないか? テーブルを調整する前に、SwiftUIのビュー階層とパディングを確認します。
ラベルを変更しても古い内容が表示される モデルの変更後にセルが再設定されているか? 表示中のルートビューを更新するか、該当する内容を再読み込みします。

幅の見積もりを使うと、確認内容が具体的になります。セル幅が例として320ポイントなら、左右に12ポイントずつあるインセットを引くと、ホストに使える幅は296ポイントです。縦方向のパディングは合計16ポイントです。残りの必要な高さは実際のテキストレイアウトによって決まります。これらの数値から、特定のラベルが何行になるかは予測できません。

SwiftUIの拡張フレームは、利用可能な横方向の領域内で先頭揃えを設定します。物理的に無制限の幅を割り当てるわけではありません。Appleのフレームに関するドキュメントでは、フレームは親からのサイズ提案に関わり、指定されたサイズ制約を適用すると説明されています。frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)

長いラベルを表示して、ウィンドウ幅を広くした場合と狭くした場合の両方でサンプルを実行し、その後、短いラベルに戻します。すべてのテキストが表示され、行の高さを再び小さくできることを確認してください。これらは、対応するmacOSバージョンで実施する受け入れ確認です。取得したAPIドキュメントと照合してコードを確認しましたが、この環境にはSwiftコンパイラもmacOSランタイムもないため、コンパイルや実行はしていません。

行の高さを手動で計算すると何が変わるか?

手動サイズ設定は、別の実装方法です。これが必要な場合は、高さデリゲートの外で対象コンテンツ幅に対する高さを計算し、デリゲートから準備済みの結果を返します。キャッシュキーには、項目、利用可能な幅、フォントやパディングなどレイアウトに影響する表示設定を含めるとよいでしょう。

Appleは、手動で返す行高を0より大きくし、セル間スペースを含めないよう求めています。また、tableView(_:heightOfRow:)内でセルや行のビューを取得することを禁止し、再帰的なテーブルレイアウトを引き起こす可能性のあるジオメトリ照会を避けるよう警告しています。そのため、このコールバック内で表示中のセルを測定する方法は、手動実装の基盤として適していません。tableView(_:heightOfRow:)

準備した高さが変わったら、noteHeightOfRows(withIndexesChanged:)でテーブルに通知します。Appleのドキュメントによると、この通知は、デリゲートメソッドが実装されている場合、デリゲートの高さを使ってテーブルをただちに再配置します。列幅が変わった場合も、幅に依存する測定値を無効化する必要があります。その変更を示す通知として、columnDidResizeNotificationがドキュメントに記載されています。自動方式を手動キャッシュに置き換える場合は、これらを考慮する必要があります。noteHeightOfRows(withIndexesChanged:)、columnDidResizeNotification

SwiftUIラベルの測定にTextKitを使うべきか?

TextKitは、アプリが実際にTextKitコンテナを通じてテキストをレイアウトする場合に役立ちます。ドキュメントに記載された測定範囲は、SwiftUIセル全体よりも限定的です。NSLayoutManager.usedRect(for:)は、指定されたコンテナですでにレイアウトされたグリフの境界を返すもので、グリフの生成やレイアウトそのものは行いません。レイアウトがまだ完了していない場合は、その測定値を読む前にensureLayout(for:)を呼び出してください。usedRect(for:)、ensureLayout(for:)

コンテナの設定も考慮してください。Appleのドキュメントでは、lineFragmentPaddingのデフォルトは行フラグメントの両端それぞれ5ポイントであり、利用可能なレイアウト幅に影響すると説明されています。また、このプロパティはテキストのマージンを表す目的ではないとも記されています。lineFragmentPadding

つまり、別に設定したTextKitによる測定だけでは、このSwiftUI階層の高さを決められません。セルにはSwiftUIのレイアウト動作とパディングも含まれます。このラベルテーブルでは、ホスティングビューとAuto Layoutを通じたサイズ設定を維持し、アプリが対応する幅で折り返し、内容の変更、縮小を確認してください。

参照資料