To reveal the insertion point in your Mac app’s wrapped NSTextView, restore keyboard focus and call scrollRangeToVisible(_:) with its current selection. Configure caret color separately. Keeping the caret onscreen and making it stop blinking require different controls.
This guide builds a small local editor with a Reveal insertion point button. It addresses a familiar desktop task: returning to your editing position after scrolling elsewhere in a document. The implementation preserves the current selection and lets AppKit manage normal text input and cursor drawing.
Identify why the insertion point is missing
Start with the visible symptom. Scrolling, focus, selection, and cursor animation can produce similar-looking results, but each needs a different response.
| What you observe | What to check | Appropriate action |
|---|---|---|
| Your editing position is outside the visible document area | The text view’s current selection | Scroll that range into view |
| Clicking another control stops typing in the editor | The window’s first responder | Explicitly return focus when requested |
| The caret is difficult to distinguish from the background | Caret and background colors | Configure the text view’s color properties |
| Text is highlighted instead of showing a single caret | Whether the selected range has a nonzero length | Preserve the selection unless the user requests a move |
| The caret disappears and reappears while the editor is active | Normal cursor animation | Treat blinking separately from scrolling |
Apple documents scrollRangeToVisible(_:) as scrolling an enclosing scroll view until the first characters of the supplied range are visible. It does not promise to center that range or display an entire long selection. This makes it suitable for revealing an insertion position without calculating glyph rectangles yourself. Apple’s scrolling documentation.
Focus belongs to the containing window. Apple explicitly instructs callers to use window.makeFirstResponder(textView) rather than invoke the text view’s becomeFirstResponder() directly. The implementation below follows that path and checks which object actually became first responder. Apple’s NSTextView documentation, first-responder transition documentation.
Build a local editor with a reveal command
Create a macOS App project in Xcode using SwiftUI and Swift. Set the deployment target to macOS 13 or later for this example. Replace the generated app entry point with the first file below, and add the second file to the same app target. Together, these files form the complete demonstration app.
The example uses synthetic text and keeps edits in the AppKit view for that view’s lifetime. It has no document-saving implementation or external text model. Closing the app discards edits. The code was inspected against the retrieved documentation; it was not compiled or run here because the available environment is Linux and has no AppKit SDK.
Create the SwiftUI window
The button increments a request number. This gives the wrapper a specific command to process instead of making every SwiftUI update refocus or scroll the editor.
// 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)
}
}
Wrap the scrollable AppKit editor
NSViewRepresentable is Apple’s bridge for managing an AppKit view in a SwiftUI hierarchy. Here, the represented view is an NSScrollView, with an NSTextView as its document view. The coordinator remembers the last handled request. Apple provides the scrollable text-view factory used during creation. 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())
}
}
The editor starts with a zero-length selection at the end of its sample text. Pressing Reveal insertion point focuses the editor and reveals that position. Later presses use the live selection, so moving the caret elsewhere changes the position the command reveals. Nothing in the command replaces text or moves the selection.
The end position uses NSString.length, which counts UTF-16 code units. Swift’s UTF-16 view matches indexed NSString APIs; Swift’s String.count counts characters instead. When constructing AppKit text ranges, keep those representations consistent. Reading an existing selectedRange() avoids calculating an offset for the reveal command itself. NSString length, Swift String.UTF16View, Swift String.count.
Preserve editing state during SwiftUI updates
The critical design choice is the division between creation and commands. makeNSView seeds the editor once. updateNSView responds to a changed request number and leaves the document contents alone. This follows the representable lifecycle Apple describes for creating and updating AppKit views. SwiftUI also controls the represented view’s layout, so this wrapper does not assign the scroll view’s frame or bounds. Apple’s representable lifecycle and layout guidance.
Avoid assigning textView.string = initialText on every update in this demonstration. Doing so would replace the user’s current document with the original sample. Likewise, unconditionally setting the selection to the document’s end would make a reveal operation move the editing position.
For an editor backed by SwiftUI state, add deliberate two-way synchronization: forward user edits through a coordinator and define when incoming model changes replace the text. Preserve valid selections across those replacements and define how concurrent changes are resolved. That is a separate responsibility from revealing the caret; the sample intentionally keeps one owner of its editable contents. Apple documents coordinators as the mechanism for forwarding delegate and target-action interactions into SwiftUI. Coordinator guidance.
The focus guards also matter. makeFirstResponder(_:) can fail when the existing responder refuses to resign. Apple further notes that a successful return can leave the window itself as first responder if the requested responder does not accept focus. Checking window.firstResponder === textView verifies the actual outcome before scrolling. First-responder return values and behavior.
If the editor is not attached to a window, this sample consumes the request without acting. Press the button again after it is attached. For an initial-focus feature, design an explicit attachment-aware request rather than repeatedly reclaiming focus during unrelated updates.
Configure color without replacing cursor drawing
The example assigns .textColor to both the text and insertion point, and .textBackgroundColor to the editor background. These are documented semantic colors for text and the area behind it. insertionPointColor is the documented property for setting the caret color. Check the resulting appearance on each macOS version and appearance your app supports. Caret color, text color, text background color.
Color configuration does not request focus, scroll the document, or choose a nonblinking display mode. Keep those responsibilities separate when diagnosing a missing caret. For example, changing the color cannot reveal a position that remains outside the scroll view’s visible area.
The sample also avoids accessing layoutManager to calculate caret geometry. Apple documents that accessing this property on a text view or text container can switch the view into compatibility mode using NSLayoutManager on macOS 12 and later. Calling the high-level scrolling API avoids introducing that TextKit compatibility change just to reveal the selection. TextKit compatibility behavior.
Understand the limit of an always-visible caret
If “keep visible” means “never blink,” the scrolling command does not meet that requirement. Apple’s modern cursor documentation distinguishes automatic animation from an always-visible display mode.
On macOS 14 and later, NSTextInsertionIndicator provides .automatic, .visible, and .hidden display modes. Apple documents .visible for showing a cursor continuously with animations disabled, such as indicating a drag destination. Standard AppKit text views automatically use the system cursor UI. System text cursor adoption guide, NSTextInsertionIndicator.
The documented custom-cursor path requires creating and positioning your own indicator. Its display mode controls that instance; Apple’s guide does not expose a setting on NSTextView for changing its internally managed indicator to .visible. Treat the custom path as an architectural choice for custom text UI, including responsibility for updating cursor geometry and active state. It is not a drop-in change to this wrapper.
Two older-looking APIs do not supply that missing setting. shouldDrawInsertionPoint is documented as read-only. drawInsertionPoint(in:color:turnedOn:) draws or erases the cursor, and Apple says callers should not need to invoke it directly. Neither is documented as an always-visible switch. Insertion-point drawing state, Insertion-point drawing method.
For the ordinary editor task in this article, retain AppKit’s cursor animation and provide a reliable way to return to the current position. A custom cursor implementation needs its own design and, when using NSTextInsertionIndicator, a macOS 14 availability boundary.
Verify the behavior in your app
Use the sample to separate scrolling, selection, focus, and appearance checks. The following are expected behaviors derived from the implementation, not reported execution results.
- Launch the app and press
Reveal insertion point. The editor should receive focus and reveal its end position. - Click within an earlier row to move the caret. Scroll away without clicking another text position, then press the button. It should reveal that earlier position.
- Select several words and press the button. The selection should remain selected; the command does not collapse it into a caret.
- Type a distinctive label such as
orange triangle, then press the button several times. The edited contents should remain intact. - Resize the window and repeat the reveal operation. The command should still use the current range rather than an old screen coordinate.
- Check both light and dark appearances on supported macOS releases. Confirm that text, background, and caret colors remain usable together.
If scrolling succeeds but the caret still appears intermittently, check whether you are observing normal blinking or an active text selection. If the command does nothing, inspect the window-attachment and first-responder guards first. Keep the repair tied to the failing step: preserve the text, restore focus when requested, and reveal the current range.
Sources
- NSViewRepresentable — Apple
- NSTextView — Apple
- scrollableTextView() — Apple
- scrollRangeToVisible(_:) — Apple
- makeFirstResponder(_:) — Apple
- length — Apple
- String.UTF16View — Apple
- count — Apple
- insertionPointColor — Apple
- textColor — Apple
- textBackgroundColor — Apple
- Adopting the system text cursor in custom text views — Apple
- NSTextInsertionIndicator — Apple
- shouldDrawInsertionPoint — Apple
- drawInsertionPoint(in:color:turnedOn:) — Apple