인터랙티브 SwiftUI 프리뷰를 만들려면 각 프리뷰에 자체 로컬 상태를 두고, 관찰 가능한 뷰 모델을 에디터에 전달한 다음, 바인딩을 통해 편집 가능한 속성을 노출하세요. 이 가이드에서는 예시용 가상 텍스트를 사용해 작은 macOS 레이블 에디터를 만듭니다.
프리뷰 상태를 둘 위치 선택하기
이 작업은 사용자가 레이블의 제목과 세부 텍스트를 변경한 다음 세부 텍스트를 표시할지 전환하는 데스크톱 에디터를 프리뷰하는 것입니다. 에디터는 즉시 반응해야 하며, 각 프리뷰는 명시적인 예시 설정으로 시작해야 합니다.
상호작용을 지원하는 데 필요한 최소한의 입력을 사용하세요. Apple은 복잡한 종속성이 프리뷰 구성을 어렵게 만들 수 있으므로 뷰에 필요한 데이터만 전달할 것을 권장합니다. 정적 레이블에는 문자열을 전달하면 되고, 에디터에는 상태에 대한 쓰기 가능한 연결이 필요합니다. 인터페이스 파일에 프리뷰 추가하기.
| 뷰에 필요한 항목 | 적절한 입력 | 예시 상태를 둘 위치 |
|---|---|---|
| 고정 텍스트 표시 | 변경할 수 없는 문자열 | 프리뷰에 직접 지정 |
| 부모가 소유한 값 하나 편집 | @Binding 속성 |
프리뷰 호스트의 로컬 @State |
| 서로 연관된 관찰 가능 속성 여러 개 편집 | @Bindable로 노출한 관찰 가능 모델 |
프리뷰 호스트의 @State에 저장한 모델 인스턴스 |
| 환경을 통해 제공된 모델 읽기 | 환경 종속성 | 프리뷰 루트에서 명시적으로 주입 |
이 예제에서 뷰 모델은 두 개의 문자열, 표시 여부 설정, 계산된 표시 제목을 보유합니다. 각 프리뷰는 별도의 인스턴스를 소유합니다. 따라서 시작 조건이 프리뷰 선언 옆에 명확히 표시되며, 예시가 공유되는 가변 싱글턴에 의존하지 않습니다.
macOS SwiftUI 타깃 준비하기
여기서 보여 주는 인라인 @Previewable 구문을 사용하려면 Xcode 16 이상이 필요합니다. Apple은 Xcode 16에서 이 매크로를 도입했습니다. SwiftUI가 Observation을 지원하는 macOS 14 이상을 이 예제의 배포 타깃으로 설정하세요. Apple의 최신 Previewable() 참조 문서에도 macOS 14부터 사용할 수 있다고 나와 있습니다. Xcode 16 릴리스 노트, 앱의 모델 데이터 관리하기, Previewable().
기존 macOS SwiftUI 앱 타깃에 LabelEditor.swift라는 Swift 파일을 추가하세요. 다음 두 코드 블록이 합쳐져 완전한 프리뷰 예제를 이룹니다. 앱의 기존 진입점은 유지하세요. 이 파일은 모델, 에디터, 프리뷰를 정의합니다.
이 코드는 지원되는 도구 체인에서 @State 표기를 사용합니다. Apple의 최신 문서는 구현 방식의 차이를 설명합니다. Xcode 26 이하는 State 속성 래퍼를 사용하고, Xcode 27 이상은 State() 매크로를 사용합니다. 생성된 코드를 살펴보거나 초기화 비용을 검토할 때 이 차이를 염두에 두세요. State, State().
관찰 가능한 모델과 편집 가능한 뷰 만들기
인터페이스가 읽는 속성을 SwiftUI가 추적할 수 있도록 모델에 @Observable을 적용하세요. 에디터에서 @Bindable을 사용해 텍스트 필드와 토글에 필요한 속성 바인딩을 만드세요. Apple은 관찰 가능한 모델을 편집할 때 이 조합을 문서화하고 있습니다. 앱의 모델 데이터 관리하기, Bindable.
import Foundation
import Observation
import SwiftUI
@MainActor
@Observable
final class LabelViewModel {
var title: String
var detail: String
var showsDetail: Bool
init(
title: String,
detail: String,
showsDetail: Bool = true
) {
self.title = title
self.detail = detail
self.showsDetail = showsDetail
}
var displayedTitle: String {
let trimmed = title.trimmingCharacters(
in: .whitespacesAndNewlines
)
return trimmed.isEmpty ? "Untitled label" : trimmed
}
}
@MainActor
struct LabelEditor: View {
@Bindable var model: LabelViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Title")
TextField("Label title", text: $model.title)
}
VStack(alignment: .leading, spacing: 6) {
Text("Detail")
TextField("Label detail", text: $model.detail)
}
Toggle("Show detail", isOn: $model.showsDetail)
Divider()
Text("Rendered label")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
Text(model.displayedTitle)
.font(.title2)
.fixedSize(horizontal: false, vertical: true)
if model.showsDetail {
Text(model.detail)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(
.quaternary,
in: RoundedRectangle(cornerRadius: 10)
)
}
.textFieldStyle(.roundedBorder)
.padding(20)
}
}
모델은 초기화 메서드에서 모든 예시 값을 받도록 했습니다. 초기화 메서드는 로컬 속성에 값을 할당하기만 합니다. 대체값 계산은 사용자가 입력한 제목을 덮어쓰지 않고 렌더링되는 레이블만 바꿉니다. 공백만 있는 제목은 계속 편집할 수 있지만, 렌더링 결과에는 Untitled label이 표시됩니다.
에디터는 호출자로부터 모델을 받습니다. @Bindable 선언은 $model.title과 같은 바인딩을 제공하며, 상태 소유권은 프리뷰 호스트에 둡니다. 이 뷰가 모델 값을 표시하기만 한다면 관찰을 위해 일반 모델 참조만으로 충분합니다. 여기서는 컨트롤에 쓰기 가능한 속성 바인딩이 필요하므로 Bindable 저장소를 사용합니다. Bindable.
각 프리뷰에 독립적인 예시 상태 제공하기
다음 선언을 같은 파일에 추가하세요. 각 선언은 명시적인 가상 값으로 자체 모델을 만듭니다. 너비는 레이아웃을 확인하기 위한 예시일 뿐, 권장 최소 창 크기가 아닙니다.
#Preview("Standard label") {
@Previewable @State var model = LabelViewModel(
title: "Sample tile",
detail: "Blue square, medium size"
)
LabelEditor(model: model)
.frame(width: 420)
.preferredColorScheme(.light)
}
#Preview("Long label in a narrow editor") {
@Previewable @State var model = LabelViewModel(
title: "Sample collection with a longer descriptive label",
detail: "Round tiles arranged in three columns"
)
LabelEditor(model: model)
.frame(width: 300)
.preferredColorScheme(.dark)
}
#Preview("Blank title and hidden detail") {
@Previewable @State var model = LabelViewModel(
title: " ",
detail: "An optional detail line",
showsDetail: false
)
LabelEditor(model: model)
.frame(width: 420)
}
@Previewable은 로컬 상태 선언을 #Preview가 생성하는 내장 SwiftUI 뷰의 일부로 만듭니다. Apple 문서에 따르면 이 매크로가 붙은 선언은 생성된 뷰의 속성이 됩니다. 선언은 프리뷰 클로저의 루트에 두세요. 프리뷰 본문 밖에서 @Previewable을 사용하면 오류가 발생합니다. Previewable().
이렇게 하면 소유권 연결이 명확해집니다. 생성된 프리뷰 호스트가 모델을 @State에 저장하고, LabelEditor가 모델 참조를 받은 다음, @Bindable이 편집 가능한 속성을 노출합니다. SwiftUI는 Observation 모델을 상태에 저장하고 그 참조를 하위 뷰에 전달하는 방식을 지원합니다. State.
초기화 값은 시작 시점의 테스트 데이터로 사용하세요. 인터랙티브 프리뷰가 실행되는 동안에는 값을 변경할 수 있습니다. 특정 레이아웃을 재현하려면 초기 모습을 비교하기 전에 새 프리뷰 인스턴스로 돌아가세요.
작은 시나리오 표로 동작 확인하기
LabelEditor.swift를 열고 Show Canvas를 선택한 다음, 캔버스 탭에서 이름이 지정된 프리뷰를 선택하세요. 캔버스가 일시 정지되어 있으면 Restart를 사용하세요. 텍스트 입력과 컨트롤 로직을 시험하려면 Live 모드를 켜세요. 이 컨트롤은 Apple의 최신 캔버스 가이드에 설명되어 있습니다. 캔버스에서 프리뷰와 상호작용하기.
다음 항목을 확인해 상태 문제와 레이아웃 문제를 구분하세요. 예상 결과는 예제 코드에서 도출한 것이며, 실제 테스트 결과를 보고하는 내용은 아닙니다.
| 시나리오 | 동작 | 예상 결과 |
|---|---|---|
| 기본 레이블 | 제목을 Amber circle로 바꾸기 |
렌더링되는 제목이 즉시 바뀜 |
| 세부 정보 표시 여부 | Show detail을 껐다가 다시 켜기 |
세부 정보가 사라졌다가 텍스트를 유지한 채 다시 표시됨 |
| 빈 제목 | 제목의 모든 문자 지우기 | 렌더링되는 제목이 Untitled label로 바뀜 |
| 공백 제목 | 공백만 입력하기 | 필드에는 공백이 유지되고, 렌더링되는 제목에는 대체값이 사용됨 |
| 좁은 에디터 | 긴 제목 프리뷰 확인하기 | 렌더링되는 제목이 카드 안에서 줄바꿈됨 |
| 별도 테스트 데이터 | 한 프리뷰를 편집한 다음 다른 프리뷰를 새로 열어 확인하기 | 다른 프리뷰는 선언된 자체 모델 값으로 시작함 |
텍스트 입력은 작동하지만 렌더링된 출력이 바뀌지 않으면, 필드 바인딩과 출력에서 읽는 속성 사이의 연결을 확인하세요. 여기서는 두 경로 모두 직접 또는 displayedTitle을 거쳐 model.title로 이어집니다. 표시를 전환할 때 세부 텍스트가 지워진다면 빈 값을 할당하는 코드를 찾아보세요. 이 예제는 showsDetail만 변경합니다.
조사 환경에서는 Swift 컴파일러나 macOS 프리뷰 런타임을 사용할 수 없었으므로, 이 예제는 문서화된 API와 대조해 살펴보았으며 컴파일하거나 실행해 확인하지는 않았습니다. 에디터를 수정하기 전에 Xcode에서 타깃을 빌드하고 상호작용 항목을 확인하세요.
공유 설정과 편집 가능한 테스트 데이터를 분리하기
이처럼 작은 모델은 로컬에서 구성하면 각 프리뷰를 이해하기 쉽습니다. 여러 프리뷰에 비용이 많이 드는 재사용 설정이 필요해지면 Apple이 제공하는 PreviewModifier를 사용할 수 있습니다. 이 API의 공유 컨텍스트는 캐시되어 참여하는 프리뷰에서 재사용됩니다. macOS 15 이상이 필요합니다. PreviewModifier.
문서화된 공유 동작에 따라 실용적인 설계 선택이 가능합니다. 각각 독립적으로 편집할 테스트 모델은 로컬에 두고, 참여하는 프리뷰에서 의도적으로 재사용할 설정에만 공유 컨텍스트를 사용하세요. 공유 가변 모델은 편집 내용도 공유하므로 서로 다른 시나리오를 비교하기 어려워질 수 있습니다.
기존 뷰가 환경에서 타입으로 모델을 가져온다면 프리뷰 루트에서 필요한 인스턴스를 주입하세요. Apple 문서에 따르면 요청한 타입의 비옵셔널 모델이 없으면 예외가 발생합니다. 따라서 프리뷰는 선택한 루트가 실제로 읽는 종속성을 제공해야 합니다. 앱의 모델 데이터 관리하기.
AppKit과 TextKit의 경계 유지하기
위 에디터는 SwiftUI 컨트롤을 사용합니다. 기존 macOS 에디터에 AppKit 텍스트 뷰가 있다면 NSViewRepresentable을 사용해 해당 뷰를 SwiftUI 계층에 통합하세요. makeNSView를 통해 뷰를 만들고 구성한 다음, updateNSView를 통해 SwiftUI 상태 변경을 적용하세요. 편집 내용을 SwiftUI로 다시 전달해야 한다면 코디네이터를 통해 AppKit 델리게이트 변경 사항을 전달하세요. 표현된 뷰의 프레임과 경계는 SwiftUI가 제어하도록 하세요. NSViewRepresentable.
Apple은 AppKit 뷰와 뷰 컨트롤러를 직접 프리뷰하는 기능도 지원합니다. 그러나 @Previewable은 SwiftUI 전용 기능이므로 AppKit 직접 프리뷰에는 적용되지 않습니다. 이 글의 로컬 상태 패턴을 AppKit 콘텐츠에 적용하려면 representable을 포함하는 SwiftUI 호스트를 프리뷰하세요. 인터페이스 파일에 프리뷰 추가하기.
NSTextView를 만들 때는 텍스트 엔진 선택을 유지하세요. 초기화 시 TextKit 2를 선택하는 방법으로 Apple은 NSTextView(usingTextLayoutManager: true)을 문서화하고 있습니다. TextKit 가이드에서는 TextKit 1의 layoutManager에 접근하면 해당 텍스트 뷰 인스턴스가 한 방향으로 대체 경로를 사용할 수 있다고 설명합니다. TextKit 2가 의도한 엔진이라면 이에 맞는 TextKit 2 API를 사용하세요. 프리뷰를 사용해도 이러한 경계가 사라지지는 않습니다. TextKit과 텍스트 뷰의 새로운 기능.
소유권 경계에서 프리뷰 문제 진단하기
첫 번째 빌드 진단부터 확인하세요. Swift 매크로는 컴파일 시점에 코드를 생성하므로 매크로 호출에서 강조된 오류가 생성된 선언과 관련될 수 있습니다. Xcode의 Editor > Expand Macro 명령을 사용하면 해당 코드를 확인할 수 있습니다. 매크로 적용하기.
그다음 상태 메커니즘을 확인하세요. 기존 Combine 기반 ObservableObject에는 그에 맞는 소유권 패턴이 필요합니다. Apple은 이러한 객체를 State에 저장해도 게시된 속성의 변경을 추적하지 못한다고 경고하며, 소유자에게 StateObject를 사용하도록 안내합니다. 여기의 예제는 @Observable을 사용하므로 Observation 방식에 따라 상태 소유권을 설정합니다. State.
완성된 프리뷰 파일은 명시적인 시작 값과 눈에 보이는 상호작용을 중심으로 구성하세요. 기본, 긴 제목, 빈 제목 시나리오가 예상대로 작동하면 앱의 적절한 상위 뷰가 소유하는 모델과 함께 같은 에디터를 재사용할 수 있습니다. 프리뷰 테스트 데이터는 이러한 인터페이스 상태를 재현하는 간결한 방법입니다.
출처
- 인터페이스 파일에 프리뷰 추가하기 — Apple
- Xcode 16 릴리스 노트 — Apple
- 앱의 모델 데이터 관리하기 — Apple
- Previewable() — Apple
- Bindable — Apple
- State — Apple
- State() — Apple
- 캔버스에서 프리뷰와 상호작용하기 — Apple
- PreviewModifier — Apple
- NSViewRepresentable — Apple
- TextKit과 텍스트 뷰의 새로운 기능 — Apple
- 매크로 적용하기 — Apple