Utwórz edytor na Maca, przechowując zaakceptowany tekst w widoku nadrzędnym i edytując osobny szkic w arkuszu. Tylko Commit kopiuje szkic z powrotem; Cancel zamyka arkusz. Ten samouczek pokazuje, jak zaimplementować ten przepływ dla krótkiej notatki w postaci zwykłego tekstu we własnej aplikacji.

Określ działanie opcji Commit i Cancel

Zadanie czytelnika jest proste: otworzyć istniejącą notatkę, poprawić kilka wierszy, a następnie zaakceptować całą wersję albo pozostawić oryginał bez zmian. Przykład używa fikcyjnego tekstu i pojedynczej sesji edycji. Przechowuje zaakceptowany tekst w pamięci; dodanie zapisu do pliku to osobna decyzja dotycząca aplikacji.

Apple’owy TextEditor wyświetla wielowierszowy, przewijany tekst i przyjmuje powiązanie z edytowaną wartością. Powiązanie z zaakceptowaną wartością pozwalałoby więc na natychmiastowe wprowadzanie zmian. Aby odłożyć akceptację, powiąż edytor ze szkicem. Zobacz dokumentację Apple dotyczącą TextEditor.

Projekt składa się z trzech elementów: zaakceptowanego tekstu należącego do widoku nadrzędnego, migawki tworzonej przy rozpoczęciu edycji oraz zmiennego stanu szkicu należącego do arkusza. Typ String w Swift ma semantykę wartości, więc zmiana szkicu nie zmienia zaakceptowanego ciągu. Mechanizm copy-on-write to optymalizacja implementacyjna; niezależność wartości pozostaje bez zmian. Zobacz String.

Zdarzenie Działanie na szkicu Zaakceptowany tekst
Otwarcie edytora Rozpoczyna się od bieżącego zaakceptowanego tekstu Bez zmian
Wpisanie lub usunięcie tekstu Zmienia się w arkuszu Bez zmian
Wybranie Commit Przekazywany do widoku nadrzędnego, po czym arkusz się zamyka Zastępowany szkicem
Wybranie Cancel Arkusz się zamyka bez przekazania szkicu z powrotem Bez zmian
Ponowne otwarcie edytora Rozpoczyna nową sesję Dostarcza nowy tekst początkowy

Ta tabela stanowi również kontrakt implementacji. Samo zamknięcie arkusza nie oznacza akceptacji. Jasne rozdzielenie tych działań pozwala obsłużyć anulowanie bez operacji przywracania.

Utwórz kompletny przykład dla macOS

W Xcode utwórz projekt aplikacji macOS używający SwiftUI i ustaw docelową wersję systemu na macOS 12 lub nowszą. Zastąp wygenerowane deklaracje aplikacji i widoku zawartości poniższym pojedynczym plikiem źródłowym, usuwając starą deklarację aplikacji, tak aby element docelowy zawierał dokładnie jeden punkt wejścia @main. Przykład używa edycji zwykłego tekstu, a nie nowszego inicjalizatora dla tekstu z atrybutami.

Minimalna wersja macOS 12 wynika z użycia przez arkusz DismissAction; TextEditor do zwykłego tekstu jest dostępny od macOS 11. Apple opisuje te wymagania dotyczące dostępności w dokumentacji DismissAction i 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)
    }
}

To kompletny przykład aplikacji, obejmujący tekst początkowy, stan prezentacji, edytor i punkt wejścia. W środowisku badawczym nie było dostępnego kompilatora Swift ani SDK macOS, dlatego przykład sprawdzono na podstawie pobranej dokumentacji API, ale nie skompilowano go ani nie uruchomiono.

Dlaczego zaakceptowana wartość należy do widoku nadrzędnego

Widok nadrzędny jest właścicielem acceptedText, ponieważ wyświetla zaakceptowany wynik i dostarcza wartość początkową następnej sesji edycji. Widok podrzędny jest właścicielem draft, ponieważ tylko interfejs edycji musi ją zmieniać. Jest to zgodne z zaleceniem Apple, by utrzymywać stan jako prywatny i umieszczać go na odpowiednim poziomie hierarchii widoków. Zobacz State.

