Bouw een Mac-editor door geaccepteerde tekst in de bovenliggende weergave te bewaren en een afzonderlijk concept in een sheet te bewerken. Alleen Commit kopieert het concept terug; Cancel sluit de sheet. Deze tutorial implementeert die werkwijze voor een korte tekstnotitie in je eigen app.

Bepaal wat Commit en Cancel moeten doen

De taak van de gebruiker is eenvoudig: open een bestaande notitie, pas meerdere regels aan en accepteer de volledige revisie of laat het origineel ongewijzigd. Het voorbeeld gebruikt synthetische tekst en één bewerksessie. De geaccepteerde tekst wordt in het geheugen bewaard; bestandsopslag toevoegen is een afzonderlijke keuze voor de app.

Apple’s TextEditor toont meerregelige, scrollbare tekst en accepteert een binding naar de waarde die wordt bewerkt. Een binding naar de geaccepteerde waarde zou wijzigingen dus direct in die waarde doorvoeren. Bind de editor daarom aan een concept om acceptatie uit te stellen. Zie Apple’s TextEditor-documentatie.

Het ontwerp bestaat uit drie onderdelen: geaccepteerde tekst die eigendom is van de bovenliggende weergave, een momentopname die wordt gemaakt wanneer het bewerken begint en veranderlijke conceptstatus die eigendom is van de sheet. De String van Swift heeft waardesemantiek, dus wijzigingen in het concept veranderen de geaccepteerde tekenreeks niet. Copy-on-write-opslag is een implementatieoptimalisatie; het gedrag waarbij elke waarde onafhankelijk is, blijft hetzelfde. Zie String.

Gebeurtenis Gedrag van het concept Geaccepteerde tekst
Editor openen Begint met de huidige geaccepteerde tekst Ongewijzigd
Tekst typen of verwijderen Wijzigt binnen de sheet Ongewijzigd
Commit kiezen Wordt aan de bovenliggende weergave doorgegeven, waarna de sheet sluit Vervangen door het concept
Cancel kiezen De sheet sluit zonder het concept door te geven Ongewijzigd
Editor opnieuw openen Begint een nieuwe sessie Levert de nieuwe begintekst

Deze tabel is ook het implementatiecontract. De sheet sluiten betekent op zichzelf niet dat de wijzigingen zijn geaccepteerd. Als je dat onderscheid expliciet houdt, werkt annuleren zonder dat je wijzigingen hoeft terug te draaien.

Maak het volledige macOS-voorbeeld

Maak in Xcode een macOS-appproject met SwiftUI en stel het implementatiedoel in op macOS 12 of hoger. Vervang de gegenereerde app- en content-view-declaraties door het volgende bronbestand en verwijder de oude app-declaratie, zodat het doel precies één @main-ingangspunt bevat. Het voorbeeld gebruikt tekstbewerking zonder opmaak in plaats van de nieuwere initializer voor tekst met opmaak.

De minimale macOS-versie 12 is vereist vanwege de DismissAction van de sheet; TextEditor voor tekst zonder opmaak is al beschikbaar vanaf macOS 11. Apple documenteert deze beschikbaarheidsgrenzen in DismissAction en TextEditor.

import SwiftUI
import Foundation

@main
struct DraftEditorApp: App {
    var body: some Scene {
        WindowGroup {
            NoteView()
        }
    }
}

private struct EditSession: Identifiable {
    let id = UUID()
    let initialText: String
}

private struct NoteView: View {
    @State private var acceptedText = """
    Sample note
    Arrange the blue tiles beside the green tiles.
    """
    @State private var editSession: EditSession?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Accepted note")
                .font(.headline)

            ScrollView {
                Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }

            Button("Edit Note") {
                editSession = EditSession(initialText: acceptedText)
            }
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 300)
        .sheet(item: $editSession) { session in
            DraftEditor(initialText: session.initialText) { newText in
                acceptedText = newText
            }
            .id(session.id)
        }
    }
}

private struct DraftEditor: View {
    @Environment(\.dismiss) private var dismiss
    @State private var draft: String

    private let initialText: String
    private let onCommit: (String) -> Void

    init(
        initialText: String,
        onCommit: @escaping (String) -> Void
    ) {
        self.initialText = initialText
        self.onCommit = onCommit
        _draft = State(initialValue: initialText)
    }

    private var hasChanges: Bool {
        draft != initialText
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Edit note")
                .font(.headline)

            TextEditor(text: $draft)
                .font(.body)
                .accessibilityLabel("Note draft")
                .frame(minHeight: 220)

            Text(hasChanges ? "Draft changed" : "No changes")
                .font(.caption)
                .foregroundColor(.secondary)

            HStack {
                Spacer()

                Button("Cancel") {
                    dismiss()
                }
                .keyboardShortcut(.cancelAction)

                Button("Commit") {
                    onCommit(draft)
                    dismiss()
                }
                .keyboardShortcut(.return, modifiers: .command)
            }
        }
        .padding(20)
        .frame(minWidth: 480, minHeight: 340)
    }
}

