SwiftUI 메뉴 명령을 활성 NSTextView로 보내려면 명령이 실행될 때 키 윈도우의 첫 번째 응답자를 확인한 다음 해당 뷰에 액션을 보내세요. 이 튜토리얼에서는 두 편집기 중 포커스된 편집기에만 영향을 주는 Select First Line 명령을 만드는 예제를 보여 줍니다.

이 작업은 직접 만든 macOS 앱 안에서 사용자 지정 메뉴 액션을 구현하는 것입니다. 예제에서는 합성 텍스트와 앱 소유의 NSTextView 하위 클래스를 사용하고, 공개 SwiftUI 및 AppKit API를 활용합니다. 핵심 설계 결정은 명령이 AppKit의 응답자 체인을 따를지, 아니면 특정 활성 편집기에서만 실행되도록 할지입니다.

“활성 텍스트 뷰”의 의미 선택하기

이 예제에서 활성 편집기는 현재 키 윈도우의 첫 번째 응답자인 SampleTextView 인스턴스입니다. Apple은 키 윈도우를 키보드 이벤트를 받는 윈도우로 정의하며, 해당 윈도우의 firstResponder은 일반적으로 이벤트와 액션을 가장 먼저 받는 응답자를 가리킵니다. 명령이 실행되는 시점에 이 속성을 읽으면 명령의 현재 대상이 결정됩니다. Apple의 keyWindow 문서, Apple의 firstResponder 문서.

AppKit은 대상이 지정되지 않은 액션도 지원합니다. nil를 NSApplication.sendAction(_:to:from:)의 대상으로 전달하면 애플리케이션이 해당 선택자를 구현하는 응답자를 찾습니다. 이 검색은 키 윈도우의 첫 번째 응답자에서 시작하지만, 대리자와 메인 윈도우를 거쳐 애플리케이션 수준의 처리기까지 이어질 수 있습니다. 따라서 대상이 지정되지 않은 액션은 “이 키 윈도우의 활성 텍스트 뷰에서만 실행”을 뜻하지 않습니다. Apple의 액션 라우팅 문서.

필요한 동작 라우팅 방식 결과
AppKit이 적절한 처리기를 찾도록 허용 to: nil로 액션 전송 일반적인 응답자 체인 대체 동작이 적용됩니다.
활성 앱 소유 편집기에만 영향을 줌 첫 번째 응답자를 확인하고 형식을 검사한 다음 해당 응답자를 명시적으로 대상으로 지정 해당 편집기가 활성 상태가 아니면 명령이 중단됩니다.
포커스와 관계없이 특정 편집기에서 실행 해당 편집기를 대상으로 지정 활성 편집기 명령과는 다른 상호작용입니다.

아래 구현은 두 번째 동작을 선택합니다. 사용자 지정 하위 클래스를 확인하므로 관련 없는 텍스트 필드의 편집기가 텍스트 뷰라는 이유만으로 대상이 되는 것도 막을 수 있습니다.

완전한 SwiftUI 및 AppKit 예제 만들기

SwiftUI를 사용하는 macOS 앱 프로젝트를 만들고, 이 예제의 배포 대상을 macOS 13 이상으로 설정하세요. 생성된 앱 진입점을 다음 단일 Swift 파일로 바꾸고, 다른 @main 선언은 제거하세요. 이 프로그램은 두 편집기와 초기 텍스트, 메뉴, 액션 처리기를 모두 제공합니다.

NSViewRepresentable은 AppKit 뷰를 임베드하는 공식 브리지입니다. 씬 명령은 메뉴 항목을 제공하고, CommandMenu는 macOS 메뉴 막대에 앱 전용 메뉴를 만듭니다. Apple의 NSViewRepresentable 문서, 씬 명령, 명령 메뉴.

import SwiftUI
import AppKit
import Foundation