Widok nadrzędny nie przekazuje edytorowi $acceptedText. Zamiast tego przekazuje wartość ciągu i domknięcie. Domknięcie zawiera jedyne przypisanie, które akceptuje nową wersję: acceptedText = newText. W tej implementacji ani pisanie, ani anulowanie nie mogą doprowadzić do tego przypisania.

Dlaczego każde otwarcie otrzymuje identyfikator sesji

Opcjonalny EditSession zapewnia zarówno stan prezentacji, jak i tekst początkowy. Apple dokumentuje, że sheet(item:onDismiss:content:) prezentuje zawartość, gdy powiązany z nim element ma wartość inną niż nil, i przekazuje ten element do domknięcia zawartości. Zobacz sheet(item:onDismiss:content:).

Każde naciśnięcie Edit Note tworzy nowy identyfikator. Przypisanie tego identyfikatora do edytora jasno określa oczekiwany czas życia stanu: jeden szkic należy do jednej sesji edycji. Inicjalizator ustawia wartość początkową stanu; nie służy do ciągłej synchronizacji istniejącego szkicu z późniejszymi zmianami w widoku nadrzędnym.

W tym przypadku takie zachowanie migawki jest pożądane. Częściowo edytowana notatka nie powinna nagle się zresetować z powodu aktualizacji niezwiązanej części widoku nadrzędnego. Ponowne otwarcie tworzy nowy szkic na podstawie ostatnio zaakceptowanego tekstu.

Ustal sposób zamykania i obsługę klawiatury

Odczytaj @Environment(\.dismiss) wewnątrz prezentowanego edytora, tak jak w przykładzie. Apple wyjaśnia, że akcja dotyczy środowiska, w którym została zadeklarowana. Odczytanie jej w głównym widoku prezentującym arkusz może skierować działanie do okna tego widoku zamiast do arkusza. Zobacz DismissAction.

Przycisk Cancel po prostu zamyka arkusz. Szkic może zniknąć, ponieważ nigdy nie zmienił zaakceptowanej wartości. Przycisk Commit najpierw wywołuje domknięcie akceptujące, a następnie zamyka arkusz. Nie przenoś akceptacji do onDismiss: to wywołanie zwrotne opisuje zamknięcie, które następuje również po anulowaniu.

Przykład przypisuje klawisz Escape do opcji Cancel za pomocą .cancelAction, czyli standardowego skrótu anulowania opisanego przez Apple. Przypisuje Command-Return do opcji Commit za pomocą jawnego równoważnika klawiszowego i modyfikatora. Są to skróty sterujące w interfejsie aplikacji. Zobacz cancelAction i keyboardShortcut(_:modifiers:).

Klawisz Return bez modyfikatorów nadal służy do edycji wielowierszowej, ponieważ przykład nie przypisuje przyciskowi akceptacji opcji .defaultAction. Apple definiuje .defaultAction jako klawisz Return bez modyfikatorów. Command-Return to wybór dla tego wielowierszowego interfejsu edycji, a nie stwierdzenie, że każdy edytor na Macu powinien go używać. Zobacz defaultAction.

W odpowiednim interfejsie każdy skrót powinien być unikatowy. Apple dokumentuje, że gdy kilka kontrolek ma ten sam skrót, używana jest pierwsza pasująca kontrolka znaleziona podczas wyszukiwania. Przetestuj skróty, gdy punkt wstawiania znajduje się w edytorze, a także klikając widoczne przyciski. Zobacz keyboardShortcut(_:modifiers:).

Sprawdź granicę akceptacji krótką sekwencją testów

W działającej aplikacji na Maca wykonaj poniższe czynności. Sprawdzają one zachowanie, które odróżnia edytor szkiców od edytora na żywo, w tym łatwy do przeoczenia przypadek pustego ciągu.

  1. Otwórz Edit Note, zastąp drugi wiersz tekstem Arrange the green tiles in a row., a następnie kliknij Cancel. Zaakceptowana notatka powinna zachować oryginalny drugi wiersz.
  2. Otwórz edytor ponownie. Powinien zawierać oryginalny zaakceptowany tekst, bez anulowanej zmiany.
  3. Wprowadź tę samą zmianę i kliknij Commit. Zaakceptowana notatka powinna teraz zawierać zmieniony wiersz.
  4. Otwórz edytor ponownie, dodaj znak nowego wiersza i użyj skrótu Command-Return. Cały wielowierszowy szkic powinien stać się zaakceptowanym tekstem.
  5. Otwórz edytor ponownie, wprowadź zmianę i podczas edycji przetestuj klawisz Escape. Sprawdź, czy anulowanie pozostawia zaakceptowany tekst bez zmian.
  6. Otwórz edytor ponownie, usuń cały tekst i kliknij Commit. Widok nadrzędny powinien wyświetlić (Empty note). Ponowne otwarcie powinno pokazać pusty edytor.

W tym przykładzie pusty tekst jest prawidłowy. Implementacja nie usuwa białych znaków ani podziałów wiersza i nie blokuje akceptacji pustego ciągu. Jeśli własny typ notatki wymaga treści, dodaj widoczną regułę walidacji i stosuj ją zarówno podczas zatwierdzania, jak i przy określaniu, czy przycisk jest aktywny.

Porównanie hasChanges steruje wyłącznie etykietą stanu. Pozostawienie aktywnego przycisku Commit bez zmian oznacza nieszkodliwe przypisanie tej samej wartości. Jeśli później wyłączysz go, gdy tekst się nie zmienił, pozostaw dostępny przycisk Cancel, aby arkusz zawsze miał jawne wyjście.

Sprawdź, kiedy wystarczy SwiftUI, a kiedy przyda się AppKit

Do edycji zwykłego tekstu TextEditor zapewnia już wymagany interfejs. Do oddzielenia zmian szkicu od zaakceptowanego tekstu nie potrzeba niestandardowego układu tekstu ani mostka do AppKit. To rozdzielenie wynika z projektu stanu aplikacji.

Obecne SwiftUI obsługuje również inicjalizator TextEditor przyjmujący ciąg z atrybutami, dostępny od macOS 26. Tekst sformatowany nie wymaga więc automatycznie zastąpienia SwiftUI. Wymaga zmiany typu szkicu i uwzględnienia nowszego wymagania dotyczącego wersji systemu. Apple opisuje też ograniczenia konwersji atrybutów formatowania w tym edytorze. Zobacz init(text:selection:).

Jeśli potrzebujesz zachowania edycji specyficznego dla AppKit albo bezpośredniej kontroli nad układem tekstu, obsługiwaną ścieżką integracji jest NSTextView osadzony przez NSViewRepresentable. Apple opisuje metody representable służące do tworzenia, aktualizowania i usuwania widoku AppKit oraz koordynator przekazujący zmiany zgłaszane przez delegata z powrotem do SwiftUI. Zobacz NSViewRepresentable.

W tym mostku zachowaj ten sam kontrakt akceptacji: zmiany edycji w AppKit aktualizują szkic, a tylko jawna akcja zatwierdzenia aktualizuje zaakceptowany model. Jako środek ostrożności w implementacji unikaj zastępowania całej zawartości widoku tekstowego przy każdej aktualizacji SwiftUI; odróżniaj rzeczywiste zmiany przychodzące od zmian wysyłanych przez sam edytor.

TextKit ma jeszcze jedno ograniczenie: NSTextView udostępnia nowoczesny układ za pomocą textLayoutManager, a starszy za pomocą layoutManager. Apple dokumentuje, że odwołanie do tego drugiego na macOS 12 lub nowszym przełącza widok tekstowy w tryb zgodności korzystający z NSLayoutManager. Przy dodawaniu funkcji układu wybierz silnik świadomie. Zobacz TextKit i NSTextView.

Na koniec: operacja zatwierdzania w przykładzie jest synchroniczna i odbywa się w pamięci. Jeśli rozszerzysz ją o zapis dokumentu, określ, czy akceptacja oznacza aktualizację modelu, czy zakończenie zapisu. W przypadku zapisu przed zamknięciem pozostaw arkusz otwarty, jeśli zapis się nie powiedzie, i zachowaj szkic do kolejnej próby. Jeśli ta sama notatka może zmienić się za pośrednictwem innego interfejsu w aplikacji podczas edycji, przed jej zastąpieniem sprawdź wersję.

Podstawowy projekt pozostaje prosty: utwórz jeden szkic na sesję edycji, pozwól edytorowi go zmieniać i zapewnij jedną jawną ścieżkę akceptacji. Dzięki takiej granicy anulowanie pozostaje przewidywalne nawet po rozbudowie interfejsu edycji.

Źródła