Dit is een volledig applicatievoorbeeld, inclusief de begintekst, de presentatiestatus, de editor en het ingangspunt. In de onderzoeksomgeving waren geen Swift-compiler en macOS SDK beschikbaar, dus is het voorbeeld vergeleken met de opgehaalde API-documentatie in plaats van hier te worden gecompileerd of uitgevoerd.

Waarom de bovenliggende weergave eigenaar is van de geaccepteerde waarde

De bovenliggende weergave beheert acceptedText, omdat die het geaccepteerde resultaat toont en de beginwaarde voor de volgende bewerksessie levert. Het kind beheert draft, omdat alleen de interface voor bewerken die waarde hoeft te wijzigen. Dit volgt Apples advies om status privé te houden en op het juiste niveau in de weergavehiërarchie te plaatsen. Zie State.

De bovenliggende weergave geeft $acceptedText niet door aan de editor. In plaats daarvan geeft die een tekenreekswaarde en een closure door. De closure bevat de enige toewijzing waarmee een revisie wordt geaccepteerd: acceptedText = newText. In deze implementatie kunnen noch typen, noch annuleren die toewijzing activeren.

Waarom elke opening een sessie-ID krijgt

De optionele EditSession levert zowel de presentatiestatus als de begintekst. Apple documenteert dat sheet(item:onDismiss:content:) content presenteert wanneer het eraan gekoppelde item niet-nil is, en dat item doorgeeft aan de content-closure. Zie sheet(item:onDismiss:content:).

Elke keer dat je op Edit Note drukt, wordt een nieuwe ID gemaakt. Door die ID toe te passen op de editor wordt de bedoelde levensduur van de status expliciet: één concept hoort bij één bewerksessie. De initializer levert de beginwaarde van de status; het is geen mechanisme om een bestaand concept voortdurend te synchroniseren met latere wijzigingen in de bovenliggende weergave.

Voor deze taak is dat momentopnamegedrag wenselijk. Een gedeeltelijk bewerkte notitie hoort niet plotseling te worden teruggezet omdat een ander onderdeel van de bovenliggende weergave wordt bijgewerkt. Bij opnieuw openen ontstaat een nieuw concept op basis van de tekst die het laatst is geaccepteerd.

Bepaal bewust hoe sluiten en toetsenbordbediening werken

Lees @Environment(\.dismiss) binnen de gepresenteerde editor, zoals in het voorbeeld. Apple legt uit dat de actie van toepassing is op de omgeving waarin die is gedeclareerd. Als je die in de bovenliggende weergave leest, kan de actie zich richten op het venster van die weergave in plaats van op de sheet. Zie DismissAction.

De knop Cancel sluit de sheet gewoon. Het concept kan verdwijnen omdat het de geaccepteerde waarde nooit heeft gewijzigd. De knop Commit roept eerst de acceptatie-closure aan en sluit daarna de sheet. Verplaats de acceptatie niet naar onDismiss: die callback beschrijft het sluiten, wat ook gebeurt wanneer iemand annuleert.

Het voorbeeld koppelt Escape aan Cancel met .cancelAction, de door Apple gedocumenteerde standaardtoets voor annuleren. Command-Return wordt met een expliciete sneltoets en modifier aan Commit gekoppeld. Dit zijn sneltoetsen voor bedieningselementen in de interface van de app. Zie cancelAction en keyboardShortcut(_:modifiers:).

Gewone Return blijft beschikbaar voor meerregelige bewerking, omdat het voorbeeld .defaultAction niet aan de commitknop koppelt. Apple definieert .defaultAction als Return zonder modifiers. Command-Return is een redactionele keuze voor deze meerregelige interface, geen bewering dat elke Mac-editor die toetscombinatie zou moeten gebruiken. Zie defaultAction.

Zorg dat sneltoetsen uniek zijn binnen de relevante interface. Apple documenteert dat wanneer meerdere bedieningselementen dezelfde sneltoets hebben, het eerste overeenkomende bedieningselement dat tijdens het zoeken wordt gevonden, wordt gebruikt. Test de sneltoetsen terwijl de cursor in de editor staat, en test ook de zichtbare knoppen. Zie keyboardShortcut(_:modifiers:).

Controleer de acceptatiegrens met een korte testreeks