@main
@MainActor
struct RoutingDemoApp: App {
    var body: some Scene {
        WindowGroup("Routing Demo") {
            ContentView()
        }
        .commands {
            CommandMenu("Sample Actions") {
                Button("Select First Line") {
                    Self.routeSelectFirstLine()
                }
            }
        }
    }

    private static func routeSelectFirstLine() {
        let application = NSApplication.shared
        guard let editor = application.keyWindow?.firstResponder
                as? SampleTextView else {
            return
        }

        _ = application.sendAction(
            #selector(SampleTextView.selectFirstSampleLine(_:)),
            to: editor,
            from: nil
        )
    }
}

@MainActor
struct ContentView: View {
    @State private var windowLabel = "Sample Window"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Window label", text: $windowLabel)

            HStack(spacing: 12) {
                SampleEditor(initialText: "Sample Alpha\nAlpha details")
                SampleEditor(initialText: "Sample Beta\nBeta details")
            }

            Text("Click an editor, then use Sample Actions.")
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 640, minHeight: 320)
    }
}

@MainActor
final class SampleTextView: NSTextView {
    @objc func selectFirstSampleLine(_ sender: Any?) {
        guard isSelectable, !hasMarkedText() else { return }

        let contents = string as NSString
        guard contents.length > 0 else { return }

        let firstLine = contents.lineRange(
            for: NSRange(location: 0, length: 0)
        )
        setSelectedRange(firstLine)
        scrollRangeToVisible(firstLine)
    }
}

@MainActor
struct SampleEditor: NSViewRepresentable {
    let initialText: String

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.borderType = .bezelBorder

        let editor = SampleTextView(
            frame: NSRect(x: 0, y: 0, width: 300, height: 240)
        )
        editor.isEditable = true
        editor.isSelectable = true
        editor.isRichText = false
        editor.allowsUndo = true
        editor.font = NSFont.monospacedSystemFont(
            ofSize: 14, weight: .regular
        )
        editor.minSize = .zero
        editor.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.isVerticallyResizable = true
        editor.isHorizontallyResizable = false
        editor.autoresizingMask = [.width]
        editor.textContainer?.containerSize = NSSize(
            width: 300,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.textContainer?.widthTracksTextView = true
        editor.string = initialText

        scrollView.documentView = editor
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        // AppKit owns the editable contents in this example.
    }
}

이 예제는 편집기 내용을 메모리에 저장하는 완전한 앱입니다. 가져온 API 문서를 바탕으로 코드를 검토했지만, 사용 가능한 환경이 Linux이고 Swift 도구 체인이 없어 여기서 컴파일하거나 실행하지는 않았습니다.

선택자가 의도한 편집기에 도달하는 이유

라우팅 함수는 액션을 보내기 직전에 첫 번째 응답자를 가져옵니다. SampleTextView만 허용하고 바로 그 객체를 액션 대상으로 지정합니다. 따라서 메뉴는 이전에 클릭한 편집기를 계속 참조하지 않으며, 형식 검사에 실패해도 다른 윈도우를 검색하지 않습니다.

타깃 액션 디스패치는 Objective-C 선택자를 사용하므로 액션 메서드에 @objc가 필요합니다. #selector 표현식은 직접 작성한 선택자 문자열 대신 선언된 메서드를 참조합니다. Apple은 Objective-C 타깃 액션 API를 통해 Swift 메서드를 호출할 때 이 조합을 사용하는 방법을 문서화하고 있습니다. Swift에서 Objective-C 런타임 기능 사용하기.

sendAction의 반환값은 메시지가 전송되었는지를 나타냅니다. 처리기가 메시지를 받고도 내용이 비어 있거나 표시된 텍스트가 있는 경우 일찍 반환할 수 있으므로, 선택 영역이 변경되었는지는 나타내지 않습니다. 대상과 메서드가 이미 정해져 있으므로 이 예제에서는 디스패치 결과를 무시합니다. Apple의 sendAction 반환값 문서.

선택 명령이 실제로 선택하는 범위

