업데이트 중에도 SwiftUI 시트를 열린 상태로 유지하려면 프레젠테이션 호스트의 정체성을 보존하고 프레젠테이션 바인딩을 활성 상태로 유지하세요. 시트를 안정적인 컨테이너에 연결하면 프레젠터를 교체하지 않고도 바뀌는 하위 콘텐츠를 업데이트할 수 있습니다.

이 가이드는 기반 인터페이스가 바뀌는 동안에도 편집 가능한 시트를 계속 표시해야 하는 Mac 앱 개발자를 위한 것입니다. 예제에서는 합성된 로컬 텍스트를 사용하며, 독립적으로 살펴볼 수 있는 세 가지 요소, 즉 프레젠테이션 상태, 프레젠터 뷰의 수명, 편집기 콘텐츠를 구분합니다.

뷰가 업데이트되면 시트가 사라질 수 있는 이유는 무엇인가요?

SwiftUI는 body를 평가할 때 새 뷰 값을 만들 수 있지만, 표시된 뷰의 정체성은 유지할 수 있습니다. 상태는 해당 정체성의 수명 동안 지속됩니다. 뷰를 제거하거나 정체성을 바꾸면 그 수명이 끝납니다. 두 분기에 같은 뷰 유형이 있어도 조건부 분기 사이를 전환하면 서로 다른 정체성이 만들어질 수 있습니다. Apple은 SwiftUI의 작동 원리 설명에서 이러한 관계를 설명합니다.

불리언으로 구동되는 시트의 경우, 문서화된 프레젠테이션 제어 방식은 sheet(isPresented:onDismiss:content:)에 전달하는 바인딩입니다. 값이 true이면 프레젠테이션을 요청합니다. 시트가 사라지는 원인을 조사할 때는 해당 바인딩과 수정자를 포함한 뷰가 계속 존재하는지 모두 확인하세요. 다른 곳에 저장된 true 값만으로 원래 프레젠테이션 호스트가 살아남았다고 볼 수는 없습니다. Apple의 불리언 시트 문서를 참조하세요.

명시적 식별자가 바뀌는 경우는 특히 직접적인 원인입니다. Apple은 id(_:)에 전달하는 값이 바뀌면 상태를 포함해 뷰의 정체성이 재설정된다고 설명합니다. 프레젠터 뷰와 그 상위 뷰에서 수정 횟수, 편집 가능한 텍스트 또는 새로 생성된 값에서 파생된 식별자가 있는지 확인하세요. 재설정 동작이 의도된 것이 아니라면 해당 식별자를 제거하세요. id(_:)를 참조하세요.

실용적인 설계 방법은 인터페이스의 변경되는 부분 바깥에 시트 수정자를 두는 것입니다. 로딩 뷰, 필터링된 행 또는 대체 상세 레이아웃이 사라질 수 있다면, 그보다 가까운 적절한 상위 지속 뷰가 대개 더 나은 프레젠테이션 소유자입니다.

완전한 Mac 예제로 안정적인 시트 소유자 만들기

Xcode에서 macOS SwiftUI 앱을 만들고 배포 대상을 macOS 12 이상으로 설정한 다음, 생성된 앱 및 콘텐츠 뷰 선언을 아래 단일 파일로 바꾸세요. @main 앱 선언은 하나만 남겨 두세요. 이 예제의 dismiss 환경 액션은 macOS 12부터 사용할 수 있습니다. TextEditor 자체는 macOS 11부터 사용할 수 있습니다. Apple의 dismiss 문서와 TextEditor 문서를 참조하세요.

윈도우의 루트 뷰는 프레젠테이션 플래그, 편집 가능한 텍스트, 수정 횟수, 레이아웃 플래그를 소유합니다. 바깥쪽 VStack에 시트 수정자가 붙어 있습니다. 컨테이너 안의 콘텐츠만 분기를 전환합니다. Apple은 필요한 가장 높은 뷰에서 상태를 비공개로 선언하고, 변경이 필요한 하위 뷰에는 바인딩을 전달하라고 권장합니다. State를 참조하세요.

import SwiftUI

@main
struct StableSheetDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SheetOwnerView()
        }
    }
}

struct SheetOwnerView: View {
    @State private var isEditorPresented = false
    @State private var note = "Sample note Alpha"
    @State private var revision = 0
    @State private var compactLayout = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Local Notes")
                .font(.title)

            if compactLayout {
                Text("Compact summary: revision \(revision)")
            } else {
                VStack(alignment: .leading, spacing: 8) {
                    Text("Expanded summary")
                    Text("Revision \(revision)")
                    Text(note)
                }
            }

            Button("Edit Note") {
                isEditorPresented = true
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 260)
        .sheet(isPresented: $isEditorPresented) {
            NoteEditorSheet(
                note: $note,
                revision: $revision,
                compactLayout: $compactLayout
            )
        }
    }
}

struct NoteEditorSheet: View {
    @Binding var note: String
    @Binding var revision: Int
    @Binding var compactLayout: Bool
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Edit Sample Note")
                .font(.headline)

            TextEditor(text: $note)
                .frame(minHeight: 160)

            Text("Owner revision: \(revision)")

            Button("Update Owner") {
                revision += 1
                compactLayout.toggle()
            }

            HStack {
                Spacer()
                Button("Done") {
                    dismiss()
                }
            }
        }
        .padding(24)
        .frame(width: 460, height: 340)
    }
}

이것은 문서화된 API를 기준으로 검토한 완전한 시연 앱입니다. 이 환경에는 Swift 도구 체인과 macOS 런타임이 없어 컴파일하거나 실행하지는 않았습니다.

시트를 닫지 않고 업데이트를 실행하는 방법

  1. 앱을 실행하고 Edit Note를 클릭합니다.
  2. 샘플 텍스트를 Sample note Beta로 바꿉니다.
  3. Update Owner를 여러 번 클릭합니다. 클릭할 때마다 소유자 상태가 증가하고 소유자의 조건부 콘텐츠가 전환됩니다.
  4. 편집기가 계속 표시되고 텍스트가 Sample note Beta로 유지되며 수정 횟수 표시가 증가하는지 확인합니다.
  5. Done을 클릭합니다. 확장 레이아웃이 활성화된 경우, 윈도우에 편집된 텍스트가 표시되는지 확인합니다.
  6. 편집기를 다시 열어 수정된 텍스트가 남아 있는지 확인합니다.

업데이트 버튼은 시트 안에 있으므로 테스트를 위해 뒤쪽 윈도우를 클릭할 필요가 없습니다. AppKit은 시트를 문서 모달로 설명합니다. 시트가 표시되어 있는 동안 상위 윈도우로 전달되는 대부분의 이벤트는 금지됩니다. 바인딩을 통해 소유자를 업데이트하면 이 예제에서 상태 변경을 직접 확인할 수 있습니다. beginSheet(_:completionHandler:)를 참조하세요.

편집기를 닫을 때 어떤 동작을 할지 결정하기

이 예제는 소유자 텍스트를 즉시 편집합니다. Done은 편집기를 닫을 뿐 별도의 저장 작업을 수행하지 않습니다. 문자열은 윈도우 로컬 상태에 남으며, 앱을 다시 실행한 뒤에도 유지되는 기능은 제공하지 않습니다. 이 동작은 State에 설명된 공유 바인딩 설계를 따릅니다.

취소할 수 있는 초안이 필요하다면 별도의 초안 값을 사용하고, 명시적인 확인 동작이 있을 때만 문서에 복사하세요. 닫기 동작을 구현하기 전에 이 동작을 정의하세요. 초안을 적용할지 버릴지 결정하는 문제는 어떤 뷰가 시트를 소유할지 결정하는 문제와 별개입니다.

독립적인 세 가지 점검으로 닫힘 원인 진단하기

자신의 앱에서 다음 순서대로 확인하세요. 사라지는 시트마다 원인이 같다고 가정하지 않고, 일반적인 정체성 규칙을 집중적으로 살펴볼 수 있습니다.

