NSTextFieldDelegate를 지정하고 controlTextDidEndEditing(_:)을 구현한 다음, 필드의 stringValue을 SwiftUI 바인딩에 복사합니다. 코디네이터가 이 콜백을 뷰 상태에 연결하므로 편집이 끝난 후 레이블을 업데이트할 수 있습니다.

이 튜토리얼에서는 편집 가능한 레이블 두 개와 확정된 값을 보여 주는 요약이 있는 작은 Mac 앱을 만듭니다. 누군가 입력하는 동안 요약은 그대로 두고, AppKit에서 편집 세션이 끝났을 때 업데이트하는 것이 목표입니다. Apple 문서에 따르면 이 컨트롤이 변경 사항을 확정한 후 편집 종료 콜백이 발생합니다. Apple의 controlTextDidEndEditing(_:) 문서

어떤 이벤트로 뷰를 업데이트해야 할까요?

기존 NSTextField에는 컨트롤 편집 델리게이트 콜백을 사용합니다. 델리게이트는 object이 알림을 보낸 컨트롤인 알림을 받으므로, 핸들러에서 필드를 식별하고 값을 읽을 수 있습니다. Apple의 textDidEndEditingNotification 문서

필요한 인터페이스 동작에 따라 이벤트를 선택하세요.

원하는 동작 적절한 방법
AppKit 텍스트 필드의 편집이 끝났을 때 확정된 값 업데이트 델리게이트에서 controlTextDidEndEditing(_:)을 구현합니다.
편집 완료 시 기존 AppKit 타깃-액션 핸들러 호출 셀의 sendsActionOnEndEditing 속성을 설정합니다.
네이티브 SwiftUI TextField에서 제출 처리 문서에 명시된 제출 동작을 위해 .onSubmit을 연결합니다.

이 방법들은 각기 다른 계약을 따릅니다. Apple 문서에 따르면 sendsActionOnEndEditing을 활성화하면 Return 키를 누르거나, 필드에서 Tab으로 나가거나, 다른 텍스트 필드를 클릭했을 때 액션이 전송됩니다. 비활성화하면 Return 키를 눌렀을 때만 액션이 전송됩니다. SwiftUI 문서에 따르면 TextField의 .onSubmit은 Return 키에 반응합니다. 따라서 이 동작만으로는 포함된 AppKit 필드의 일반적인 포커스 손실 콜백이 보장되지 않습니다. Apple의 sendsActionOnEndEditing 문서, Apple의 onSubmit(of:_:) 문서

이 앱에서는 델리게이트 한 곳에서 필드의 텍스트를 확정합니다. 나중에 타깃-액션 핸들러도 추가한다면 두 핸들러에서 같은 확정 부수 효과를 수행하지 않도록 하세요.

완전한 SwiftUI 및 AppKit 예제 만들기

Xcode에서 Swift와 SwiftUI 인터페이스를 사용하는 macOS 앱 프로젝트를 만드세요. 이 예제에는 최신 Swift 6 지원 툴체인을 사용하고 배포 대상을 macOS 13 이상으로 설정합니다. 생성된 앱 진입점 파일을 아래의 전체 프로그램으로 바꾸고, 생성된 ContentView.swift에서 다른 ContentView를 선언한다면 제거하세요. @main 진입점은 정확히 하나만 유지합니다. Apple의 App 문서에서 이 진입점 구조를 설명합니다. Apple의 App 문서

예제의 macOS 13 배포 대상은 프로젝트 설정이며, 브리지의 최초 도입 버전은 아닙니다. Apple은 NSViewRepresentable을 macOS 10.15부터 사용할 수 있다고 명시합니다. AppKit을 사용하므로 이 구현은 macOS 대상에 한정됩니다. Apple의 NSViewRepresentable 문서

import SwiftUI
import AppKit

@main
@MainActor
struct EditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
struct ContentView: View {
    @State private var cardTitle = "Blue Card"
    @State private var shelfLabel = "Shelf A"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Card title")
            CommitTextField(value: $cardTitle, label: "Card title")
                .frame(height: 24)

            Text("Shelf label")
            CommitTextField(value: $shelfLabel, label: "Shelf label")
                .frame(height: 24)

            Divider()

            Text("Committed values")
                .font(.headline)
            Text("Card: \(cardTitle)")
            Text("Shelf: \(shelfLabel)")
        }
        .padding(20)
        .frame(minWidth: 420)
    }
}

@MainActor
struct CommitTextField: NSViewRepresentable {
    @Binding var value: String
    let label: String

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSTextField {
        let field = NSTextField(string: value)
        field.placeholderString = label
        field.delegate = context.coordinator
        return field
    }

    func updateNSView(_ field: NSTextField, context: Context) {
        context.coordinator.parent = self
        field.placeholderString = label
        field.isEnabled = context.environment.isEnabled

        // Leave the active editing buffer alone.
        if field.currentEditor() == nil, field.stringValue != value {
            field.stringValue = value
        }
    }

    static func dismantleNSView(
        _ field: NSTextField,
        coordinator: Coordinator
    ) {
        field.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: CommitTextField

        init(parent: CommitTextField) {
            self.parent = parent
            super.init()
        }

        func controlTextDidEndEditing(_ notification: Notification) {
            guard let field = notification.object as? NSTextField else {
                return
            }

            let committedValue = field.stringValue
            if parent.value != committedValue {
                parent.value = committedValue
            }
        }
    }
}

이 예제는 임의의 로컬 값을 사용하는 완전한 앱입니다. 가져온 API 문서를 기준으로 검토했지만 여기서는 컴파일하거나 실행하지 않았습니다. 사용 가능한 실행 환경은 Linux이며 Swift 툴체인이나 macOS SDK가 없습니다. Mac의 Xcode에서 빌드하고 실행해 보세요.

값이 요약에 전달되는 방식

부모의 각 @State 값은 필드 래퍼에 바인딩을 제공합니다. 편집이 끝나면 코디네이터가 해당 바인딩에 값을 씁니다. 요약에서 변경된 상태를 읽으므로 SwiftUI가 요약을 업데이트합니다. Apple 문서에서는 상태에 따른 뷰 업데이트와 자식 뷰가 부모 소유 상태를 수정하도록 바인딩을 사용하는 방법을 모두 설명합니다. Apple의 State 문서

makeNSView은 편집 가능한 AppKit 컨트롤을 만들고 코디네이터를 델리게이트로 지정합니다. updateNSView은 SwiftUI에서 해당 컨트롤로 전달되는 변경을 처리합니다. 델리게이트 콜백은 반대 방향의 변경을 처리합니다. Apple의 통합 가이드에서도 AppKit 뷰를 호스팅할 때 이와 같은 코디네이터 및 바인딩 구성을 사용합니다. Apple의 “Use SwiftUI with AppKit” 세션

context.coordinator.parent = self 할당은 코디네이터에 저장된 representable을 최신 상태로 유지합니다. 코디네이터는 호스팅된 뷰의 수명 동안 유지되지만 representable의 값은 바뀔 수 있습니다. Apple은 updateNSView 중 저장된 값을 새로 고치는 방법을 명시적으로 보여 줍니다. Apple의 코디네이터 수명 주기 설명

SwiftUI 업데이트 중 초안을 보존하기

핵심은 field.currentEditor() == nil 가드입니다. 이 예제에서는 미완성 텍스트를 AppKit 내부에 유지하고 SwiftUI에는 마지막으로 확정된 값을 보관합니다. 편집 중 가드가 없으면 SwiftUI 업데이트 중 확정된 값을 필드에 복사하면서 미완성 초안을 덮어쓸 수 있습니다.

Apple 문서에 따르면 currentEditor()는 컨트롤의 필드 에디터를 반환하며, 필드 에디터가 없으면 nil를 반환합니다. 필드 에디터는 창 안의 여러 컨트롤이 공유하는 NSTextView입니다. 따라서 창의 firstResponder이 NSTextField와 같은지만 확인하는 것은 컨트롤에 에디터를 직접 묻는 방법을 제대로 대체하지 못합니다. Apple의 currentEditor() 문서

그 결과 다음과 같은 명시적 정책이 적용됩니다.

상황 예제 동작
필드가 유휴 상태이고 바인딩이 바뀜 다음 representable 업데이트에서 바인딩 값을 필드에 복사합니다.
필드에 활성 에디터가 있음 편집 버퍼를 보존합니다.
다른 텍스트로 편집이 끝남 완료된 텍스트를 바인딩에 씁니다.
텍스트를 바꾸지 않고 편집이 끝남 불필요한 상태 할당을 건너뜁니다.

이 정책은 편집 세션 동안 로컬 초안에 우선권을 줍니다. 사용자가 편집하는 동안 앱의 다른 코드에서 같은 바인딩을 변경하면, 나중에 확정할 때 그 변경을 덮어쓸 수 있습니다. 이 예제에는 값을 변경하는 다른 코드가 없습니다. 더 큰 편집기에서는 동시 변경을 어떻게 해결할지 정하세요. 예를 들어 현재 세션을 끝낸 후 선택 항목을 전환하거나, 초안과 확정된 값을 따로 유지하면서 명시적인 선택지를 제공할 수 있습니다. 화면을 다시 그리는 시점에 미완성 텍스트를 버려 동기화하지 마세요.

이 작업에는 TextKit 레이아웃 사용자 지정이 필요하지 않습니다. 중요한 경계는 컨트롤의 편집 세션과 필드 에디터입니다. Apple의 텍스트 아키텍처 가이드는 NSTextField, 델리게이트, 공유 NSTextView 필드 에디터의 관계를 설명합니다. 이 가이드는 아카이브 자료이며, 현재 currentEditor() 참조 문서에서 공유 에디터 모델을 확인할 수 있습니다. Apple의 “Text Editing” 가이드, Apple의 현재 필드 에디터 참조 문서