처리기는 NSString.lineRange(for:)을 사용해 문자열 시작 지점이 포함된 논리적 줄을 가져옵니다. 이 범위에는 줄 종결 문자가 있으면 그것도 포함됩니다. 따라서 해당 줄이 화면에서 여러 행에 걸쳐 줄바꿈되어 표시되더라도 첫 번째 줄바꿈 기준 줄을 선택합니다. Apple의 줄 범위 문서.

제공된 텍스트에서 왼쪽 편집기는 Sample Alpha와 그 뒤의 줄바꿈 문자를 선택하고, 오른쪽 편집기는 Sample Beta와 그 뒤의 줄바꿈 문자를 선택합니다. 이 명령은 이전 선택 영역을 대체하며 텍스트는 변경하지 않습니다.

setSelectedRange(_:)는 선택 영역을 적용하고, scrollRangeToVisible(_:)은 선택 영역의 첫 문자가 보이도록 스크롤합니다. 내용이 비어 있을 때 선택 작업을 시도하지 않도록 빈 내용 검사를 합니다. 표시된 텍스트가 있는 동안 사용자 지정 선택 액션을 미루는 정책은 이 예제에서 명시적으로 정한 것입니다. 선택 범위, 범위를 보이도록 스크롤하기, 표시된 텍스트.

뷰 생성, 편집, 포커스를 분리해서 관리하기

이 representable은 NSScrollView를 만들고 사용자 지정 텍스트 뷰를 문서 뷰로 설치합니다. 편집기는 세로로 확장되며 텍스트 컨테이너는 너비를 따릅니다. 이는 스크롤 텍스트 뷰를 구성하는 Apple의 문서화된 방식입니다. 해당 구성은 보관된 Apple AppKit 가이드에 나와 있으며, 여기서 인용한 SwiftUI 브리지와 액션 API는 현재 문서를 사용합니다. NSScrollView에 NSTextView 객체 배치하기.

표시되는 스크롤 뷰의 레이아웃은 SwiftUI가 관리합니다. 코드는 내부 문서 뷰의 초기 기하와 AppKit 크기 조정 동작을 설정하며, 표시되는 스크롤 뷰의 프레임을 반복해서 할당하지 않습니다. Apple은 representable에 대해 SwiftUI가 관리하는 레이아웃 속성을 변경하지 말라고 명시적으로 주의를 줍니다. Apple의 representable 레이아웃 지침.

이 예제는 initialText을 생성 시점에만 할당합니다. 비어 있는 update 메서드는 이후 입력을 의도적으로 AppKit이 처리하도록 둡니다. 앱에서 SwiftUI 문서 모델이 필요하다면 코디네이터와 대리자 기반 동기화를 추가하고, 편집 중에 모델 변경 사항이 들어올 때 적용할 정책을 정의하세요. 업데이트할 때마다 초기 문자열을 다시 할당하면 사용자가 현재 입력한 내용이 교체됩니다. Apple은 코디네이터를 AppKit 상호작용을 SwiftUI로 전달하는 브리지로 설명합니다. Apple의 representable 조정 지침.

이 안내를 따라가는 동안에는 편집기를 클릭해 포커스를 설정합니다. 프로그래밍 방식으로 포커스를 옮겨야 하는 앱이라면 뷰가 해당 윈도우에 속한 뒤 포함하는 윈도우의 makeFirstResponder(_:)을 사용하세요. Apple은 텍스트 뷰의 becomeFirstResponder()를 직접 호출하지 말라고 명시합니다. SwiftUI가 업데이트될 때마다 포커스를 요청하면 사용자의 편집 대상이 계속 바뀌므로 피하세요. Apple의 텍스트 뷰 포커스 지침.

기존 TextKit 구성 유지하기

메뉴 라우팅을 위해 텍스트 레이아웃 엔진을 변경할 필요는 없습니다. 이 예제는 NSTextView(frame:)를 사용하며, Apple 문서에 따르면 기본적으로 NSLayoutManager를 사용합니다. NSTextLayoutManager를 사용해 명시적으로 생성하는 방식은 macOS 12부터 지원됩니다. Apple의 텍스트 뷰 초기화 문서.

