Om du vill att SwiftUI-text ska radbrytas i en NSTableView-cell ger du värdvyn en bestämd bredd, kopplar dess höjd till cellen med Auto Layout och aktiverar automatiska radhöjder. Den här genomgången bygger en liten lista med Mac-etiketter med den lösningen.

Uppgiften är avgränsad: visa syntetiska etiketter av olika längd i din egen app, med rader som är tillräckligt höga för att visa innehållet när den tillgängliga bredden ändras. Exemplet riktar sig till macOS 13 eller senare och använder en vybaserad tabell.

Hur delar SwiftUI och NSTableView på storleksberäkningen?

Tre lager samverkar. Tabellen tilldelar cellen en rektangel. Auto Layout kopplar cellen till dess inbäddade NSHostingView. SwiftUI avgör hur etiketten får plats inom det utrymme som föreslås. Apple beskriver NSHostingView som bryggan som rapporterar SwiftUI-innehållets storlekspreferenser till AppKit under layoutberäkningen. NSHostingView

För att text ska radbrytas måste bredden fastställas innan höjden blir meningsfull. En etikett kan behöva en rad i en bred kolumn och flera rader i en smal. Att ange en initial ram för en värdvy skapar ingen bestående layout för tabellcellen: Apple dokumenterar att tabellen anger ramen för vyn som cellens delegat returnerar. tableView(_:viewFor:row:)

Implementeringen nedan följer därför en enda storlekslösning: tabellen tillhandahåller det horisontella utrymmet, värdvyn begränsas till cellens kanter med horisontella inlägg och SwiftUI-etiketten behåller sin ideala vertikala storlek samtidigt som den accepterar den föreslagna bredden. Tabellen använder Auto Layout för att beräkna radhöjder när usesAutomaticRowHeights är aktiverat. usesAutomaticRowHeights

Behandla inte intrinsicContentSize och fittingSize som utbytbara mått. Apple definierar fittingSize som den minsta storlek som uppfyller vyns begränsningar och dess undervyrors begränsningar. Därför besvarar en avläsning utan de avsedda breddbegränsningarna inte frågan om hur hög cellen ska vara vid den aktuella kolumnbredden. fittingSize

Skapa en komplett etiketttabell för macOS 13 och senare

Skapa ett macOS-approjekt i Xcode med SwiftUI-gränssnittet och ett distributionsmål på macOS 13 eller senare. Ersätt den genererade appdeklarationen med den första filen nedan och lägg sedan till de två andra Swift-filerna i samma appmål. Tillsammans utgör de här tre filerna ett komplett program; behåll bara en @main-deklaration.

Kravet på macOS 13 beror på den uttryckliga användningen av NSHostingView.sizingOptions. Själva värdvyn finns från macOS 10.15, och automatiska radhöjder i tabeller finns från macOS 10.13. Ett äldre distributionsmål kräver en tillgänglighetskontroll runt sizingOptions eller en version av implementeringen som utelämnar den anpassningen. NSHostingView, sizingOptions, usesAutomaticRowHeights

Skapa appen och syntetiska exempeldata

Den första filen, LabelSizingApp.swift, tillhandahåller tre etiketter och en knapp som ändrar den sista etiketten. Då kan du kontrollera både den initiala layouten och en innehållsuppdatering.

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)
        }
    }
}

Koppla värdvyn till cellen

I HostedLabelCell.swift accepterar SwiftUI-innehållet den tillgängliga bredden och begär sin ideala höjd. Den axelspecifika modifieraren fixedSize är viktig: om den horisontella axeln låses ber texten om att behålla sin ideala bredd, vilket kan få den att överskrida förälderns gränser. 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)
    }
}

De fyra kantbegränsningarna kopplar värdvyn till cellen. Om du inaktiverar översättning av storleksändringsmasken för den inbäddade värdvyn styrs den av de uttryckliga begränsningarna, vilket Apple rekommenderar för vyer vars begränsningar kräver dynamisk justering. Tabellen ansvarar fortfarande för att placera den yttre cellen. translatesAutoresizingMaskIntoConstraints

Här behåller sizingOptions storleksberäkningen utifrån det inneboende innehållet utan att begära värdvyernas minsta och största storleksbegränsningar. Apple dokumenterar dessa som separat valbara beteenden; om egenskapen anges till en tom alternativuppsättning skapas inga sådana innehållsbaserade begränsningar. Att behålla storleksberäkningen utifrån det inneboende innehållet är ett implementeringsval för den här innehållsstyrda cellen, inte en universell inställning för alla värdvyer. sizingOptions

Aktivera automatiska radhöjder och konfigurera återanvända celler

Lägg till LabelTable.swift för att skapa rullningsvyn, tabellen och delegaten. Programmet använder medvetet automatisk storleksberäkning utan att implementera en delegat för manuell radhöjd.

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
        }
    }
}

Delegaten försöker återanvända en vy innan den skapar en cell och tilldelar sedan den aktuella etiketten varje gång. Det följer Apples rekommenderade ordning för att skapa cellvyer. makeView(withIdentifier:owner:) kan returnera en återanvänd vy eller returnera nil när ingen matchande vy finns tillgänglig. tableView(_:viewFor:row:), makeView(withIdentifier:owner:)

