Para enviar um comando de menu do SwiftUI à NSTextView ativa, obtenha o primeiro respondente da janela principal quando o comando for executado e, em seguida, envie a ação a essa view. Este tutorial cria um exemplo com dois editores, no qual o comando Select First Line afeta somente o editor em foco.

A tarefa é uma ação de menu personalizada no seu próprio app para macOS. O exemplo usa texto sintético, uma subclasse de NSTextView pertencente ao app e APIs públicas do SwiftUI e do AppKit. A principal decisão de design é se um comando deve seguir a cadeia de respondentes do AppKit ou exigir um editor ativo específico.

Escolha o que significa “view de texto ativa”

Neste exemplo, o editor ativo é a instância de SampleTextView que atualmente é o primeiro respondente da janela principal. A Apple define a janela principal como a janela que recebe eventos do teclado; seu firstResponder identifica o respondente que normalmente recebe eventos e ações primeiro. Ler essas propriedades no momento da invocação dá ao comando seu destino atual. Documentação da Apple sobre keyWindow, documentação da Apple sobre firstResponder.

O AppKit também aceita ações sem alvo: passe nil como alvo para NSApplication.sendAction(_:to:from:), e o aplicativo procura um respondente que implemente o seletor. Essa busca começa no primeiro respondente da janela principal, mas pode continuar pelos delegates e pela janela principal antes de chegar aos handlers no nível do aplicativo. Portanto, uma ação sem alvo não significa “executar somente na view de texto ativa desta janela principal”. Documentação da Apple sobre o encaminhamento de ações.

Comportamento necessário Opção de encaminhamento Consequência
Permitir que o AppKit encontre um handler elegível Enviar a ação com to: nil Aplica-se o fallback normal da cadeia de respondentes.
Afetar somente o editor ativo pertencente ao app Obter e verificar o tipo do primeiro respondente e, em seguida, defini-lo como alvo explicitamente O comando é interrompido se esse editor não estiver ativo.
Operar em um editor específico, independentemente do foco Fornecer esse editor como alvo Essa interação é diferente de um comando para o editor ativo.

A implementação abaixo escolhe o segundo comportamento. A verificação da subclasse personalizada também impede que o editor de um campo de texto não relacionado se torne o destino apenas por ser uma view de texto.

Crie o exemplo completo com SwiftUI e AppKit

Crie um projeto de app para macOS com SwiftUI e, para este exemplo, use macOS 13 ou posterior como destino de implantação. Substitua o ponto de entrada gerado do app pelo arquivo Swift único a seguir; remova qualquer outra declaração @main. O programa fornece os dois editores, o texto inicial de cada um, o menu e o handler da ação.

NSViewRepresentable do SwiftUI é a ponte compatível para incorporar uma view do AppKit. Os comandos de cena fornecem o conteúdo do menu, e CommandMenu cria um menu específico do app na barra de menus do macOS. Documentação da Apple sobre NSViewRepresentable, comandos de cena, menus 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 é um exemplo completo de app com conteúdo dos editores armazenado em memória. Ele foi conferido com a documentação da API consultada; não foi compilado nem executado aqui porque o ambiente disponível é Linux e não tem uma toolchain do Swift.

Por que o seletor chega ao editor pretendido

A função de encaminhamento obtém o primeiro respondente imediatamente antes do envio. Ela aceita somente SampleTextView e fornece esse objeto exato como alvo da ação. Assim, o menu não mantém uma referência ao editor em que se clicou anteriormente nem procura em outra janela quando a verificação de tipo falha.

O método da ação usa @objc porque o envio de ações por alvo e ação usa seletores do Objective-C. A expressão #selector referencia o método declarado em vez de uma string de seletor escrita manualmente. A Apple documenta essa combinação para chamar métodos do Swift pelas APIs de alvo e ação do Objective-C. Usar recursos do runtime do Objective-C no Swift.

O valor retornado por sendAction indica se a mensagem foi enviada. Ele não indica se o handler alterou a seleção: o handler pode receber a mensagem e retornar antecipadamente quando o conteúdo está vazio ou há texto marcado. Este exemplo descarta intencionalmente o resultado do envio porque o destino e o método já são conhecidos. Documentação da Apple sobre o valor de retorno de sendAction.

O que o comando de seleção realmente seleciona

O handler pede a NSString.lineRange(for:) o intervalo da linha lógica que contém o início da string. Esse intervalo inclui o terminador da linha, quando presente. Assim, ele seleciona a primeira linha delimitada por nova linha, mesmo quando essa linha ocupa várias linhas visuais na tela. Documentação da Apple sobre intervalos de linha.

Com o texto fornecido, o editor da esquerda seleciona Sample Alpha e a nova linha seguinte; o editor da direita seleciona Sample Beta e a nova linha seguinte. O comando substitui a seleção anterior. Ele não altera o texto.

setSelectedRange(_:) aplica a seleção, e scrollRangeToVisible(_:) traz seus primeiros caracteres para a área visível. A verificação de conteúdo vazio evita tentar uma operação de seleção sem texto. A verificação de texto marcado ilustra uma política explícita deste exemplo: adiar essa ação de seleção personalizada enquanto a composição de texto estiver ativa. Intervalos de seleção, rolagem de um intervalo até a área visível, texto marcado.