앱에서 완료 동작 확인하기

빌드한 후 다음 순서로 예제를 확인하세요. 이는 실행 결과를 보고한 것이 아니라 구현과 문서화된 편집 동작을 바탕으로 예상한 확인 항목입니다.

  1. Blue Card를 Green Card로 바꿉니다. 입력하는 동안 요약에는 계속 Card: Blue Card가 표시되어야 합니다.
  2. Return 키를 누릅니다. 요약이 Card: Green Card로 바뀌어야 합니다.
  3. 카드 제목을 다시 편집한 다음 Shelf label 필드를 클릭합니다. 완료된 카드 제목이 요약에 표시되어야 합니다.
  4. 선반 값을 편집한 다음 Tab 키를 누릅니다. 선반 요약이 업데이트되는지 확인하고 키보드 탐색을 살펴봅니다.
  5. 빈 값을 확정합니다. 해당 요약에는 접두어만 값 없이 표시되어야 합니다. 이 예제는 빈 문자열을 허용합니다.
  6. 텍스트를 바꾸지 않은 채 편집을 끝냅니다. 동등성 검사를 통해 상태를 다시 할당하지 않아야 합니다.

Return 키로 편집을 완료해도 예상한 포커스 이동이 발생하지 않을 수 있습니다. Apple 문서에 따르면 편집 종료 알림 후 NSTextField는 자체 액션 및 기타 Return 처리를 시도하고 텍스트를 선택할 수도 있습니다. Tab과 Shift-Tab은 다음 또는 이전 키 뷰로 이동을 시도합니다. 인터페이스를 확인할 때 완료와 포커스 이동 위치는 별개의 동작으로 취급하세요. Apple의 textDidEndEditing(_:) 문서

콜백이 오지 않을 때 문제 진단하기

먼저 field.delegate가 여전히 코디네이터를 참조하는지 확인합니다. 이 속성은 약한 참조이므로 별도의 소유자 없이 임시 델리게이트 객체를 사용하면 해당 객체가 유지되지 않습니다. 여기서는 representable의 코디네이터가 필요한 수명을 제공합니다. Apple의 delegate 문서, Apple의 코디네이터 수명 주기 설명

다음으로 편집이 실제로 끝났는지 확인합니다. 아무 배경이나 클릭하는 것은 이 조건을 확인하는 신뢰할 만한 방법이 아닙니다. 이 예제에서는 Return 키를 누르거나 다른 편집 가능한 필드로 이동하세요. 결과가 다르면 구체적인 응답자 전환을 살펴보세요.

앱에서 유효성 검사 기능을 추가했다면 control(_:textShouldEndEditing:)을 확인합니다. 문서에 따르면 이 메서드의 반환값이 편집을 끝낼 수 있는지 결정합니다. 필드에서 나가려는 시도가 거부되었다면 성공적으로 확정된 것으로 취급하면 안 됩니다. Apple의 control(_:textShouldEndEditing:) 문서

취소 경로도 확인하세요. Apple은 변경 사항을 버리기 위해 abortEditing()를 호출해도 controlTextDidEndEditing(_:)가 호출되지 않는다고 명시합니다. 따라서 취소 작업에는 별도의 명확한 상태 처리가 필요합니다. Apple의 편집 종료 콜백 문서

명시적인 앱 명령으로 완료 요청하기

나중에 이 필드에 대한 참조가 있는 Done 명령을 추가한다면, 지원되는 응답자 경로는 해당 필드가 속한 창에 makeFirstResponder(nil)을 호출하도록 요청하는 것입니다. 계속 진행하기 전에 반환된 불리언 값을 확인하세요. Apple 문서에 따르면 현재 응답자가 첫 응답자 지정을 거부할 수 있으며, 이 경우 메서드는 false를 반환합니다. 애플리케이션 전역의 키 창이 이 편집기에 속한다고 가정하지 말고 필드의 실제 창을 사용하세요. Apple의 makeFirstResponder(_:) 문서

이 브리지의 AppKit 접근과 UI 콜백을 메인 액터에서 처리하세요. 현재 델리게이트 선언은 메인 액터에 격리되어 있으며, Swift 문서에서는 MainActor가 메인 디스패치 큐와 동등한 실행기를 사용한다고 설명합니다. 이 예제는 @MainActor로 격리를 직접 표현하며, 완료 콜백과 바인딩 기록 사이에 지연된 작업을 추가하지 않습니다. Apple의 콜백 선언, Swift의 MainActor 문서

이 구현에는 명확한 확정 경계가 하나 있습니다. AppKit에서 편집을 끝내면 코디네이터가 필드 값을 읽고 바인딩을 업데이트하여 이에 의존하는 SwiftUI 텍스트를 갱신합니다. representable 업데이트 중 초안을 보호하면 사용자 지정 텍스트 레이아웃 기능 없이도 일반적인 레이블 편집에서 이 경계를 활용할 수 있습니다.

출처