要在 Mac 应用封装的 NSTextView 中显示插入点,请恢复键盘焦点,并使用当前选区调用 scrollRangeToVisible(_:)。插入点颜色需单独配置。让插入点保持在屏幕内与让它停止闪烁需要使用不同的控制方式。

本指南将构建一个带有 Reveal insertion point 按钮的小型本地编辑器。它解决了常见的桌面操作需求:在文档中滚动到其他位置后,返回当前编辑位置。此实现会保留当前选区,并让 AppKit 管理常规文本输入和光标绘制。

找出插入点不可见的原因

先观察可见的症状。滚动、焦点、选区和光标动画可能会造成相似的现象,但每种情况都需要不同的处理方式。

观察到的现象 检查内容 适当的操作
编辑位置位于文档的可见区域之外 文本视图的当前选区 将该范围滚动到可见区域
点击其他控件后,编辑器停止输入 窗口的第一响应者 按需显式恢复焦点
插入点难以与背景区分 插入点和背景颜色 配置文本视图的颜色属性
显示的是高亮文本,而非单个插入点 选定范围的长度是否大于零 保留选区,除非用户要求移动插入点
编辑器处于活动状态时,插入点时隐时现 常规光标动画 将闪烁与滚动分开处理

Apple 将 scrollRangeToVisible(_:) 说明为:滚动包含该文本视图的滚动视图,直到所提供范围中的首字符可见。它不保证将该范围居中,也不保证完整显示较长的选区。因此,它适合用于显示插入位置,无需自行计算字形矩形。Apple 的滚动文档。

焦点由所属窗口管理。Apple 明确要求调用方使用 window.makeFirstResponder(textView),而不是直接调用文本视图的 becomeFirstResponder()。下面的实现遵循这一方式,并检查实际成为第一响应者的对象。Apple 的 NSTextView 文档、第一响应者切换文档。

构建带有显示命令的本地编辑器

在 Xcode 中使用 SwiftUI 和 Swift 创建一个 macOS App 项目。本示例将部署目标设为 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 是 Apple 提供的桥接机制,用于在 SwiftUI 层级中管理 AppKit 视图。此处被封装的视图是 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())
    }
}

编辑器初始时会在示例文本末尾设置一个长度为零的选区。按下 Reveal insertion point 后,编辑器会获得焦点,并显示该位置。之后的按键操作会使用实时选区,因此,将插入点移至其他位置后,命令显示的位置也会随之改变。该命令不会替换文本,也不会移动选区。

末尾位置使用 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 会响应请求编号的变化,同时不触碰文档内容。这符合 Apple 所述的 representable 生命周期,即创建和更新 AppKit 视图。SwiftUI 还会控制被封装视图的布局,因此,此封装不会为滚动视图指定 frame 或 bounds。Apple 关于 representable 生命周期和布局的指南。

在本示例中,避免每次更新都设置 textView.string = initialText。否则,用户当前编辑的文档会被原始示例文本替换。同样,无条件地将选区设到文档末尾,会导致显示命令移动编辑位置。

如果编辑器由 SwiftUI 状态驱动,请添加明确的双向同步:通过协调器传递用户编辑,并定义何时用传入的模型变更替换文本。替换时应保留有效选区,并定义如何处理并发变更。这是独立于显示插入点的职责;本示例有意让一个对象负责可编辑内容。Apple 将协调器说明为把委托和 target-action 交互传递到 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 的可用性边界。

在应用中验证行为

使用此示例分别检查滚动、选区、焦点和外观。以下是根据实现推导出的预期行为,并非实际运行结果。

  1. 启动应用并按下 Reveal insertion point。编辑器应获得焦点并显示文档末尾的位置。
  2. 点击较早的一行,将插入点移到那里。不要点击其他文本位置,直接滚动离开,然后按下按钮。编辑器应显示该较早的位置。
  3. 选中几个词并按下按钮。选区应保持选中;该命令不会将其折叠成插入点。
  4. 输入一个独特的标签,例如 orange triangle,然后多次按下按钮。编辑内容应保持完整。
  5. 调整窗口大小并再次执行显示操作。命令仍应使用当前范围,而不是过时的屏幕坐标。
  6. 在支持的 macOS 版本上检查浅色和深色外观。确认文本、背景和插入点颜色搭配后仍清晰可辨。

如果滚动成功,但插入点仍时隐时现,请检查你看到的是正常闪烁,还是活动文本选区。如果命令没有效果,请先检查窗口附加状态和第一响应者检查。应针对出错的步骤进行修复:保留文本、按需恢复焦点,并显示当前范围。

来源