Aby tekst SwiftUI zawijał się w komórce NSTableView, nadaj widokowi hostującemu określoną szerokość, połącz jego wysokość z komórką za pomocą Auto Layout i włącz automatyczne wysokości wierszy. W tym przewodniku utworzymy niewielką listę etykiet na Maca z takim układem.
Zadanie jest konkretne: wyświetlić w aplikacji syntetyczne etykiety o różnej długości, z wierszami wystarczająco wysokimi, by pokazać ich zawartość przy zmieniającej się dostępnej szerokości. Przykład jest przeznaczony dla macOS 13 lub nowszego i korzysta z tabeli opartej na widokach.
Jak SwiftUI i NSTableView współdzielą ustalanie rozmiaru?
Biorą w nim udział trzy warstwy. Tabela przydziela prostokąt komórki. Auto Layout łączy komórkę z osadzonym w niej NSHostingView. SwiftUI ustala, jak etykieta mieści się w zaproponowanej przestrzeni. Apple opisuje NSHostingView jako most, który podczas układu przekazuje AppKit preferencje dotyczące rozmiaru zawartości SwiftUI. NSHostingView
W przypadku zawijanego tekstu szerokość trzeba ustalić, zanim wysokość będzie miała znaczenie. W szerokiej kolumnie etykieta może zajmować jeden wiersz, a w wąskiej kilka. Ustawienie początkowej ramki widoku hostującego nie określa trwałego układu komórki tabeli: według dokumentacji Apple tabela ustawia ramkę widoku zwracanego przez delegata komórki. tableView(_:viewFor:row:)
Dlatego poniższa implementacja stosuje jeden układ ustalania rozmiaru: tabela udostępnia przestrzeń w poziomie, widok hostujący jest ograniczony do krawędzi komórki z poziomymi odstępami, a etykieta SwiftUI zachowuje idealny rozmiar w pionie, przyjmując zaproponowaną szerokość. Tabela używa Auto Layout do obliczania wysokości wierszy, gdy włączona jest opcja usesAutomaticRowHeights. usesAutomaticRowHeights
Nie traktuj intrinsicContentSize i fittingSize jako wymiennych miar. Apple definiuje fittingSize jako najmniejszy rozmiar spełniający ograniczenia widoku i jego widoków podrzędnych. W związku z tym odczyt tej wartości bez zamierzonych ograniczeń szerokości nie odpowiada na pytanie, jak wysoka powinna być komórka przy aktualnej szerokości kolumny. fittingSize
Utwórz kompletną tabelę etykiet dla macOS 13 i nowszego
W Xcode utwórz projekt aplikacji macOS z interfejsem SwiftUI i docelową wersją macOS 13 lub nowszą. Zastąp wygenerowaną deklarację aplikacji pierwszym plikiem poniżej, a następnie dodaj dwa pozostałe pliki Swift do tego samego celu aplikacji. Te trzy pliki tworzą kompletny program; pozostaw tylko jedną deklarację @main.
Wymóg macOS 13 wynika z jawnego użycia NSHostingView.sizingOptions. Sam widok hostujący jest dostępny od macOS 10.15, a automatyczne wysokości wierszy tabeli od macOS 10.13. W przypadku starszej docelowej wersji systemu dodaj sprawdzenie dostępności wokół sizingOptions albo użyj wersji implementacji, która pomija tę konfigurację. NSHostingView, sizingOptions, usesAutomaticRowHeights
Utwórz aplikację i syntetyczne dane przykładowe
Pierwszy plik, LabelSizingApp.swift, udostępnia trzy etykiety i przycisk zmieniający ostatnią z nich. Dzięki temu możesz sprawdzić zarówno początkowy układ, jak i aktualizację zawartości.
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)
}
}
}
Połącz widok hostujący z komórką
W pliku HostedLabelCell.swift zawartość SwiftUI przyjmuje dostępną szerokość i żąda idealnej wysokości. Modyfikator fixedSize określający oś ma znaczenie: ustalenie osi poziomej sprawiłoby, że tekst miałby zachować idealną szerokość, przez co mógłby wykraczać poza granice widoku nadrzędnego. 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)
}
}
Cztery ograniczenia krawędzi łączą widok hostujący z komórką. Wyłączenie tłumaczenia maski zmiany rozmiaru na ograniczenia dla osadzonego widoku hostującego pozwala, by sterowały nim te jawne ograniczenia, zgodnie z zaleceniem Apple dla widoków, których ograniczenia wymagają dynamicznego dostosowania. Za pozycjonowanie zewnętrznej komórki nadal odpowiada tabela. translatesAutoresizingMaskIntoConstraints
Tutaj sizingOptions zachowuje ustalanie rozmiaru na podstawie zawartości wewnętrznej bez żądania minimalnych i maksymalnych ograniczeń rozmiaru widoku hostującego. Apple opisuje je jako osobno wybierane zachowania; ustawienie tej właściwości na pusty zestaw opcji nie tworzy żadnych takich ograniczeń wynikających z zawartości. Zachowanie ustalania rozmiaru na podstawie zawartości wewnętrznej to wybór implementacyjny dla tej komórki, której rozmiar zależy od zawartości, a nie uniwersalne ustawienie dla każdego widoku hostującego. sizingOptions
Włącz automatyczne wysokości wierszy i skonfiguruj ponownie używane komórki
Dodaj LabelTable.swift, aby utworzyć widok przewijania, tabelę i delegata. Program celowo korzysta z automatycznego ustalania rozmiaru i nie implementuje delegata ręcznie ustalającego wysokość wiersza.
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
}
}
}
Delegat najpierw próbuje ponownie użyć widoku, a jeśli to się nie uda, tworzy komórkę, po czym za każdym razem przypisuje aktualną etykietę. Jest to zgodne z zalecaną przez Apple kolejnością tworzenia widoków komórek. makeView(withIdentifier:owner:) może zwrócić ponownie używany widok albo nil, jeśli nie ma dostępnego pasującego widoku. tableView(_:viewFor:row:), makeView(withIdentifier:owner:)
Przykład ponownie wczytuje dane tylko wtedy, gdy zmienia się tablica wejściowa. Pełne wczytanie jest łatwe do zrozumienia przy trzech wierszach, ale według dokumentacji Apple powoduje odrzucenie i ponowne pobranie widocznych widoków wierszy i komórek. W większej aplikacji wybierz w razie potrzeby węższy zakres aktualizacji. Tworzenie i aktualizowanie widoków AppKit wykonuj na głównym aktorze; według dokumentacji Swift MainActor korzysta z wykonawcy równoważnego głównej kolejce dyspozytora. reloadData(), MainActor
Jak diagnozować przycinanie tekstu lub nadmierną wysokość wiersza?
Zacznij od szerokości, następnie sprawdź przekazywanie wysokości, a na końcu aktualizacje. Ta kolejność odpowiada zależnościom w układzie i pozwala uniknąć zmieniania kilku niezwiązanych ustawień naraz.
| Objaw | Co najpierw sprawdzić | Przydatna poprawka |
|---|---|---|
| Długi tekst wykracza w poziomie poza komórkę | Czy włączono idealny rozmiar w poziomie? | Dla tej zawijanej etykiety użyj fixedSize(horizontal: false, vertical: true). |
| Tekst się zawija, ale jego ostatnie wiersze są przycięte | Czy widok hostujący jest połączony z obiema pionowymi krawędziami komórki? | Przywróć górne i dolne ograniczenie oraz usuń kolidujące stałe wysokości. |
| Wszystkie wiersze mają wciąż tę samą wysokość | Czy włączono automatyczne ustalanie rozmiaru i czy zaimplementowano ręczne ustalanie wysokości? | Sprawdź konfigurację tabeli i zastosuj jedną przemyślaną strategię ustalania rozmiaru. |
| Krótki tekst znajduje się w niepotrzebnie wysokiej komórce | Czy zawartość ma ustaloną wysokość lub dodatkową przestrzeń w pionie? | Sprawdź hierarchię SwiftUI i jej wypełnienie, zanim zmienisz ustawienia tabeli. |
| Zmieniona etykieta wyświetla starą zawartość | Czy komórka jest ponownie konfigurowana po zmianie modelu? | Zaktualizuj wyświetlany widok główny lub wczytaj ponownie zmienioną zawartość. |
Użyj budżetu szerokości, aby konkretne pomiary ułatwiły sprawdzenie. Jeśli komórka ma przykładowo 320 punktów szerokości, dwa poziome odstępy po 12 punktów pozostawiają widokowi hostującemu 296 punktów. Wypełnienie w pionie wnosi łącznie 16 punktów. Wymagana pozostała wysokość zależy od rzeczywistego układu tekstu; te liczby nie pozwalają przewidzieć, ile wierszy zajmie konkretna etykieta.
Rozciągająca się ramka SwiftUI ustala wyrównanie do lewej strony w dostępnej przestrzeni poziomej. Nie przypisuje nieskończonej szerokości fizycznej: dokumentacja Apple dotycząca ramki wyjaśnia, że ramka uczestniczy w propozycjach rozmiaru od widoku nadrzędnego i stosuje określone ograniczenia rozmiaru. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
Uruchom przykład z długą etykietą przy szerokim i wąskim oknie, a następnie przełącz z powrotem na krótką etykietę. Sprawdź, czy cały tekst pozostaje widoczny i czy wiersz może ponownie się zmniejszyć. Te kryteria akceptacji należy sprawdzić w obsługiwanych wersjach macOS. Kod sprawdzono w odniesieniu do pobranej dokumentacji API; nie został tutaj skompilowany ani uruchomiony, ponieważ to środowisko nie ma kompilatora Swift ani środowiska wykonawczego macOS.
Co się zmienia przy ręcznym obliczaniu wysokości wierszy?
Ręczne ustalanie rozmiaru to odrębna ścieżka implementacji. Jeśli go potrzebujesz, oblicz wysokości dla zamierzonej szerokości zawartości poza delegatem wysokości, a delegatowi każ zwrócić przygotowany wynik. Przydatny klucz pamięci podręcznej obejmuje element, dostępną szerokość i wszelkie parametry prezentacji wpływające na układ, takie jak czcionka lub wypełnienie.
Apple wymaga, aby ręcznie zwracana wysokość wiersza była większa od zera i nie uwzględniała odstępów między komórkami. Dokumentacja zabrania też pobierania widoków komórek lub wierszy w tableView(_:heightOfRow:) i ostrzega przed zapytaniami o geometrię, które mogą wywołać rekurencyjne układanie tabeli. Mierzenie widocznej komórki w ramach tego wywołania zwrotnego jest więc kiepską podstawą ręcznej implementacji. tableView(_:heightOfRow:)
Gdy przygotowane wysokości się zmienią, powiadom tabelę za pomocą noteHeightOfRows(withIndexesChanged:). Dokumentacja Apple informuje, że natychmiast ponownie układa to tabelę z użyciem wysokości delegata, jeśli zaimplementowano tę metodę delegata. Zmiana rozmiaru kolumny również wymaga unieważnienia pomiarów zależnych od szerokości; columnDidResizeNotification to udokumentowane powiadomienie o tym zdarzeniu. Należy uwzględnić te kwestie, jeśli zastąpisz podejście automatyczne ręczną pamięcią podręczną. noteHeightOfRows(withIndexesChanged:), columnDidResizeNotification
Czy TextKit powinien mierzyć etykietę SwiftUI?
TextKit przydaje się, gdy aplikacja faktycznie układa tekst za pomocą kontenera TextKit. Udokumentowany pomiar ma węższy zakres niż cała komórka SwiftUI: NSLayoutManager.usedRect(for:) zwraca granice glifów już ułożonych w określonym kontenerze i sam nie generuje glifów ani nie wykonuje układu. Jeśli układ nie został jeszcze ukończony, przed odczytaniem tego pomiaru wywołaj ensureLayout(for:). usedRect(for:), ensureLayout(for:)
Uwzględnij też konfigurację kontenera. Według dokumentacji Apple domyślna wartość lineFragmentPadding wynosi 5 punktów na każdym końcu wiersza fragmentu tekstu, co wpływa na dostępną szerokość układu. Apple stwierdza także, że ta właściwość nie jest przeznaczona do określania marginesów tekstu. lineFragmentPadding
Z praktycznego punktu widzenia odrębnie skonfigurowany pomiar TextKit sam w sobie nie może określić wysokości tej hierarchii SwiftUI. Komórka obejmuje również zachowanie układu SwiftUI i wypełnienie. W przypadku tej tabeli etykiet utrzymaj ścieżkę ustalania rozmiaru przez widok hostujący i Auto Layout, a następnie sprawdź zawijanie tekstu, zmiany zawartości i zmniejszanie się wierszy przy szerokościach obsługiwanych przez aplikację.
Źródła
- NSHostingView — Apple
- sizingOptions — Apple
- usesAutomaticRowHeights — Apple
- tableView(_:viewFor:row:) — Apple
- fittingSize — Apple
- fixedSize(horizontal:vertical:) — Apple
- translatesAutoresizingMaskIntoConstraints — Apple
- makeView(withIdentifier:owner:) — Apple
- reloadData() — Apple
- MainActor — Apple
- frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) — Apple
- tableView(_:heightOfRow:) — Apple
- noteHeightOfRows(withIndexesChanged:) — Apple
- columnDidResizeNotification — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- lineFragmentPadding — Apple