확인 항목 살펴볼 내용 실용적인 수정 방법
프레젠테이션 값 불리언을 false로 설정하거나 선택적 프레젠테이션 항목을 지우는 모든 쓰기 의도적인 닫기 동작에서만 닫힘 값을 쓰도록 유지합니다
프레젠터 정체성 조건부 분기, 제거된 행, 바뀌는 상위 뷰 식별자 프레젠테이션 상태와 수정자를 모두 적절한 안정적 상위 뷰로 옮깁니다
콘텐츠 수명 시트 로컬 상태, 바뀌는 콘텐츠 식별자, 편집기 동기화 의도한 편집 세션을 유지하고 기존 콘텐츠를 신중하게 업데이트합니다

먼저 프로젝트에서 프레젠테이션 속성에 값을 할당하는 부분과 dismiss() 호출을 검색하세요. 새로 고침 콜백이 시트를 명시적으로 닫고 있을 수 있습니다. 예제처럼 시트 자체의 뷰 안에서 dismiss를 읽으세요. Apple 문서에 따르면 이 액션은 가져온 환경에 적용됩니다. 윈도우의 루트 뷰에서 읽으면 해당 윈도우를 대상으로 할 수 있습니다. dismiss를 참조하세요.

둘째, 수정자 위쪽의 뷰 계층을 확인하세요. 새로 고침으로 제거되는 상세 뷰에 시트가 붙어 있다면, 그 교체 경계보다 위로 옮기세요. 로컬 소유 프레젠테이션 상태도 함께 옮기세요. Apple의 뷰 수명 설명에 따르면 정체성이 끝나는 하위 뷰에 플래그를 두면 저장소가 교체될 수 있습니다.

셋째, 프레젠테이션이 닫힌 경우와 편집기가 계속 표시되지만 초안이 사라진 경우를 구분하세요. 먼저 기본 TextEditor 예제로 시작한 다음 사용자 지정 콘텐츠를 단계별로 다시 추가하세요. 이 비교는 진단 방법입니다. 문제가 프레젠테이션 소유권에서 비롯되는지, 콘텐츠 업데이트 로직에서 비롯되는지 파악하는 데 도움이 됩니다.

항목 기반 시트로 전환해야 할까요?

특정 선택 항목이 프레젠테이션의 원본이라면 sheet(item:onDismiss:content:)을 사용하세요. Apple 문서에서는 Identifiable을 준수하는 선택적 항목을 사용하고, 콘텐츠 클로저에 nil이 아닌 항목을 전달하며, 항목이 바뀌면 교체된다고 설명합니다. 오버로드를 바꾼다고 해서 수명이 끝나는 프레젠터가 저절로 유지되는 것은 아닙니다. 항목 기반 시트를 참조하세요.

의도한 편집 세션 동안 항목을 식별할 수 있는 식별자를 선택하세요. Swift의 Identifiable 프로토콜은 안정적인 정체성을 나타냅니다. 문서에서는 필요한 지속 시간과 범위를 해당 타입에 맡긴다고 명시합니다. 속성을 읽을 때마다 새 식별자를 만들면 계속 이어지는 항목을 나타낼 수 없습니다. Identifiable을 참조하세요.

시연과 같은 간단한 편집기에서는 불리언 프레젠테이션 플래그와 별도의 텍스트 바인딩을 사용하면 의도한 관계를 쉽게 살펴볼 수 있습니다. 선택 항목 편집기에서는 선택 정체성을 자주 바뀌는 필드와 분리하세요. 다른 항목을 선택하는 일을 의도적인 프레젠테이션 결정으로 처리하고, 먼저 현재 초안을 어떻게 할지 정의하세요.

대화형 닫기를 비활성화하면 시트가 유지되나요?

interactiveDismissDisabled(_:)은 프로그램 호출이 아닌 방식의 닫기를 제어합니다. Apple은 프레젠테이션 바인딩을 바꾸거나 dismiss를 호출해 발생하는 닫기는 막지 않는다고 명시합니다. 따라서 문서화된 역할로는 정체성 교체 문제를 해결할 수 없습니다. interactiveDismissDisabled(_:)를 참조하세요.

