Per fare in modo che il testo SwiftUI vada a capo all’interno di una cella NSTableView, assegna alla vista di hosting una larghezza definita, collegane l’altezza alla cella con Auto Layout e abilita l’altezza automatica delle righe. Questa guida realizza una piccola lista di etichette per Mac con questa configurazione.

Il compito è specifico: mostrare nella tua app etichette sintetiche di lunghezza diversa, con righe abbastanza alte da visualizzarne il contenuto mentre cambia la larghezza disponibile. L’esempio è destinato a macOS 13 o versioni successive e usa una tabella basata su viste.

Come condividono il dimensionamento SwiftUI e NSTableView?

Partecipano tre livelli. La tabella assegna il rettangolo della cella. Auto Layout collega la cella alla NSHostingView incorporata. SwiftUI determina come adattare l’etichetta allo spazio proposto. Apple descrive NSHostingView come il ponte che comunica ad AppKit le preferenze sulle dimensioni dei contenuti SwiftUI durante il layout. NSHostingView

Per il testo a capo, la larghezza deve essere stabilita prima che l’altezza abbia un significato. In una colonna larga, un’etichetta può stare su una riga; in una stretta, può richiederne diverse. Impostare il frame iniziale di una vista di hosting non definisce un layout stabile per la cella della tabella: Apple documenta che la tabella imposta il frame della vista restituita dal delegato della cella. tableView(_:viewFor:row:)

L’implementazione seguente adotta quindi una configurazione di dimensionamento precisa: la tabella fornisce lo spazio orizzontale, la vista di hosting viene vincolata ai bordi della cella con margini orizzontali e l’etichetta SwiftUI mantiene la propria dimensione verticale ideale accettando la larghezza proposta. La tabella usa Auto Layout per calcolare l’altezza delle righe quando è abilitato usesAutomaticRowHeights. usesAutomaticRowHeights

Non considerare intrinsicContentSize e fittingSize come misure intercambiabili. Apple definisce fittingSize come la dimensione minima che soddisfa i vincoli della vista e quelli delle sue sottoviste. Di conseguenza, richiederla senza i vincoli di larghezza previsti non chiarisce quanto debba essere alta la cella con la larghezza corrente della colonna. fittingSize

Crea una tabella completa di etichette per macOS 13 e versioni successive

Crea in Xcode un progetto App macOS con interfaccia SwiftUI e destinazione di distribuzione macOS 13 o successiva. Sostituisci la dichiarazione dell’app generata con il primo file qui sotto, quindi aggiungi gli altri due file Swift allo stesso target dell’app. Insieme, questi tre file formano un programma completo; mantieni una sola dichiarazione @main.

Il requisito di macOS 13 deriva dall’uso esplicito di NSHostingView.sizingOptions. La vista di hosting è disponibile da macOS 10.15 e l’altezza automatica delle righe delle tabelle da macOS 10.13. Per una destinazione di distribuzione più datata occorre un controllo di disponibilità attorno a sizingOptions oppure una versione dell’implementazione che ometta questa personalizzazione. NSHostingView, sizingOptions, usesAutomaticRowHeights

Crea l’app e i dati sintetici di esempio

Il primo file, LabelSizingApp.swift, fornisce tre etichette e un pulsante che modifica l’ultima. In questo modo puoi verificare sia il layout iniziale sia l’aggiornamento dei contenuti.

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

Collega la vista di hosting alla cella

In HostedLabelCell.swift, il contenuto SwiftUI accetta la larghezza disponibile e richiede la propria altezza ideale. Il modificatore fixedSize, applicato a un asse specifico, è importante: fissare l’asse orizzontale chiederebbe al testo di mantenere la propria larghezza ideale e potrebbe farlo uscire dai limiti del contenitore. 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)
    }
}

I quattro vincoli sui bordi collegano la vista di hosting alla cella. Disabilitare la traduzione delle maschere di ridimensionamento automatico sulla vista di hosting incorporata consente a questi vincoli espliciti di governarla, come consiglia Apple per le viste i cui vincoli devono adattarsi dinamicamente. La tabella resta responsabile del posizionamento della cella esterna. translatesAutoresizingMaskIntoConstraints

Qui, sizingOptions mantiene il dimensionamento basato sul contenuto intrinseco senza richiedere i vincoli di dimensione minima e massima della vista di hosting. Apple documenta questi comportamenti come selezionabili separatamente; impostando la proprietà su un insieme di opzioni vuoto non viene creato alcun vincolo derivato dal contenuto. Mantenere il dimensionamento basato sul contenuto intrinseco è una scelta implementativa per questa cella, il cui contenuto ne determina le dimensioni, non un’impostazione universale per ogni vista di hosting. sizingOptions

Abilita l’altezza automatica delle righe e configura le celle riutilizzate

Aggiungi LabelTable.swift per creare la vista di scorrimento, la tabella e il delegato. Il programma usa intenzionalmente il dimensionamento automatico senza implementare un delegato per l’altezza manuale delle righe.

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

Il delegato prova a riutilizzare una vista prima di crearne una cella, poi assegna ogni volta l’etichetta corrente. Segue così la sequenza di costruzione delle viste delle celle raccomandata da Apple. makeView(withIdentifier:owner:) può restituire una vista riutilizzata oppure nil se non è disponibile alcuna vista corrispondente. tableView(_:viewFor:row:), makeView(withIdentifier:owner:)

