NSTableView 셀 안에서 SwiftUI 텍스트가 줄바꿈되도록 하려면 호스팅 뷰에 명확한 너비를 지정하고, Auto Layout으로 높이를 셀에 연결한 다음, 자동 행 높이를 활성화하세요. 이 안내에서는 이러한 구성을 사용해 간단한 Mac 레이블 목록을 만듭니다.
목표는 명확합니다. 앱에서 길이가 서로 다른 합성 레이블을 표시하고, 사용 가능한 너비가 바뀌어도 콘텐츠가 모두 보이도록 행 높이를 충분히 확보하는 것입니다. 이 예제는 macOS 13 이상을 대상으로 하며 뷰 기반 테이블을 사용합니다.
SwiftUI와 NSTableView는 크기 조정을 어떻게 나눠 맡나요?
세 계층이 관여합니다. 테이블은 셀의 사각형 영역을 할당합니다. Auto Layout은 셀을 포함된 NSHostingView에 연결합니다. SwiftUI는 제안된 공간 안에 레이블을 어떻게 배치할지 결정합니다. Apple은 NSHostingView를 레이아웃 중 SwiftUI 콘텐츠 크기 기본 설정을 AppKit에 전달하는 다리로 설명합니다. NSHostingView
텍스트를 줄바꿈하려면 높이를 따지기 전에 너비를 정해야 합니다. 넓은 열에서는 한 줄이면 되는 레이블도 좁은 열에서는 여러 줄이 필요할 수 있습니다. 호스팅 뷰의 초기 프레임을 설정해도 테이블 셀의 레이아웃이 계속 유지되지는 않습니다. Apple 문서에 따르면 테이블은 셀 대리자가 반환한 뷰의 프레임을 설정합니다. tableView(_:viewFor:row:)
따라서 아래 구현은 하나의 크기 조정 구성을 따릅니다. 테이블이 가로 공간을 제공하고, 호스트는 가로 여백을 두고 셀 가장자리에 제약되며, SwiftUI 레이블은 제안된 너비를 받아들이면서 이상적인 세로 크기를 유지합니다. usesAutomaticRowHeights이 활성화되면 테이블은 Auto Layout을 사용해 행 높이를 계산합니다. usesAutomaticRowHeights
intrinsicContentSize과 fittingSize을 서로 바꿔 쓸 수 있는 측정값으로 여기지 마세요. Apple은 fittingSize을 뷰와 그 하위 뷰의 제약 조건을 만족하는 최소 크기로 정의합니다. 따라서 의도한 너비 제약 없이 이를 조회해도 현재 열 너비에서 셀에 필요한 높이를 알 수 없습니다. fittingSize
macOS 13 이상에서 완전한 레이블 테이블 만들기
Xcode에서 SwiftUI 인터페이스를 사용하는 macOS 앱 프로젝트를 만들고 배포 대상은 macOS 13 이상으로 설정하세요. 생성된 앱 선언을 아래 첫 번째 파일로 바꾼 다음, 나머지 두 Swift 파일을 같은 앱 타깃에 추가하세요. 이 세 파일은 하나의 완전한 프로그램을 구성합니다. @main 선언은 하나만 남기세요.
macOS 13이 필요한 이유는 NSHostingView.sizingOptions를 명시적으로 사용하기 때문입니다. 호스팅 뷰 자체는 macOS 10.15부터 사용할 수 있고, 자동 테이블 행 높이는 macOS 10.13부터 사용할 수 있습니다. 더 낮은 배포 대상을 지원하려면 sizingOptions 주변에 가용성 검사를 추가하거나 이 설정을 생략한 구현을 사용해야 합니다. NSHostingView, sizingOptions, usesAutomaticRowHeights
앱과 합성 샘플 데이터 만들기
첫 번째 파일인 LabelSizingApp.swift는 레이블 세 개와 마지막 레이블을 바꾸는 버튼을 제공합니다. 이를 통해 초기 레이아웃과 콘텐츠 업데이트를 모두 확인할 수 있습니다.
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)
}
}
네 가장자리 제약 조건이 호스트와 셀을 연결합니다. 포함된 호스트의 자동 크기 조정 마스크 변환을 비활성화하면 명시적 제약 조건이 호스트를 제어합니다. 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:)
샘플은 입력 배열이 바뀔 때만 다시 로드합니다. 행이 세 개라면 전체 다시 로드가 이해하기 쉽지만, Apple 문서에 따르면 이 작업은 화면에 보이는 행과 셀 뷰를 버리고 다시 가져옵니다. 규모가 더 큰 앱에서는 적절한 경우 더 좁은 범위의 업데이트 방식을 선택하세요. AppKit 뷰 생성과 업데이트는 메인 액터에서 수행하세요. Swift는 MainActor가 메인 디스패치 큐와 동등한 실행기를 사용한다고 설명합니다. reloadData(), MainActor
잘림이나 지나치게 높은 행은 어떻게 진단하나요?
먼저 너비를 확인하고, 다음으로 높이 전파를 살핀 뒤, 마지막으로 업데이트를 점검하세요. 이 순서는 레이아웃의 의존 관계를 따르므로 서로 관련 없는 설정을 한꺼번에 바꾸지 않아도 됩니다.
| 증상 | 먼저 확인할 사항 | 유용한 수정 방법 |
|---|---|---|
| 긴 텍스트가 셀 너비를 넘어 가로로 이어짐 | 가로 방향의 이상적 크기 조정이 활성화되어 있나요? | 이 줄바꿈 레이블에는 fixedSize(horizontal: false, vertical: true)을 사용하세요. |
| 텍스트는 줄바꿈되지만 마지막 줄이 잘림 | 호스트가 셀의 세로 가장자리 양쪽에 연결되어 있나요? | 위쪽과 아래쪽 제약 조건을 복원하고 충돌하는 고정 높이를 제거하세요. |
| 모든 행의 높이가 계속 같음 | 자동 크기 조정이 활성화되어 있나요? 수동 높이 구현이 있나요? | 테이블 설정을 확인하고 일관된 크기 조정 전략 하나를 사용하세요. |
| 짧은 텍스트가 불필요하게 높은 셀에 표시됨 | 콘텐츠에 고정 높이나 추가 세로 공간이 있나요? | 테이블을 조정하기 전에 SwiftUI 계층 구조와 패딩을 살펴보세요. |
| 레이블이 바뀐 뒤에도 이전 콘텐츠가 표시됨 | 모델이 변경된 뒤 셀이 다시 구성되나요? | 표시된 루트 뷰를 업데이트하거나 영향을 받은 콘텐츠를 다시 로드하세요. |
너비 예산을 사용하면 구체적으로 점검할 수 있습니다. 셀 너비가 예를 들어 320포인트라면, 이 예제의 가로 여백 12포인트씩 두 개를 제외하고 호스트에 296포인트가 남습니다. 세로 패딩은 총 16포인트를 더합니다. 필요한 나머지 높이는 실제 텍스트 레이아웃에 따라 달라집니다. 이 숫자만으로 특정 레이블이 몇 줄을 차지할지는 예측할 수 없습니다.
확장 SwiftUI 프레임은 사용 가능한 가로 공간 안에서 선행 정렬을 설정합니다. 물리적으로 무제한인 너비를 지정하는 것은 아닙니다. Apple의 프레임 문서에 따르면 프레임은 부모가 제안하는 크기에 반응하고 지정된 크기 제약 조건을 적용합니다. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
긴 레이블이 있는 샘플을 넓은 창과 좁은 창에서 실행한 다음, 짧은 레이블로 다시 전환해 보세요. 모든 텍스트가 계속 표시되고 행 높이가 다시 줄어드는지 확인하세요. 이는 지원할 macOS 버전에서 수행해야 할 수용 기준 점검입니다. 이 환경에는 Swift 컴파일러나 macOS 런타임이 없어 코드를 컴파일하거나 실행하지 않았으며, 가져온 API 문서를 바탕으로 검토했습니다.
행 높이를 수동으로 계산하면 무엇이 달라지나요?
수동 크기 조정은 별도의 구현 방식입니다. 이 방식이 필요하다면 높이 대리자 바깥에서 의도한 콘텐츠 너비에 맞춰 높이를 계산하고, 대리자는 준비된 값을 반환하도록 하세요. 유용한 캐시 키에는 항목, 사용 가능한 너비, 글꼴이나 패딩처럼 레이아웃을 바꾸는 표시 입력값이 포함됩니다.
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을 통한 크기 조정 방식을 유지한 다음, 앱이 지원하는 너비에서 줄바꿈, 콘텐츠 변경, 행 높이 축소를 확인하세요.
출처
- 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