Mantenha separados a criação da view, a edição e o foco

O representable cria um NSScrollView e instala a view de texto personalizada como sua view de documento. O editor cresce verticalmente, enquanto o contêiner de texto acompanha sua largura. Isso segue a configuração documentada pela Apple para uma view de texto rolável. Essa configuração aparece no guia arquivado do AppKit da Apple; a ponte do SwiftUI e as APIs de ação citadas aqui usam a documentação atual. Inserir um objeto NSTextView em um NSScrollView.

O SwiftUI controla o layout da view de rolagem representada. O código configura a geometria inicial da view de documento aninhada e o comportamento de redimensionamento do AppKit; ele não atribui repetidamente um frame à view de rolagem representada. A Apple alerta especificamente para não alterar as propriedades de layout que o SwiftUI gerencia para um representable. Orientações da Apple sobre o layout de representables.

O exemplo atribui initialText somente durante a criação. Seu método de atualização vazio deixa deliberadamente a digitação subsequente sob o controle do AppKit. Se seu aplicativo precisar de um modelo de documento do SwiftUI, adicione um coordenador e sincronização baseada em delegate, com uma política definida para alterações no modelo recebidas durante a edição. Atribuir novamente a string inicial em cada atualização substituiria o conteúdo atual do usuário. A Apple descreve os coordenadores como a ponte para encaminhar interações do AppKit ao SwiftUI. Orientações da Apple sobre coordenação de representables.

Clicar em um editor estabelece o foco para o passo a passo. Se seu app precisar mover o foco programaticamente, use makeFirstResponder(_:) da janela que contém a view depois que ela pertencer a essa janela. A Apple diz explicitamente para não chamar diretamente becomeFirstResponder() da view de texto. Evite solicitar foco em cada atualização do SwiftUI, pois isso redirecionaria repetidamente o destino de edição do usuário. Orientações da Apple sobre o foco de views de texto.

Preserve sua configuração existente do TextKit

O encaminhamento do menu não exige alterar o mecanismo de layout do texto. Este exemplo usa NSTextView(frame:); a Apple documenta que esse inicializador usa NSLayoutManager por padrão. A construção explícita com NSTextLayoutManager é compatível desde o macOS 12. Documentação da Apple sobre inicialização de views de texto.

Para um editor existente do TextKit 2, mantenha sua construção e adicione a ação à subclasse. Evite ler layoutManager apenas para localizar ou encaminhar ao editor: a Apple documenta que acessar essa propriedade em uma view de texto do TextKit 2 pode colocá-la no modo de compatibilidade. O código de encaminhamento precisa apenas do respondente e do método da ação. Documentação da Apple sobre o modo de compatibilidade de NSTextView.

Verifique o encaminhamento e trate comandos indisponíveis

Use esta sequência para verificar o comportamento esperado no macOS. Estes são resultados esperados com base na implementação, não resultados de uma execução relatada.

  1. Clique no editor da esquerda e escolha Sample Actions → Select First Line. A primeira linha lógica do editor da esquerda deverá ser selecionada.
  2. Clique no editor da direita e invoque o mesmo comando. Somente a seleção do editor da direita deverá mudar.
  3. Clique em Window label e invoque o comando. Os dois editores personalizados deverão permanecer inalterados porque o primeiro respondente não passa na verificação da subclasse.
  4. Apague todo o conteúdo de um editor e invoque o comando nesse editor. A verificação de conteúdo vazio deverá retornar sem alterar o texto.
  5. Adicione várias linhas, role para baixo e invoque o comando. A primeira linha deverá ser selecionada e trazida para a área visível.

Neste exemplo mínimo, o comando permanece habilitado, inclusive quando o foco está em outro lugar. A verificação feita no momento da invocação determina se ele será executado. Ler keyWindow?.firstResponder somente dentro de uma expressão .disabled do SwiftUI não estabeleceria, por si só, um mecanismo para atualizar essa expressão sempre que o foco do AppKit mudasse.

Para uma interface SwiftUI maior, publique o recurso de comando apropriado usando um estado que acompanhe o foco, observe-o com @FocusedValue e derive a condição de .disabled do botão desse estado. A ponte do AppKit deve manter o recurso alinhado com o foco real do editor. Mantenha também a verificação feita no momento da invocação. A Apple documenta FocusedValue como uma forma de observar valores da view em foco ou de seus ancestrais. Valores em foco, desabilitar a interação.

Se, em vez disso, você criar um item de menu do AppKit cujo alvo seja o editor, validateMenuItem(_:) fornece o mecanismo de validação do AppKit. Adicionar esse método a esta subclasse não validaria, por si só, o botão do SwiftUI: a ação dele é um closure que depois envia outra ação. A Apple exige que o objeto de validação seja o alvo do item de menu. Documentação da Apple sobre validação de itens de menu.

Escolha a política de alvo antes de adicionar mais comandos. Use uma ação sem alvo quando o fallback da cadeia de respondentes fizer parte do comportamento desejado; obtenha e defina como alvo o primeiro respondente quando a operação pertencer exclusivamente ao editor ativo. Essa escolha mantém o menu previsível à medida que os usuários alternam entre views de texto e outros controles.

Fontes