Voer de volgende controles uit in de draaiende Mac-app. Ze testen het gedrag dat een concepteditor onderscheidt van een editor die wijzigingen direct doorvoert, inclusief het gemakkelijk over het hoofd geziene geval van een lege tekenreeks.

  1. Open Edit Note, vervang de tweede regel door Arrange the green tiles in a row. en klik op Cancel. De geaccepteerde notitie moet de oorspronkelijke tweede regel behouden.
  2. Open de editor opnieuw. Die moet de oorspronkelijk geaccepteerde tekst bevatten, zonder de geannuleerde revisie.
  3. Breng dezelfde wijziging aan en klik op Commit. De geaccepteerde notitie moet nu de aangepaste regel tonen.
  4. Open de editor opnieuw, voeg een regeleinde toe en gebruik Command-Return. Het volledige meerregelige concept moet de geaccepteerde tekst worden.
  5. Open de editor opnieuw, breng een wijziging aan en test Escape tijdens het bewerken. Controleer of annuleren de geaccepteerde tekst ongewijzigd laat.
  6. Open de editor opnieuw, verwijder alle tekst en klik op Commit. De bovenliggende weergave moet (Empty note) tonen. Bij opnieuw openen moet de editor leeg zijn.

Lege tekst is in dit voorbeeld geldig. De implementatie verwijdert geen witruimte of regeleinden en schakelt acceptatie niet uit voor een lege tekenreeks. Als je eigen notitietype inhoud vereist, voeg dan een zichtbare validatieregel toe en pas die zowel toe in de commitactie als op de ingeschakelde status van de knop.

De vergelijking hasChanges bepaalt alleen het statuslabel. Als Commit ingeschakeld blijft wanneer er niets is gewijzigd, is accepteren een onschadelijke toewijzing van dezelfde waarde. Als je de knop later uitschakelt wanneer de tekst ongewijzigd is, houd Cancel dan beschikbaar zodat de sheet altijd een expliciete uitgang heeft.

Weet wanneer SwiftUI volstaat en wanneer AppKit helpt

Voor deze taak met tekst zonder opmaak biedt TextEditor al de benodigde bewerkingsinterface. Je hebt geen aangepaste tekstlay-out of AppKit-bridge nodig om conceptwijzigingen te scheiden van geaccepteerde tekst. Die scheiding hoort thuis in het statusontwerp van de app.

Huidige SwiftUI ondersteunt ook een TextEditor-initializer voor tekenreeksen met opmaak, beschikbaar vanaf macOS 26. Rijke tekst vereist dus niet automatisch dat je SwiftUI vervangt. Wel moet je het type van het concept aanpassen en rekening houden met de nieuwere vereiste voor de minimale macOS-versie. Apple documenteert ook beperkingen voor het omzetten van opmaakkenmerken in deze editor. Zie init(text:selection:).

Als je AppKit-specifiek bewerkingsgedrag of directe controle over de tekstlay-out nodig hebt, is de ondersteunde integratieroute een NSTextView die via NSViewRepresentable wordt gehost. Apple beschrijft de representable-methoden voor het maken, bijwerken en opruimen van een AppKit-weergave, met een coordinator om gedelegeerde wijzigingen terug te koppelen aan SwiftUI. Zie NSViewRepresentable.

Behoud in die bridge hetzelfde acceptatiecontract: wijzigingen door AppKit-bewerking werken het concept bij, en alleen de expliciete commitactie werkt het geaccepteerde model bij. Voorkom bij de implementatie dat je de volledige inhoud van de tekstweergave bij elke SwiftUI-update vervangt; maak onderscheid tussen echte binnenkomende wijzigingen en wijzigingen die de editor zelf doorgeeft.

TextKit heeft nog een beperking: NSTextView biedt moderne lay-out via textLayoutManager en legacy lay-out via layoutManager. Apple documenteert dat toegang tot die laatste op macOS 12 of hoger de tekstweergave in de compatibiliteitsmodus met NSLayoutManager zet. Kies bewust welke engine je gebruikt wanneer je lay-outfuncties toevoegt. Zie TextKit en NSTextView.

De commitbewerking van het voorbeeld is tot slot synchroon en gebeurt in het geheugen. Als je die uitbreidt om een document weg te schrijven, bepaal dan of acceptatie betekent dat het model wordt bijgewerkt of dat het schrijven is voltooid. Houd bij een ontwerp dat pas sluit na het schrijven de sheet open wanneer het schrijven mislukt en bewaar het concept voor een nieuwe poging. Als dezelfde notitie tijdens het bewerken via een andere interface van de app kan veranderen, voeg dan een revisiecontrole toe voordat je de notitie vervangt.

Het essentiële ontwerp blijft eenvoudig: maak één concept per bewerksessie, laat de editor dat concept wijzigen en geef acceptatie één expliciet pad. Met die grens blijft annuleren voorspelbaar, ook wanneer de bewerkingsinterface groeit.

Bronnen