Mac 앱에서 SwiftUI로 감싼 NSTextView의 삽입점을 표시하려면 키보드 포커스를 복원하고 현재 선택 영역을 사용해 scrollRangeToVisible(_:)를 호출하세요. 캐럿 색상은 별도로 설정하세요. 캐럿을 화면 안에 유지하는 것과 깜박임을 멈추는 데에는 서로 다른 제어 방법이 필요합니다.
이 가이드에서는 삽입점 표시 버튼이 있는 간단한 로컬 편집기를 만듭니다. 문서의 다른 곳으로 스크롤한 뒤 편집하던 위치로 돌아가는 익숙한 데스크톱 작업을 다룹니다. 구현은 현재 선택 영역을 유지하고, 일반적인 텍스트 입력과 커서 그리기는 AppKit이 관리하도록 합니다.
삽입점이 보이지 않는 이유 파악하기
먼저 눈에 보이는 증상부터 확인하세요. 스크롤, 포커스, 선택 영역, 커서 애니메이션은 비슷하게 보이는 결과를 낼 수 있지만 각각 다른 대응이 필요합니다.
| 관찰되는 현상 | 확인할 항목 | 적절한 조치 |
|---|---|---|
| 편집 위치가 문서의 표시 영역 밖에 있음 | 텍스트 뷰의 현재 선택 영역 | 해당 범위가 보이도록 스크롤 |
| 다른 컨트롤을 클릭하면 편집기에서 입력이 중단됨 | 윈도우의 첫 응답자 | 요청이 있을 때 명시적으로 포커스 복원 |
| 캐럿과 배경을 구분하기 어려움 | 캐럿 및 배경 색상 | 텍스트 뷰의 색상 속성 설정 |
| 캐럿 하나 대신 텍스트가 강조 표시됨 | 선택 범위의 길이가 0이 아닌지 여부 | 사용자가 위치 이동을 요청하지 않는 한 선택 영역 유지 |
| 편집기가 활성화된 동안 캐럿이 사라졌다 다시 나타남 | 일반적인 커서 애니메이션 | 깜박임은 스크롤과 별도로 다루기 |
Apple은 scrollRangeToVisible(_:)를 지정된 범위의 첫 문자가 보일 때까지 이를 둘러싼 스크롤 뷰를 스크롤하는 메서드로 설명합니다. 해당 범위를 가운데에 배치하거나 긴 선택 영역 전체를 표시한다고 보장하지는 않습니다. 따라서 글리프 사각형을 직접 계산하지 않고 삽입 위치를 표시하는 데 적합합니다. Apple의 스크롤 설명.
포커스는 포함하는 윈도우에 속합니다. Apple은 텍스트 뷰의 becomeFirstResponder()를 직접 호출하는 대신 window.makeFirstResponder(textView)을 사용하라고 명시적으로 안내합니다. 아래 구현은 이 경로를 따르며 실제로 어떤 객체가 첫 응답자가 되었는지 확인합니다. Apple의 NSTextView 설명, 첫 응답자 전환 설명.
표시 명령이 있는 로컬 편집기 만들기
Xcode에서 SwiftUI와 Swift를 사용하는 macOS 앱 프로젝트를 만드세요. 이 예제의 배포 대상을 macOS 13 이상으로 설정합니다. 생성된 앱 진입점을 아래 첫 번째 파일로 바꾸고, 두 번째 파일을 같은 앱 타깃에 추가하세요. 두 파일이 함께 완전한 데모 앱을 구성합니다.
이 예제는 합성 텍스트를 사용하며, AppKit 뷰의 수명 동안 해당 뷰에서 편집 내용을 유지합니다. 문서 저장 구현이나 외부 텍스트 모델은 없습니다. 앱을 닫으면 편집 내용이 사라집니다. 코드는 가져온 문서를 기준으로 검토했으며, 사용 가능한 환경이 Linux이고 AppKit SDK가 없어 여기서 컴파일하거나 실행하지는 않았습니다.
SwiftUI 윈도우 만들기
버튼은 요청 번호를 증가시킵니다. 이를 통해 모든 SwiftUI 업데이트 때마다 편집기에 다시 포커스를 주거나 스크롤하는 대신, 래퍼가 처리할 구체적인 명령을 전달합니다.
// CaretDemoApp.swift
import SwiftUI
@main
struct CaretDemoApp: App {
var body: some Scene {
WindowGroup("Local Editor") {
EditorScreen()
}
}
}
struct EditorScreen: View {
@State private var revealRequest = 0
private let sampleText = (1...80)
.map { "Sample row \($0): blue square, green circle." }
.joined(separator: "\n")
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Button("Reveal insertion point") {
revealRequest += 1
}
CaretEditor(
initialText: sampleText,
revealRequest: revealRequest
)
}
.padding()
.frame(minWidth: 480, minHeight: 320)
}
}
스크롤 가능한 AppKit 편집기 감싸기
NSViewRepresentable은 SwiftUI 계층에서 AppKit 뷰를 관리하기 위한 Apple의 브리지입니다. 여기서 표현되는 뷰는 NSScrollView이며, 그 문서 뷰로 NSTextView를 사용합니다. 코디네이터는 마지막으로 처리한 요청을 기억합니다. Apple은 생성 중 사용할 스크롤 가능한 텍스트 뷰 팩터리를 제공합니다. NSViewRepresentable, scrollableTextView().
// CaretEditor.swift
import AppKit
import SwiftUI
@MainActor
struct CaretEditor: NSViewRepresentable {
let initialText: String
let revealRequest: Int
@MainActor
final class Coordinator {
var lastHandledRequest: Int
init(lastHandledRequest: Int) {
self.lastHandledRequest = lastHandledRequest
}
}
func makeCoordinator() -> Coordinator {
Coordinator(lastHandledRequest: revealRequest)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
scrollView.hasVerticalScroller = true
guard let textView = scrollView.documentView as? NSTextView else {
preconditionFailure("Expected an NSTextView document view")
}
textView.isRichText = false
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.drawsBackground = true
textView.backgroundColor = .textBackgroundColor
textView.textColor = .textColor
textView.insertionPointColor = .textColor
textView.font = .systemFont(ofSize: 15)
textView.textContainerInset = NSSize(width: 12, height: 12)
textView.string = initialText
let end = (initialText as NSString).length
textView.setSelectedRange(NSRange(location: end, length: 0))
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard revealRequest != context.coordinator.lastHandledRequest else {
return
}
context.coordinator.lastHandledRequest = revealRequest
guard let textView = scrollView.documentView as? NSTextView,
let window = textView.window,
textView.acceptsFirstResponder else {
return
}
guard window.makeFirstResponder(textView),
window.firstResponder === textView else {
return
}
textView.scrollRangeToVisible(textView.selectedRange())
}
}
편집기는 샘플 텍스트 끝에 길이가 0인 선택 영역을 두고 시작합니다. 삽입점 표시를 누르면 편집기에 포커스를 주고 해당 위치를 표시합니다. 이후 버튼을 누르면 현재 선택 영역을 사용하므로 캐럿을 다른 곳으로 옮기면 명령이 표시할 위치도 바뀝니다. 이 명령은 텍스트를 바꾸거나 선택 영역을 이동하지 않습니다.
끝 위치에는 NSString.length를 사용합니다. 이 값은 UTF-16 코드 단위 수를 셉니다. Swift의 UTF-16 뷰는 인덱스를 사용하는 NSString API와 호환되지만, Swift의 String.count는 문자 수를 셉니다. AppKit 텍스트 범위를 만들 때는 같은 표현 방식을 유지하세요. 기존 selectedRange()을 읽으면 표시 명령 자체에서 오프셋을 계산하지 않아도 됩니다. NSString length, Swift String.UTF16View, Swift String.count.
SwiftUI 업데이트 중 편집 상태 유지하기
핵심 설계는 생성과 명령을 분리하는 것입니다. makeNSView은 편집기를 한 번 초기화합니다. updateNSView는 요청 번호가 바뀌면 응답하며 문서 내용은 그대로 둡니다. 이는 AppKit 뷰의 생성 및 업데이트에 관한 Apple의 representable 수명 주기 설명을 따릅니다. SwiftUI는 표현된 뷰의 레이아웃도 제어하므로 이 래퍼는 스크롤 뷰의 프레임이나 경계를 설정하지 않습니다. Apple의 representable 수명 주기 및 레이아웃 지침.
이 예제에서는 업데이트할 때마다 textView.string = initialText을 설정하지 마세요. 그렇게 하면 사용자의 현재 문서가 원래 샘플로 바뀝니다. 마찬가지로 선택 영역을 무조건 문서 끝으로 설정하면 표시 작업이 편집 위치를 이동시킵니다.
SwiftUI 상태를 기반으로 하는 편집기라면 의도적인 양방향 동기화를 추가하세요. 코디네이터를 통해 사용자 편집 내용을 전달하고, 들어오는 모델 변경이 텍스트를 언제 대체할지 정의해야 합니다. 대체 과정에서 유효한 선택 영역을 유지하고 동시에 발생한 변경을 어떻게 해결할지 정하세요. 이는 캐럿 표시와는 별개의 책임입니다. 이 샘플은 편집 가능한 내용의 소유자를 하나로 유지합니다. Apple은 코디네이터를 델리게이트 및 타깃-액션 상호작용을 SwiftUI로 전달하는 메커니즘으로 설명합니다. 코디네이터 지침.
포커스 가드도 중요합니다. 기존 응답자가 포커스를 양보하지 않으면 makeFirstResponder(_:)이 실패할 수 있습니다. 또한 Apple은 요청한 응답자가 포커스를 받지 않더라도 성공을 반환하면 윈도우 자체가 첫 응답자로 남을 수 있다고 설명합니다. window.firstResponder === textView을 확인하면 실제 결과를 검증한 뒤 스크롤할 수 있습니다. 첫 응답자 전환의 반환값 및 동작.
편집기가 윈도우에 연결되지 않은 경우 이 샘플은 요청을 소비하고 아무 작업도 하지 않습니다. 연결된 뒤 버튼을 다시 누르세요. 초기 포커스 기능을 구현하려면 관련 없는 업데이트 중에 포커스를 반복해서 가져오지 말고, 연결 상태를 고려하는 명시적 요청을 설계하세요.
커서 그리기를 대체하지 않고 색상 설정하기
이 예제는 텍스트와 삽입점에 .textColor를, 편집기 배경에 .textBackgroundColor를 할당합니다. 이는 텍스트와 텍스트 뒤 영역에 대해 문서화된 의미 기반 색상입니다. insertionPointColor는 캐럿 색상을 설정하는 문서화된 속성입니다. 앱이 지원하는 각 macOS 버전과 화면 모드에서 결과가 올바르게 보이는지 확인하세요. 캐럿 색상, 텍스트 색상, 텍스트 배경색.
색상 설정은 포커스를 요청하거나 문서를 스크롤하지 않으며, 깜박이지 않는 표시 모드를 선택하지도 않습니다. 캐럿이 보이지 않는 문제를 진단할 때는 이러한 책임을 구분하세요. 예를 들어 색상을 바꿔도 스크롤 뷰의 표시 영역 밖에 있는 위치가 나타나지는 않습니다.
이 샘플은 캐럿의 기하 정보를 계산하기 위해 layoutManager에 접근하지 않습니다. Apple은 macOS 12 이상에서 텍스트 뷰나 텍스트 컨테이너의 이 속성에 접근하면 NSLayoutManager를 사용하는 호환성 모드로 뷰가 전환될 수 있다고 설명합니다. 높은 수준의 스크롤 API를 호출하면 선택 영역을 표시하기 위해 TextKit 호환성 모드로 바꾸지 않아도 됩니다. TextKit 호환성 동작.
캐럿을 항상 표시할 때의 한계 이해하기
“보이게 유지”가 “절대 깜박이지 않게”를 뜻한다면 스크롤 명령은 그 요구를 충족하지 않습니다. Apple의 최신 커서 설명은 자동 애니메이션과 항상 표시 모드를 구분합니다.
macOS 14 이상에서는 NSTextInsertionIndicator가 .automatic, .visible, .hidden 표시 모드를 제공합니다. Apple은 드래그 대상 표시처럼 애니메이션을 비활성화하고 커서를 계속 표시하는 용도로 .visible을 설명합니다. 표준 AppKit 텍스트 뷰는 시스템 커서 UI를 자동으로 사용합니다. 시스템 텍스트 커서 적용 가이드, NSTextInsertionIndicator.
문서화된 사용자 지정 커서 경로에서는 표시기를 직접 만들고 위치를 지정해야 합니다. 표시 모드는 해당 인스턴스를 제어합니다. Apple 가이드는 NSTextView에 내부적으로 관리되는 표시기를 .visible로 바꾸는 설정을 제공하지 않습니다. 커서 기하 정보와 활성 상태를 업데이트할 책임이 포함된 사용자 지정 텍스트 UI를 위한 아키텍처 선택으로 이 경로를 다루세요. 이 래퍼에 바로 적용할 수 있는 변경은 아닙니다.
오래된 API처럼 보이는 두 메서드도 이 설정을 제공하지 않습니다. shouldDrawInsertionPoint은 읽기 전용으로 문서화되어 있습니다. drawInsertionPoint(in:color:turnedOn:)는 커서를 그리거나 지우며, Apple은 호출자가 이를 직접 호출할 필요가 없다고 설명합니다. 둘 다 항상 표시하는 스위치로 문서화되어 있지 않습니다. 삽입점 그리기 상태, 삽입점 그리기 메서드.
이 글에서 다루는 일반적인 편집기 작업에서는 AppKit의 커서 애니메이션을 유지하고 현재 위치로 돌아가는 확실한 방법을 제공하세요. 사용자 지정 커서를 구현하려면 별도의 설계가 필요하며, NSTextInsertionIndicator를 사용하는 경우 macOS 14 가용성 경계도 고려해야 합니다.
앱에서 동작 확인하기
샘플을 사용해 스크롤, 선택 영역, 포커스, 화면 표시를 구분해 확인하세요. 다음은 구현에서 예상되는 동작이며, 실제 실행 결과를 보고한 것이 아닙니다.
- 앱을 실행하고
삽입점 표시를 누릅니다. 편집기에 포커스가 이동하고 끝 위치가 표시되어야 합니다. - 이전 행 안을 클릭해 캐럿을 옮깁니다. 다른 텍스트 위치를 클릭하지 않은 채로 멀리 스크롤한 다음 버튼을 누릅니다. 해당 이전 위치가 표시되어야 합니다.
- 여러 단어를 선택하고 버튼을 누릅니다. 명령은 선택 영역을 캐럿 하나로 접지 않으므로 선택 상태가 유지되어야 합니다.
주황색 삼각형같은 식별하기 쉬운 문구를 입력한 다음 버튼을 여러 번 누릅니다. 편집한 내용이 그대로 남아 있어야 합니다.- 윈도우 크기를 조정하고 표시 작업을 반복합니다. 명령은 이전 화면 좌표가 아니라 현재 범위를 계속 사용해야 합니다.
- 지원하는 macOS 버전에서 라이트 및 다크 화면 모드를 모두 확인합니다. 텍스트, 배경, 캐럿 색상이 서로 잘 구분되는지 확인하세요.
스크롤은 성공했는데 캐럿이 계속 간헐적으로 보인다면 일반적인 깜박임을 보고 있는지, 활성 텍스트 선택 상태인지 확인하세요. 명령이 아무 동작도 하지 않으면 먼저 윈도우 연결 및 첫 응답자 가드를 살펴보세요. 실패한 단계에 맞춰 수정하세요. 텍스트를 유지하고, 요청이 있을 때 포커스를 복원하고, 현재 범위를 표시하면 됩니다.
출처
- NSViewRepresentable — Apple
- NSTextView — Apple
- scrollableTextView() — Apple
- scrollRangeToVisible(_:) — Apple
- makeFirstResponder(_:) — Apple
- length — Apple
- String.UTF16View — Apple
- count — Apple
- insertionPointColor — Apple
- textColor — Apple
- textBackgroundColor — Apple
- 사용자 지정 텍스트 뷰에서 시스템 텍스트 커서 적용하기 — Apple
- NSTextInsertionIndicator — Apple
- shouldDrawInsertionPoint — Apple
- drawInsertionPoint(in:color:turnedOn:) — Apple