L’esempio ricarica la tabella solo quando cambia l’array di input. Un ricaricamento completo è facile da seguire per tre righe, ma Apple documenta che elimina e riacquisisce le viste delle righe e delle celle visibili. In un’applicazione più grande, scegli quando opportuno una strategia di aggiornamento più circoscritta. Mantieni la creazione e gli aggiornamenti delle viste AppKit sul main actor; Swift documenta che MainActor usa un executor equivalente alla coda di dispatch principale. reloadData(), MainActor

Come diagnosticare il ritaglio o un’altezza eccessiva delle righe?

Inizia dalla larghezza, poi verifica la propagazione dell’altezza e infine gli aggiornamenti. Questo ordine segue le dipendenze del layout e aiuta a evitare di modificare più impostazioni non correlate contemporaneamente.

Sintomo Primo controllo Correzione utile
Il testo lungo si estende orizzontalmente oltre la cella È abilitato il dimensionamento orizzontale ideale? Per questa etichetta con testo a capo, usa fixedSize(horizontal: false, vertical: true).
Il testo va a capo, ma le ultime righe vengono tagliate La vista di hosting è collegata a entrambi i bordi verticali della cella? Ripristina i vincoli superiore e inferiore e rimuovi le altezze fisse in conflitto.
Tutte le righe restano della stessa altezza Il dimensionamento automatico è abilitato ed è presente un’implementazione manuale dell’altezza? Controlla la configurazione della tabella e scegli una sola strategia di dimensionamento.
Il testo breve appare in una cella inutilmente alta Il contenuto ha un’altezza fissa o spazio verticale aggiuntivo? Esamina la gerarchia SwiftUI e i relativi margini prima di modificare la tabella.
Un’etichetta modificata mostra ancora il contenuto precedente La cella viene configurata di nuovo dopo la modifica del modello? Aggiorna la vista radice visualizzata oppure ricarica il contenuto interessato.

Usa un budget di larghezza per rendere concreta l’ispezione. Se, a titolo d’esempio, la cella è larga 320 punti, i due margini orizzontali da 12 punti dell’esempio lasciano 296 punti alla vista di hosting. Il margine verticale aggiunge 16 punti in totale. L’altezza restante dipende dal layout effettivo del testo; questi numeri non permettono di prevedere quante righe occuperà una determinata etichetta.

Il frame SwiftUI espansivo imposta l’allineamento a sinistra nello spazio orizzontale disponibile. Non assegna una larghezza fisica illimitata: la documentazione di Apple sui frame spiega che il frame partecipa alle proposte del contenitore e applica i vincoli di dimensione specificati. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)

Esegui l’esempio con l’etichetta lunga sia in una finestra larga sia in una stretta, poi ripristina l’etichetta breve con il pulsante. Verifica che tutto il testo resti visibile e che l’altezza della riga possa ridursi di nuovo. Questi sono controlli di accettazione da eseguire sulle versioni di macOS supportate. Il codice è stato confrontato con la documentazione API consultata, ma qui non è stato compilato né eseguito perché questo ambiente non dispone di un compilatore Swift o di un runtime macOS.

Cosa cambia se calcoli manualmente le altezze delle righe?

Il dimensionamento manuale è un percorso implementativo distinto. Se ti serve, calcola le altezze per la larghezza prevista del contenuto al di fuori del delegato dell’altezza e fai in modo che il delegato restituisca il risultato preparato. Una chiave di cache utile include l’elemento, la larghezza disponibile e gli eventuali parametri di presentazione che modificano il layout, come il font o i margini.

Apple richiede che l’altezza della riga restituita manualmente sia maggiore di zero e che escluda la spaziatura tra celle. La documentazione vieta inoltre di ottenere le viste delle celle o delle righe all’interno di tableView(_:heightOfRow:) e sconsiglia le richieste di geometria che possono innescare un layout ricorsivo della tabella. Misurare una cella visibile all’interno di quel callback è quindi una base poco adatta a un’implementazione manuale. tableView(_:heightOfRow:)

Quando cambiano le altezze preparate, notifica la tabella con noteHeightOfRows(withIndexesChanged:). Apple documenta che questo metodo ricalcola immediatamente il layout della tabella usando le altezze del delegato, se il metodo del delegato è implementato. Anche il ridimensionamento delle colonne deve invalidare le misure che dipendono dalla larghezza; columnDidResizeNotification è la notifica documentata per quell’evento. Sono aspetti necessari se sostituisci l’approccio automatico con una cache manuale. noteHeightOfRows(withIndexesChanged:), columnDidResizeNotification

TextKit dovrebbe misurare un’etichetta SwiftUI?

TextKit è utile quando la tua app dispone effettivamente il testo tramite un contenitore TextKit. La sua misura documentata ha un ambito più ristretto rispetto a un’intera cella SwiftUI: NSLayoutManager.usedRect(for:) restituisce i limiti dei glifi già disposti nel contenitore specificato e non genera glifi né esegue il layout. Se il layout non è stato completato, chiama ensureLayout(for:) prima di leggere questa misura. usedRect(for:), ensureLayout(for:)

Considera anche la configurazione del contenitore. Apple documenta un valore predefinito di lineFragmentPadding pari a 5 punti a ciascuna estremità di una riga del frammento di riga, che influisce sulla larghezza disponibile per il layout. Specifica inoltre che questa proprietà non è pensata per esprimere i margini del testo. lineFragmentPadding

In pratica, una misura TextKit configurata separatamente non può, da sola, determinare l’altezza di questa gerarchia SwiftUI. La cella include anche il comportamento di layout SwiftUI e i margini. Per questa tabella di etichette, mantieni il percorso di dimensionamento tramite la vista di hosting e Auto Layout, poi verifica l’andata a capo, le modifiche al contenuto e la riduzione dell’altezza alle larghezze supportate dalla tua app.

Fonti