sheet(item:onDismiss:content:)을 선택된 항목 하나를 옵셔널로 두고 사용하세요. 클로저가 받는 항목으로 시트를 구성하세요. 이렇게 하면 Mac 앱에서 시트를 표시할지 여부와 시트에 무엇을 표시할지를 하나의 값으로 결정할 수 있습니다.
이 튜토리얼에서는 작은 데스크톱 작업을 구현합니다. 로컬 샘플 항목을 클릭하면 즉시 세부 정보를 확인할 수 있습니다. 또한 시트가 빈 화면으로 열리거나, 이전 선택을 표시하거나, AppKit 뷰 안에 오래된 텍스트를 표시하는 경우를 진단하는 순서도 제공합니다.
항목 기반 시트를 사용하는 이유
SwiftUI에는 관련된 표시 방식이 두 가지 있습니다. 불리언 오버로드는 바인딩이 true가 되면 시트를 표시합니다. 항목 오버로드는 식별 가능한 옵셔널 값을 받으며, 그 값을 콘텐츠 클로저에 전달합니다. 고정 패널에는 불리언 방식을 선택하고, 패널의 내용이 선택 항목에 따라 달라질 때는 항목 방식을 선택하세요. Apple 문서에서 불리언 기반 시트와 항목 기반 시트를 참조하세요.
선택 상태와 표시 상태를 별도 속성으로 두면 선택 항목이 없는데도 표시가 요청되는 조합이 생길 수 있습니다. 두 속성을 하나로 합치면 자체 상태 모델에서 이런 조합을 없앨 수 있습니다. nil은 요청된 항목이 없음을 나타내고, 값이 있으면 확인할 항목을 나타냅니다. 이는 두 API를 바탕으로 한 설계상의 결론이며, 문서화되지 않은 SwiftUI 스케줄링 결함에 대한 주장은 아닙니다.
기존 구현에서 선택 항목을 할당한 다음 불리언을 전환하더라도, 이 두 문장만으로 첫 표시가 실패하는 이유를 알 수는 없습니다. 증상을 타이밍 탓으로 돌리기 전에 콘텐츠의 데이터 경로를 살펴보세요. 중요한 질문은 시트가 전달받은 입력을 렌더링하는지, 다른 속성을 읽는지, 아니면 따로 저장된 복사본을 표시하는지입니다.
완전한 macOS 예제 만들기
Xcode에서 인터페이스가 SwiftUI인 macOS 앱 프로젝트를 만드세요. 이 예제의 배포 대상은 macOS 12 이상으로 설정하세요. 항목 기반 시트 API는 macOS 10.15부터 사용할 수 있고, 아래에서 사용하는 환경의 dismiss 동작에는 macOS 12가 필요합니다. Apple 문서에서 항목 기반 시트와 dismiss 환경 값의 사용 가능 버전을 확인할 수 있습니다.
생성된 앱 선언과 콘텐츠 뷰를 이 완전한 프로그램으로 바꾸세요. 타깃에는 앱 진입점이 하나만 있어야 합니다. 아래의 모든 레이블과 설명은 로컬에서 사용하는 가상 샘플 데이터입니다.
import SwiftUI
struct SampleItem: Identifiable {
let id: String
let title: String
let detail: String
}
@main
struct FirstSheetDemoApp: App {
var body: some Scene {
WindowGroup {
ItemListView()
}
}
}
struct ItemListView: View {
private let items = [
SampleItem(
id: "tile-a",
title: "Amber Tile",
detail: "A square sample with an amber finish."
),
SampleItem(
id: "tile-b",
title: "Blue Tile",
detail: "A rectangular sample with a blue finish."
)
]
@State private var presentedItem: SampleItem? = nil
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Sample Items")
.font(.title2)
ForEach(items) { item in
Button("Inspect \(item.title)") {
presentedItem = item
}
}
}
.padding(24)
.frame(minWidth: 360, minHeight: 200)
.sheet(item: $presentedItem) { item in
ItemDetailView(item: item)
}
}
}
struct ItemDetailView: View {
let item: SampleItem
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(item.title)
.font(.title)
Text(item.detail)
Text("Item ID: \(item.id)")
.font(.caption)
HStack {
Spacer()
Button("Done") {
dismiss()
}
}
}
.padding(24)
.frame(width: 380, height: 220)
}
}
버튼은 클릭한 값을 presentedItem에 할당합니다. 그런 다음 표시 클로저가 자체 item 매개변수를 ItemDetailView에 전달합니다. 세부 정보 뷰에는 두 번째 선택 항목 상태 속성 대신 일반 입력 속성인 let item이 있습니다. Apple은 단일 진실 공급원을 유지하고 읽기 전용 값은 속성으로 공유하며, 자식 뷰에 쓰기 권한이 필요할 때 바인딩을 사용하도록 권장합니다. State를 참조하세요.
dismiss 동작은 시트 환경에서 값을 읽는 ItemDetailView 안에 있어야 합니다. Apple은 이를 표시하는 뷰에서 읽은 뒤 시트 콘텐츠에서 호출하면 표시하는 뷰의 환경을 사용한다고 명시적으로 경고합니다. 예시처럼 선언을 시트 자체의 콘텐츠 옆에 두세요. dismiss를 참조하세요.
프로그램의 입력과 참조가 일관적인지 검토했지만, 이 환경에는 Swift 툴체인이 없어 컴파일하거나 실행하지 않았습니다. 대상 macOS 버전에서 Xcode로 다음 검사를 실행하세요.
의도적인 클릭 순서로 첫 표시 확인하기
각 첫 표시 검사는 새로 실행한 상태에서 시작하세요. 다른 항목을 연 뒤에만 테스트하면 원래 증상을 놓칠 수 있습니다. 다음 기대 결과는 샘플의 명시적인 레이블과 데이터 할당을 바탕으로 한 수용 기준이지, 실제 테스트 결과가 아닙니다.
| 동작 | 예상 시트 콘텐츠 | 확인하는 항목 |
|---|---|---|
앱을 실행하고 먼저 Inspect Blue Tile 클릭 |
Blue Tile, 직사각형 설명, tile-b |
처음 표시할 때 클릭한 항목이 사용되는지 확인 |
Done을 클릭한 뒤 Amber Tile 확인 |
Amber Tile, 정사각형 설명, tile-a |
다음 선택 항목이 콘텐츠에 전달되는지 확인 |
닫은 뒤 Amber Tile을 다시 확인 |
동일한 황색 타일 세부 정보 | 항목을 다시 열어도 일관적인지 확인 |
앱을 다시 실행하고 먼저 Amber Tile 확인 |
즉시 표시되는 황색 타일 세부 정보 | 어떤 샘플을 먼저 열어도 올바른지 확인 |
제목과 ID를 함께 확인하면 제목만 보는 것보다 더 유용합니다. 실제 인터페이스에서 표시 이름이 중복될 수 있다면 개발 중 제목은 같고 ID와 설명은 다른 가상 항목 두 개를 사용하세요. 이렇게 하면 표시 텍스트로 항목을 잘못 선택하는 문제를 찾아낼 수 있습니다.
버튼에서 최종 뷰까지 오래된 콘텐츠 진단하기
다음 검사를 순서대로 진행하세요. 각 단계는 불일치가 발생한 위치를 좁혀 주므로, 관련 없는 표시 우회 방법을 추가하지 않고도 원인이 되는 계층을 바꿀 수 있습니다.
- 동작의 입력을 확인합니다. 클릭한 버튼 동작에 중단점을 설정하고
item.id을 확인하세요. 동작에 의도한 레코드가 전달되는지 확인합니다. - 표시 입력을 확인합니다. 시트 클로저 안의
item매개변수를 확인하세요. 별도의 선택 항목을 다시 조회하지 말고 해당 매개변수를 세부 정보 뷰에 전달합니다. - 최종 표시 속성을 확인합니다. 제목과 설명이 세부 정보 뷰의 입력에서 읽히는지 확인하세요. 이전 값이 들어 있는 캐시 속성이나 로컬 초안을 찾아보세요.
- AppKit 브리지가 있다면 확인합니다. 일반 SwiftUI 텍스트는 올바른데 래핑된 텍스트가 오래된 경우, representable의 업데이트 메서드를 살펴보세요.
안정적인 식별성 유지하기
항목 기반 API에는 Identifiable이 필요합니다. Swift에서 이 프로토콜은 안정적인 식별성을 기준으로 하며, 식별성의 지속 시간과 범위는 이를 채택하는 타입과 사용하는 쪽에 맡깁니다. 예시에서는 두 레코드를 구분하는 저장된 문자열 ID를 사용합니다. Identifiable을 참조하세요.
실제 목록에서는 인터페이스가 지원하는 업데이트 전반에서 같은 레코드를 식별할 수 있는 식별자를 선택하세요. 바뀔 수 있거나 여러 항목이 공유할 수 있는 제목에서 식별자를 만들지 마세요. 또한 읽을 때마다 새 UUID를 만드는 계산 속성 id도 피하세요. 안정적인 식별자를 제공하지 못합니다.
표시 입력과 편집 가능한 초안 분리하기
인스펙터에서는 item.title을 직접 표시할 수 있습니다. 편집기에서는 의도적으로 로컬 초안을 만들 수 있지만, 초안을 사용하면 수명을 정의해야 하는 값이 하나 더 생깁니다. 초안을 언제 만들지, 이후 모델 변경이 초안을 대체해야 하는지, 사용자가 완료하거나 취소하면 어떻게 처리할지 정하세요.
Apple은 SwiftUI가 뷰의 식별성에 따라 상태 저장소를 유지하고, 해당 식별성의 수명이 시작될 때 상태를 초기화한다고 설명합니다. 뷰 값을 다시 만들어도 상태가 자동으로 새로 생성되지는 않습니다. 따라서 입력으로 로컬 상태를 초기화하는 방법은 이후 입력과 상태를 동기화하는 일반적인 수단이 아닙니다. Demystify SwiftUI를 참조하세요.
이 읽기 전용 작업에서는 일반 입력 속성을 유지하세요. 편집 기능을 추가한다면 명시적인 초안 및 커밋 설계나 의도된 모델 값에 대한 바인딩을 선택하세요. 텍스트를 새로 고치려고 뷰 식별자를 바꾸지 마세요. 식별자를 바꾸면 연결된 상태가 교체되어 완료되지 않은 초안이 사라질 수 있습니다. 이는 Apple이 문서화한 상태 수명 모델에서 따르는 결과입니다.
시트에 AppKit 텍스트 뷰가 들어가면 무엇이 달라지나요?
NSTextView가 있는 시트에도 동일한 선택 항목 데이터 경로가 필요합니다. 여기에 올바른 브리지 구현도 필요합니다. Apple의 NSViewRepresentable 프로토콜은 makeNSView(context:)에서의 초기 생성과 updateNSView(_:context:)에서의 후속 구성을 구분합니다. 생성 시에만 텍스트를 할당하는 래퍼에는 이후 텍스트 입력을 적용할 구현이 없습니다. NSViewRepresentable을 참조하세요.
읽기 전용 래퍼라면 전달받은 텍스트로 네이티브 뷰를 초기화하고, 필요할 때 업데이트 메서드가 표시 텍스트를 현재 입력과 일치하도록 갱신하게 하세요. Apple은 해당 AppKit 뷰에 영향을 주는 변경이 있을 때 SwiftUI가 업데이트 메서드를 호출한다고 설명합니다. updateNSView(_:context:)를 참조하세요.
편집 가능한 래퍼에는 추가 정책이 필요합니다. 진행 중인 편집 내용을 덮어쓰지 않도록 업데이트 때마다 텍스트를 무조건 교체하지 마세요. Apple이 representable 문서에서 설명하듯 코디네이터를 사용해 네이티브 델리게이트나 타깃-액션의 변경을 SwiftUI에 전달하세요. SwiftUI가 표현된 뷰의 프레임과 바운드를 제어하도록 두세요. Apple은 이러한 레이아웃 속성을 직접 설정하지 말라고 경고합니다.
TextKit은 텍스트 저장, 레이아웃, 렌더링을 담당합니다. Apple은 NSTextView가 제공하는 최신 NSTextLayoutManager 경로와 기존 NSLayoutManager 경로를 모두 문서화합니다. 이러한 기능은 시트의 선택 레코드를 전달하지 않습니다. 텍스트 레이아웃을 조사하기 전에 브리지가 올바른 항목의 텍스트를 받는지 확인하고, 추가 TextKit API를 도입하기 전에 사용 가능 버전을 별도로 확인하세요. TextKit을 참조하세요.
작업에 맞는 표시 모델 선택하기
콘텐츠가 고정되어 있고 표시 여부만 바뀌는 경우 불리언 오버로드를 사용하세요. 이 인스펙터 작업에는 항목 오버로드를 사용하세요. Apple 문서에 따르면 전달된 항목이 바뀌면 기존 시트가 닫히고 다른 시트로 교체될 수 있으므로, 항목 교체가 동일한 표시 상태에서 편집을 중단 없이 이어 간다는 보장으로 여겨서는 안 됩니다. sheet(item:onDismiss:content:)를 참조하세요.
첫 표시를 올바르게 하려면 선택된 레코드를 쉽게 추적할 수 있도록 하세요. 버튼 동작에서 한 번 할당하고, 시트 클로저에서 한 번 항목 매개변수를 받고, 세부 정보 뷰에서 한 번 입력으로 전달합니다. 새로 실행한 뒤 두 샘플 항목을 모두 확인하세요. 결과가 여전히 다르면 진단 순서를 통해 불일치가 선택, 유지된 상태, 네이티브 뷰 업데이트 경로 중 어디에서 발생하는지 찾을 수 있습니다.
출처
- sheet(item:onDismiss:content:) — Apple
- sheet(isPresented:onDismiss:content:) — Apple
- State — Apple
- dismiss — Apple
- Identifiable — Apple
- Demystify SwiftUI — Apple
- NSViewRepresentable — Apple
- updateNSView(_:context:) — Apple
- TextKit — Apple