인터페이스에 해당 닫기 동작이 필요한 경우에만 시트 계층 안에 이 수정자를 적용하고, 명시적인 완료 경로를 마련하세요. 이 작업에서는 먼저 호스트와 상태를 보존하세요. 원인을 확인하기 전에 닫기 제한을 추가하면 기존 소유권 문제가 해결되지 않은 채 남을 수 있습니다.

시트에 AppKit 또는 TextKit 콘텐츠가 있으면 무엇이 달라지나요?

NSViewRepresentable로 NSTextView를 포함할 때는 생성과 업데이트를 분리하세요. Apple은 makeNSView(context:)를 생성 단계로 정의하고, updateNSView(_:context:)을 후속 SwiftUI 정보를 적용하는 위치로 설명합니다. 코디네이터는 델리게이트 또는 타깃 액션의 변경 사항을 SwiftUI로 전달합니다. NSViewRepresentable과 makeNSView(context:)를 참조하세요.

이 수명 주기에서 도출되는 유용한 구현 규칙은 관련 없는 소유자 상태가 바뀌었다는 이유만으로 편집기를 다시 만들지 않는 것입니다. 콘텐츠를 교체하기 전에 전달된 모델 텍스트가 실제로 달라졌는지, 동시에 발생하는 로컬 편집을 어떻게 처리할지 확인하세요. 시트가 안정적이어도 텍스트에 대한 동시 쓰기 문제까지 해결되지는 않습니다.

TextKit은 텍스트 저장소, 레이아웃, 렌더링 기능을 제공합니다. AppKit의 NSTextView는 이러한 기능을 묶고 최신 레이아웃 엔진과 기존 레이아웃 엔진을 모두 제공합니다. 이 기능은 편집기 콘텐츠에 관한 것입니다. 프레젠테이션 소유권에는 여전히 안정적인 수명이 필요합니다. 편집 작업에 필요할 때 TextKit을 사용하고, 텍스트 업데이트 동작은 시트 닫힘과 별도로 조사하세요. Apple의 TextKit 개요를 참조하세요.

윈도우 소유 AppKit 프레젠테이션은 언제 적절한가요?

기존 앱이 이미 AppKit에서 윈도우 수명을 관리한다면, macOS 26은 해당 윈도우에 SwiftUI 콘텐츠를 직접 표시하는 NSWindow.beginSheet(content:completionHandler:)를 제공합니다. Apple 문서에는 시트의 dismiss 환경 액션, 크기 조정, 도구 모음, 대화형 닫기 제어가 지원된다고 나와 있습니다. 이를 도입하려면 macOS 26 SDK를 사용하고 macOS 26 가용성 경계를 적용하세요. beginSheet(content:completionHandler:)를 참조하세요.

반환되는 NSWindow.HostingSheetRepresentation을 사용하면 표시된 루트 뷰를 바꾸거나 AppKit에서 프로그램 방식으로 닫을 수 있습니다. 윈도우 수준 소유권이 앱 아키텍처에 맞는 경우 문서화된 선택지가 됩니다. 위의 안정적인 SwiftUI 컨테이너 예제에는 필요하지 않습니다. NSWindow.HostingSheetRepresentation를 참조하세요.

이전 macOS 버전에서는 AppKit의 기존 beginSheet(_:completionHandler:)이 NSWindow 시트를 받아들이며 프레젠테이션 큐잉을 지원한다고 문서화되어 있습니다. 이러한 시스템에서 새로운 SwiftUI 콘텐츠 오버로드를 사용할 수 있다고 가정하지 마세요. beginSheet(_:completionHandler:)를 참조하세요.

소유자를 안정적으로 유지하고, 프레젠테이션 값을 활성 상태로 두며, 편집기의 의도된 데이터 원본을 보존하세요. 더 복잡한 콘텐츠를 복원하기 전에 작은 로컬 업데이트 동작으로 이러한 조건을 확인하세요. 이렇게 하면 Apple이 문서화한 수명과 API 범위 내에서 인터페이스를 업데이트하는 동안에도 편집을 계속할 수 있습니다.

출처