기존 TextKit 2 편집기라면 생성 방식을 유지하고 하위 클래스에 액션을 추가하세요. 편집기의 위치를 찾거나 라우팅하기 위해 layoutManager을 읽지 마세요. Apple 문서에 따르면 TextKit 2 텍스트 뷰에서 이 속성에 접근하면 호환성 모드로 전환될 수 있습니다. 라우팅 코드에는 응답자와 액션 메서드만 있으면 됩니다. Apple의 NSTextView 호환성 모드 문서.

라우팅 확인 및 사용할 수 없는 명령 처리하기

macOS에서 의도한 동작을 확인하려면 다음 순서대로 진행하세요. 이는 구현에서 예상되는 결과이며 실제 실행 결과를 보고한 것은 아닙니다.

  1. 왼쪽 편집기를 클릭한 다음 Sample Actions → Select First Line을 선택합니다. 왼쪽 편집기의 첫 번째 논리적 줄이 선택되어야 합니다.
  2. 오른쪽 편집기를 클릭하고 같은 명령을 실행합니다. 오른쪽 편집기의 선택 영역만 바뀌어야 합니다.
  3. Window label을 클릭하고 명령을 실행합니다. 첫 번째 응답자가 하위 클래스 검사를 통과하지 못하므로 두 사용자 지정 편집기 모두 그대로여야 합니다.
  4. 편집기 하나의 내용을 모두 지운 뒤 그 편집기에서 명령을 실행합니다. 빈 내용 검사가 반환되어 텍스트가 바뀌지 않아야 합니다.
  5. 여러 줄을 추가하고 아래로 스크롤한 뒤 명령을 실행합니다. 첫 번째 줄이 선택되고 화면에 표시되어야 합니다.

이 최소 예제에서는 포커스가 다른 곳에 있더라도 명령이 활성화된 상태로 유지됩니다. 실행 시점의 검사에서 동작 여부가 결정됩니다. SwiftUI .disabled 표현식 안에서 keyWindow?.firstResponder를 읽는 것만으로는 AppKit 포커스가 바뀔 때마다 해당 표현식이 업데이트된다는 보장이 없습니다.

규모가 큰 SwiftUI 인터페이스에서는 포커스를 인식하는 상태를 통해 적절한 명령 기능을 제공하고, @FocusedValue로 이를 관찰한 다음 그 상태에 따라 버튼의 .disabled 조건을 계산하세요. AppKit 브리지는 이 기능을 실제 편집기 포커스와 계속 일치시켜야 합니다. 실행 시점 검사도 유지하세요. Apple 문서에 따르면 FocusedValue는 포커스된 뷰나 그 조상의 값을 관찰합니다. 포커스 값, 상호작용 비활성화.

대상이 편집기인 AppKit 메뉴 항목을 만든다면 validateMenuItem(_:)가 AppKit의 유효성 검사 지점을 제공합니다. 이 메서드를 이 하위 클래스에 추가하는 것만으로 SwiftUI 버튼이 유효성 검사를 받지는 않습니다. 버튼 액션은 이후에 다른 액션을 디스패치하는 클로저이기 때문입니다. Apple은 유효성 검사 객체가 메뉴 항목의 대상이어야 한다고 요구합니다. Apple의 메뉴 항목 유효성 검사 문서.

명령을 더 추가하기 전에 대상 지정 정책을 정하세요. 응답자 체인의 대체 동작을 원한다면 대상이 지정되지 않은 액션을 사용하고, 작업이 활성 편집기에만 해당한다면 첫 번째 응답자를 확인해 대상으로 지정하세요. 이렇게 하면 사용자가 텍스트 뷰와 다른 컨트롤 사이를 이동해도 같은 메뉴가 예측 가능하게 동작합니다.

출처