Exemplet läser bara in tabellen igen när indatamatrisen ändras. En fullständig omladdning är lätt att följa för tre rader, men Apple dokumenterar att synliga rad- och cellvyer då tas bort och hämtas på nytt. I en större app bör du välja en mer avgränsad uppdateringsstrategi när det passar. Skapa och uppdatera AppKit-vyer på huvudaktören; enligt Apples dokumentation använder MainActor en exekverare som motsvarar huvudkön för dispatch. reloadData(), MainActor

Hur kan du felsöka klippt innehåll eller för höga rader?

Börja med bredden, kontrollera sedan hur höjden förs vidare och granska därefter uppdateringar. Den ordningen följer beroendena i layouten och hjälper dig att undvika att ändra flera orelaterade inställningar samtidigt.

Symptom Kontrollera först Användbar korrigering
Lång text fortsätter horisontellt utanför cellen Är horisontell idealstorlek aktiverad? Använd fixedSize(horizontal: false, vertical: true) för den här radbrytande etiketten.
Texten radbryts, men de sista raderna klipps bort Är värdvyn kopplad till cellens båda vertikala kanter? Återställ de övre och nedre begränsningarna och ta bort motstridiga fasta höjder.
Alla rader har fortfarande samma höjd Är automatisk storleksberäkning aktiverad, och finns det en manuell höjdimplementering? Kontrollera tabellens konfiguration och använd en genomtänkt storleksstrategi.
Kort text visas i en onödigt hög cell Innehåller innehållet en fast höjd eller extra vertikalt utrymme? Granska SwiftUI-hierarkin och dess utfyllnad innan du justerar tabellen.
En ändrad etikett visar det gamla innehållet Konfigureras cellen igen efter att modellen har ändrats? Uppdatera den visade rotvyn eller läs in det berörda innehållet igen.

Använd en breddbudget för att göra granskningen konkret. Om cellen exempelvis är 320 punkter bred lämnar de två horisontella inläggen på 12 punkter i exemplet 296 punkter åt värdvyn. Den vertikala utfyllnaden bidrar med totalt 16 punkter. Den återstående nödvändiga höjden beror på den faktiska textlayouten; de siffrorna förutsäger inte hur många rader en viss etikett kommer att uppta.

Den expanderande SwiftUI-ramen skapar en vänsterjustering inom det tillgängliga horisontella utrymmet. Den tilldelar inte en obegränsad fysisk bredd: Apples dokumentation om ramar förklarar att ramen deltar i de förslag som kommer från föräldern och tillämpar de angivna storleksbegränsningarna. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)

Kör exemplet med den långa etiketten både i ett brett och ett smalt fönster och växla sedan tillbaka till den korta etiketten. Kontrollera att all text förblir synlig och att raden kan bli lägre igen. Det här är acceptanstester att utföra på de macOS-versioner du stöder. Koden granskades mot den hämtade API-dokumentationen; den kompilerades eller kördes inte här eftersom miljön saknar Swift-kompilator och macOS-körtid.

Vad ändras när du beräknar radhöjder manuellt?

Manuell storleksberäkning är en separat implementeringsväg. Om du behöver den beräknar du höjder för det avsedda innehållets bredd utanför höjddelegaten och låter delegaten returnera det förberedda resultatet. En användbar cache-nyckel innehåller objektet, den tillgängliga bredden och alla presentationsvärden som ändrar layouten, till exempel typsnitt eller utfyllnad.

Apple kräver att en manuellt returnerad radhöjd är större än noll och inte inkluderar mellanrummet mellan celler. Dokumentationen förbjuder också att cell- eller radvyer hämtas inuti tableView(_:heightOfRow:) och varnar för geometriförfrågningar som kan utlösa rekursiv tabellayout. Att mäta en synlig cell från det anropet är därför en dålig grund för en manuell implementering. tableView(_:heightOfRow:)

När de förberedda höjderna ändras meddelar du tabellen med noteHeightOfRows(withIndexesChanged:). Apple dokumenterar att detta omedelbart lägger om tabellen med delegatens höjder när den delegatmetoden har implementerats. Ändringar av kolumnbredd måste också ogiltigförklara breddberoende mätningar; columnDidResizeNotification är den dokumenterade notifieringen för den händelsen. Det här behöver beaktas om du ersätter den automatiska metoden med en manuell cache. noteHeightOfRows(withIndexesChanged:), columnDidResizeNotification

Bör TextKit mäta en SwiftUI-etikett?

TextKit är användbart när appen faktiskt lägger ut text via en TextKit-behållare. Dess dokumenterade mätning har ett snävare omfång än en hel SwiftUI-cell: NSLayoutManager.usedRect(for:) rapporterar gränserna för glyfer som redan har placerats i den angivna behållaren och genererar inte glyfer eller utför layout på egen hand. Anropa ensureLayout(for:) innan du läser av mätningen om layouten inte har slutförts. usedRect(for:), ensureLayout(for:)

Ta även hänsyn till behållarens konfiguration. Apple dokumenterar ett standardvärde på 5 punkter i varje ände av ett radfragment för lineFragmentPadding, vilket påverkar den tillgängliga layoutbredden. Apple anger också att den här egenskapen inte är avsedd för att uttrycka textmarginaler. lineFragmentPadding

Den praktiska slutsatsen är att en separat konfigurerad TextKit-mätning inte i sig kan fastställa höjden på den här SwiftUI-hierarkin. Cellen innehåller även SwiftUI-layoutbeteende och utfyllnad. För den här etiketttabellen behåller du storleksberäkningen genom värdvyn och Auto Layout och verifierar sedan radbrytning, innehållsändringar och minskning av höjden vid de bredder som appen stöder.

Källor