Para enviar un comando de menú de SwiftUI al NSTextView activo, resuelve el primer respondedor de la ventana clave cuando se ejecute el comando y, luego, envía la acción a esa vista. En este tutorial se crea un ejemplo con dos editores, donde el comando Select First Line solo afecta al editor que tiene el foco.
La tarea consiste en crear una acción de menú personalizada dentro de tu propia app para macOS. El ejemplo usa texto sintético, una subclase de NSTextView propia de la app y las API públicas de SwiftUI y AppKit. La decisión clave de diseño es si un comando debe seguir la cadena de respondedores de AppKit o requerir un editor activo específico.
Elige qué significa «vista de texto activa»
En este ejemplo, el editor activo es la instancia de SampleTextView que actualmente es el primer respondedor de la ventana clave. Apple define la ventana clave como la ventana que recibe eventos de teclado; su firstResponder identifica al respondedor que normalmente recibe primero los eventos y las acciones. Leer estas propiedades en el momento de la invocación proporciona al comando su destino actual. Documentación de Apple sobre keyWindow, documentación de Apple sobre firstResponder.
AppKit también admite acciones sin destino: pasa nil como destino a NSApplication.sendAction(_:to:from:), y la aplicación busca un respondedor que implemente el selector. La búsqueda comienza en el primer respondedor de la ventana clave, pero puede continuar por los delegados y la ventana principal antes de llegar a los controladores de nivel de aplicación. Por consiguiente, una acción sin destino no significa «ejecutar solo en la vista de texto activa de esta ventana clave». Documentación de Apple sobre el enrutamiento de acciones.
| Comportamiento requerido | Opción de enrutamiento | Consecuencia |
|---|---|---|
| Permitir que AppKit encuentre un controlador apto | Enviar la acción con to: nil |
Se aplica el comportamiento normal de la cadena de respondedores. |
| Afectar solo al editor activo de la app | Resolver y comprobar el tipo del primer respondedor y, luego, dirigir la acción explícitamente a él | El comando se detiene si ese editor no está activo. |
| Operar en un editor concreto independientemente del foco | Proporcionar ese editor como destino | Esta interacción difiere de un comando para el editor activo. |
La implementación siguiente elige el segundo comportamiento. Comprobar también la subclase personalizada evita que el editor de un campo de texto ajeno se convierta en el destino solo porque sea una vista de texto.
Crea el ejemplo completo de SwiftUI y AppKit
Crea un proyecto de app para macOS con SwiftUI y, para este ejemplo, usa como destino de implementación macOS 13 o posterior. Reemplaza el punto de entrada generado de la app por el siguiente archivo Swift único; elimina cualquier otra declaración @main. El programa proporciona ambos editores, su texto inicial, el menú y el controlador de la acción.
NSViewRepresentable de SwiftUI es el puente compatible para incrustar una vista de AppKit. Los comandos de escena proporcionan el contenido del menú, y CommandMenu crea un menú específico de la app en la barra de menús de macOS. Documentación de Apple sobre NSViewRepresentable, comandos de escena, menús de comandos.
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.
}
}
Este es un ejemplo completo de app con contenido de editor en memoria. Se revisó comparándolo con la documentación de API consultada; aquí no se compiló ni ejecutó porque el entorno disponible es Linux y no tiene toolchain de Swift.
Por qué el selector llega al editor previsto
La función de enrutamiento obtiene el primer respondedor justo antes de despachar la acción. Solo acepta SampleTextView y proporciona ese objeto exacto como destino de la acción. Por tanto, el menú no conserva el editor en el que se hizo clic anteriormente ni busca en otra ventana si falla la comprobación de tipo.
El método de acción usa @objc porque el despacho de acciones por destino usa selectores de Objective-C. La expresión #selector hace referencia al método declarado en lugar de usar una cadena de selector escrita a mano. Apple documenta esta combinación para invocar métodos de Swift mediante las API de acciones por destino de Objective-C. Uso de funciones del entorno de ejecución de Objective-C en Swift.
El valor de retorno de sendAction indica si se envió el mensaje. No indica si el controlador modificó la selección: puede recibir el mensaje y salir antes si el contenido está vacío o hay texto marcado. En este ejemplo se descarta deliberadamente el resultado del despacho porque ya se conocen el destino y el método. Documentación de Apple sobre el valor de retorno de sendAction.
Qué selecciona realmente el comando de selección
El controlador pide a NSString.lineRange(for:) el rango de la línea lógica que contiene el inicio de la cadena. Ese rango incluye su terminador de línea, si lo hay. Por tanto, selecciona la primera línea delimitada por saltos de línea, aunque esta se ajuste y ocupe varias filas visuales. Documentación de Apple sobre rangos de línea.
Con el texto proporcionado, el editor izquierdo selecciona Sample Alpha y el salto de línea que le sigue; el editor derecho selecciona Sample Beta y el salto de línea que le sigue. El comando reemplaza la selección anterior. No modifica el texto.
setSelectedRange(_:) aplica la selección y scrollRangeToVisible(_:) desplaza la vista para mostrar sus primeros caracteres. La comprobación de contenido vacío evita intentar una operación de selección cuando no hay texto. La comprobación de texto marcado es una política explícita del ejemplo: posponer esta acción de selección personalizada mientras haya una composición de entrada activa. Rangos de selección, desplazar un rango para que quede visible, texto marcado.
Mantén separadas la creación de la vista, la edición y el foco
El representable crea un NSScrollView e instala la vista de texto personalizada como su vista de documento. El editor crece verticalmente y su contenedor de texto sigue el ancho de la vista. Esto sigue la disposición documentada por Apple para las vistas de texto desplazables. Esa disposición aparece en la guía archivada de AppKit de Apple; el puente de SwiftUI y las API de acciones citadas aquí se describen en la documentación actual. Cómo colocar un objeto NSTextView en un NSScrollView.
SwiftUI controla la disposición de la vista de desplazamiento representada. El código configura la geometría inicial de la vista de documento anidada y el comportamiento de cambio de tamaño de AppKit; no asigna repetidamente el marco de la vista de desplazamiento representada. Apple advierte específicamente que no se cambien las propiedades de disposición que SwiftUI administra para un representable. Orientación de Apple sobre la disposición de representables.
El ejemplo asigna initialText solo durante la creación. Su método de actualización vacío deja deliberadamente las pulsaciones posteriores bajo el control de AppKit. Si tu aplicación necesita un modelo de documento de SwiftUI, añade un coordinador y sincronización basada en delegados, con una política definida para los cambios del modelo que lleguen durante la edición. Volver a asignar la cadena inicial en cada actualización reemplazaría el contenido actual del usuario. Apple describe los coordinadores como el puente para transmitir las interacciones de AppKit a SwiftUI. Orientación de Apple sobre la coordinación de representables.
Al hacer clic en un editor se establece el foco para este recorrido. Si tu app necesita cambiar el foco mediante programación, usa makeFirstResponder(_:) de la ventana contenedora una vez que la vista pertenezca a esa ventana. Apple indica explícitamente que no se debe invocar directamente becomeFirstResponder() de la vista de texto. Evita solicitar el foco en cada actualización de SwiftUI, ya que eso redirigiría repetidamente el destino de edición del usuario. Orientación de Apple sobre el foco de las vistas de texto.
Conserva tu configuración existente de TextKit
El enrutamiento del menú no requiere cambiar el motor de disposición del texto. Este ejemplo usa NSTextView(frame:); Apple documenta que ese inicializador usa NSLayoutManager de forma predeterminada. La construcción explícita con NSTextLayoutManager es compatible desde macOS 12. Documentación de Apple sobre la inicialización de vistas de texto.
Si ya tienes un editor con TextKit 2, conserva su construcción y añade la acción a su subclase. Evita leer layoutManager solo para localizar el editor o dirigirle una acción: Apple documenta que acceder a esa propiedad en una vista de texto de TextKit 2 puede cambiarla al modo de compatibilidad. El código de enrutamiento solo necesita el respondedor y el método de acción. Documentación de Apple sobre el modo de compatibilidad de NSTextView.
Verifica el enrutamiento y gestiona los comandos no disponibles
Sigue esta secuencia para comprobar el comportamiento previsto en macOS. Estos son resultados esperados según la implementación, no resultados de una ejecución reportada.
- Haz clic en el editor izquierdo y, luego, selecciona
Sample Actions→Select First Line. Debería seleccionarse la primera línea lógica del editor izquierdo. - Haz clic en el editor derecho e invoca el mismo comando. Solo debería cambiar la selección del editor derecho.
- Haz clic en
Window labele invoca el comando. Ambos editores personalizados deberían permanecer sin cambios porque el primer respondedor no supera la comprobación de subclase. - Elimina todo el contenido de un editor e invoca el comando allí. La comprobación de contenido vacío debería terminar la ejecución sin cambiar el texto.
- Añade varias líneas, desplázate hacia abajo e invoca el comando. La primera línea debería seleccionarse y desplazarse hasta quedar visible.
El comando permanece habilitado en este ejemplo mínimo, incluso cuando el foco está en otra parte. La comprobación que se realiza al invocarlo determina si actúa. Leer keyWindow?.firstResponder solo dentro de una expresión .disabled de SwiftUI no establecería un mecanismo para actualizar esa expresión cada vez que cambie el foco de AppKit.
Para una interfaz SwiftUI más grande, publica una capacidad de comando adecuada mediante un estado que tenga en cuenta el foco, obsérvala con @FocusedValue y deriva de ese estado la condición .disabled del botón. El puente de AppKit debe mantener esa capacidad sincronizada con el foco real del editor. Conserva también la comprobación que se realiza al invocarlo. Apple documenta FocusedValue como un mecanismo para observar valores de la vista enfocada o de sus antecesoras. Valores enfocados, desactivar la interacción.
Si, en cambio, creas un elemento de menú de AppKit cuyo destino sea el editor, validateMenuItem(_:) proporciona el mecanismo de validación de AppKit. Añadir ese método a esta subclase no validaría por sí solo el botón de SwiftUI: su acción es un cierre que después despacha otra acción. Apple exige que el objeto de validación sea el destino del elemento de menú. Documentación de Apple sobre la validación de elementos de menú.
Elige la política de destino antes de añadir más comandos. Usa una acción sin destino cuando el comportamiento deseado incluya la alternativa de la cadena de respondedores; resuelve y asigna como destino el primer respondedor cuando la operación corresponda exclusivamente al editor activo. Esa elección hace que el mismo menú siga siendo predecible cuando los usuarios alternan entre vistas de texto y otros controles.
Fuentes
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- Uso de funciones del entorno de ejecución de Objective-C en Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- Cómo colocar un objeto NSTextView en un NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple