Om een SwiftUI-menuknop naar de actieve NSTextView te sturen, haalt u op het moment dat de opdracht wordt uitgevoerd de first responder van het key window op en stuurt u de actie vervolgens naar die view. In deze tutorial bouwen we een voorbeeld met twee editors waarin de opdracht Select First Line alleen invloed heeft op de editor die de focus heeft.

De taak is een aangepaste menuactie in uw eigen macOS-app. Het voorbeeld gebruikt synthetische tekst, een NSTextView-subklasse van de app en openbare SwiftUI- en AppKit-API's. De belangrijkste ontwerpkeuze is of een opdracht de responderketen van AppKit moet volgen of slechts één specifieke actieve editor mag gebruiken.

Bepaal wat ‘actieve tekstview’ betekent

In dit voorbeeld is de actieve editor de SampleTextView-instantie die op dat moment de first responder van het key window is. Apple definieert het key window als het venster dat toetsenbordgebeurtenissen ontvangt; de firstResponder ervan identificeert de responder die gebeurtenissen en acties normaal gesproken als eerste ontvangt. Door deze eigenschappen op het moment van aanroepen uit te lezen, krijgt de opdracht de actuele bestemming. Apples documentatie over keyWindow, Apples documentatie over firstResponder.

AppKit ondersteunt ook acties zonder doel: geef nil als doel door aan NSApplication.sendAction(_:to:from:), waarna de applicatie zoekt naar een responder die de selector implementeert. Die zoekopdracht begint bij de first responder van het key window, maar kan via delegates en het hoofdvenster doorgaan tot handlers op applicatieniveau. Een actie zonder doel betekent dus niet: ‘voer dit uitsluitend uit op de actieve tekstview van dit key window’. Apples documentatie over actieroutering.

Gewenst gedrag Routeringskeuze Gevolg
AppKit een geschikte handler laten vinden Stuur de actie met to: nil De normale terugval via de responderketen geldt.
Alleen de actieve editor van de app beïnvloeden Haal de first responder op, controleer het type en stel die expliciet als doel in De opdracht stopt als die editor niet actief is.
Een bepaalde editor bedienen, ongeacht de focus Geef die editor als doel op Dit is een andere interactie dan een opdracht voor de actieve editor.

De implementatie hieronder kiest het tweede gedrag. De controle op de aangepaste subklasse voorkomt ook dat de editor van een niet-gerelateerd tekstveld de bestemming wordt, alleen omdat het een tekstview is.

Bouw het volledige SwiftUI- en AppKit-voorbeeld

Maak een macOS-appproject met SwiftUI en gebruik voor dit voorbeeld een deployment target van macOS 13 of hoger. Vervang het gegenereerde app-entrypoint door het volgende Swift-bestand; verwijder alle andere @main-declaraties. Het programma levert beide editors, hun begintekst, het menu en de actiehandler.

SwiftUI's NSViewRepresentable is de ondersteunde brug om een AppKit-view in te sluiten. Scèneopdrachten leveren de menu-inhoud, en CommandMenu maakt een appspecifiek menu in de macOS-menubalk. Apples documentatie over NSViewRepresentable, scèneopdrachten, opdrachtmenu's.

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.
    }
}

Dit is een volledig appvoorbeeld met editorinhoud die alleen in het geheugen staat. Het is gecontroleerd aan de hand van de opgehaalde API-documentatie; het is hier niet gecompileerd of uitgevoerd, omdat de beschikbare omgeving Linux is en geen Swift-toolchain heeft.

Waarom de selector de bedoelde editor bereikt

De routeringsfunctie haalt de first responder op direct voordat de actie wordt verstuurd. De functie accepteert alleen SampleTextView en geeft precies dat object op als actiedoel. Het menu houdt daardoor niet vast welke editor eerder is aangeklikt en zoekt geen ander venster op als de typecontrole mislukt.

De actiemethode gebruikt @objc omdat target-action-dispatch Objective-C-selectors gebruikt. De expressie #selector verwijst naar de gedeclareerde methode in plaats van naar een zelfgeschreven selectorstring. Apple documenteert deze combinatie voor het aanroepen van Swift-methoden via Objective-C-target-action-API's. Objective-C-runtimefuncties gebruiken in Swift.

De retourwaarde van sendAction geeft aan of het bericht is verstuurd. Die geeft niet aan of de handler de selectie heeft gewijzigd: de handler kan het bericht ontvangen en vroegtijdig terugkeren als de inhoud leeg is of gemarkeerde tekst bevat. Dit voorbeeld negeert de dispatchresultaat bewust, omdat de bestemming en methode al bekend zijn. Apples documentatie over de retourwaarde van sendAction.

Wat de selectieopdracht precies selecteert

De handler vraagt NSString.lineRange(for:) naar de logische regel met het begin van de tekenreeks. Dat bereik bevat de regeleinde, als die aanwezig is. Daardoor selecteert de opdracht de eerste regel, gescheiden door een newline, ook als die regel over meerdere zichtbare rijen doorloopt. Apples documentatie over regelbereiken.

Bij de opgegeven tekst selecteert de linkereditor Sample Alpha en de daaropvolgende newline; de rechtereditor selecteert Sample Beta en de daaropvolgende newline. De opdracht vervangt de vorige selectie. De tekst wordt niet gewijzigd.

setSelectedRange(_:) past de selectie toe, en scrollRangeToVisible(_:) brengt de eerste tekens ervan in beeld. De controle op lege inhoud voorkomt een selectiehandeling zonder tekst. De controle op gemarkeerde tekst is een expliciete beleidskeuze in dit voorbeeld: stel deze aangepaste selectieactie uit zolang de invoercompositie actief is. Selectiebereiken, een bereik in beeld scrollen, gemarkeerde tekst.

Houd view-aanmaak, bewerken en focus gescheiden

De representable maakt een NSScrollView aan en stelt de aangepaste tekstview in als documentview. De editor groeit verticaal, terwijl de tekstcontainer de breedte volgt. Dit volgt Apples gedocumenteerde opstelling voor een scrollende tekstview. Die opstelling staat in Apples gearchiveerde AppKit-handleiding; de hier aangehaalde SwiftUI-brug- en actie-API's zijn gebaseerd op actuele documentatie. Een NSTextView-object in een NSScrollView plaatsen.

SwiftUI beheert de lay-out van de gerepresenteerde scrollview. De code stelt de begingeometrie en het AppKit-formaatgedrag van de geneste documentview in; het frame van de gerepresenteerde scrollview wordt niet telkens opnieuw toegewezen. Apple waarschuwt specifiek tegen het wijzigen van de lay-outeigenschappen die SwiftUI voor een representable beheert. Apples richtlijnen voor de lay-out van representables.

Het voorbeeld stelt initialText alleen tijdens het aanmaken in. De lege updatemethode laat latere invoer bewust over aan AppKit. Als uw applicatie een SwiftUI-documentmodel nodig heeft, voegt u een coordinator en synchronisatie op basis van een delegate toe, met een vastgelegd beleid voor modelwijzigingen die tijdens het bewerken binnenkomen. De begintekenreeks bij elke update opnieuw toewijzen zou de huidige inhoud van de gebruiker vervangen. Apple beschrijft coordinators als de brug waarmee AppKit-interacties naar SwiftUI worden doorgegeven. Apples richtlijnen voor representable-coördinatie.

In de walkthrough geeft het klikken op een editor die editor de focus. Als uw app de focus programmatisch moet verplaatsen, gebruikt u makeFirstResponder(_:) van het omvattende venster nadat de view aan dat venster is gekoppeld. Apple zegt expliciet dat u de becomeFirstResponder() van de tekstview niet rechtstreeks moet aanroepen. Vraag niet bij elke SwiftUI-update om focus, want daarmee zou de bewerkingsbestemming van de gebruiker telkens opnieuw worden verplaatst. Apples richtlijnen voor focus van tekstviews.

Behoud uw bestaande TextKit-configuratie

Menuroutering vereist geen wijziging van de tekstlay-outengine. Dit voorbeeld gebruikt NSTextView(frame:); Apple documenteert dat deze initializer standaard NSLayoutManager gebruikt. Expliciete constructie met NSTextLayoutManager wordt ondersteund vanaf macOS 12. Apples documentatie over het initialiseren van tekstviews.

Behoud voor een bestaande TextKit 2-editor de huidige constructie en voeg de actie toe aan de subklasse. Lees layoutManager niet alleen uit om de editor te vinden of ernaartoe te routeren: Apple documenteert dat het openen van deze eigenschap op een TextKit 2-tekstview die naar de compatibiliteitsmodus kan overschakelen. De routeringscode heeft alleen de responder en de actiemethode nodig. Apples documentatie over de compatibiliteitsmodus van NSTextView.

Controleer de routering en handel niet-beschikbare opdrachten af

Gebruik deze stappen om het bedoelde gedrag op macOS te controleren. Dit zijn verwachte resultaten op basis van de implementatie, geen gerapporteerde uitvoeringsresultaten.

  1. Klik in de linkereditor en kies vervolgens Sample Actions → Select First Line. De eerste logische regel van de linkereditor moet worden geselecteerd.
  2. Klik in de rechtereditor en voer dezelfde opdracht uit. Alleen de selectie in de rechtereditor moet veranderen.
  3. Klik in Window label en voer de opdracht uit. Beide aangepaste editors moeten ongewijzigd blijven, omdat de first responder niet slaagt voor de controle op de subklasse.
  4. Verwijder alle inhoud uit een editor en voer de opdracht daarin uit. De controle op lege inhoud moet terugkeren zonder de tekst te wijzigen.
  5. Voeg meerdere regels toe, scrol omlaag en voer de opdracht uit. De eerste regel moet worden geselecteerd en in beeld worden gescrold.

In dit minimale voorbeeld blijft de opdracht ingeschakeld, ook als de focus ergens anders ligt. De controle op het moment van aanroepen bepaalt of de opdracht iets doet. Alleen keyWindow?.firstResponder uitlezen in een SwiftUI-expressie voor .disabled biedt op zichzelf geen mechanisme om die expressie bij elke focuswijziging in AppKit bij te werken.

Publiceer in een grotere SwiftUI-interface een geschikte opdrachtmogelijkheid via focusbewuste status, observeer die met @FocusedValue en leid de voorwaarde voor .disabled af uit die status. De AppKit-brug moet deze mogelijkheid in overeenstemming houden met de daadwerkelijke editorfocus. Behoud ook de controle op het moment van aanroepen. Apple documenteert FocusedValue als een manier om waarden van de view met focus of diens bovenliggende views te observeren. Gefocuste waarden, interactie uitschakelen.

Als u in plaats daarvan een AppKit-menu-item bouwt waarvan het doel de editor is, biedt validateMenuItem(_:) de validatiehook van AppKit. Die methode aan deze subklasse toevoegen valideert op zichzelf de SwiftUI-knop niet: de actie ervan is een closure die vervolgens een andere actie verstuurt. Apple vereist dat het validatieobject het doel van het menu-item is. Apples documentatie over menu-itemvalidatie.

Kies het doelbeleid voordat u meer opdrachten toevoegt. Gebruik een actie zonder doel wanneer terugval via de responderketen gewenst gedrag is; haal de first responder op en stel die als doel in wanneer de bewerking uitsluitend bij de actieve editor hoort. Zo blijft hetzelfde menu voorspelbaar wanneer gebruikers tussen tekstviews en andere bedieningselementen schakelen.

Bronnen