Создайте редактор для Mac: храните принятый текст в родительском представлении, а отдельный черновик редактируйте в модальном окне. Только кнопка Commit копирует черновик обратно; кнопка Cancel закрывает окно. В этом руководстве такой сценарий реализован для короткой заметки в обычном текстовом формате внутри собственного приложения.
Определите, что должны делать кнопки «Применить» и «Отмена»
Задача пользователя проста: открыть существующую заметку, отредактировать несколько строк и либо принять изменения целиком, либо оставить исходный текст без изменений. В примере используется синтетический текст и один сеанс редактирования. Принятый текст хранится в памяти; добавление файлового хранилища — отдельное решение для приложения.
Apple TextEditor отображает многострочный прокручиваемый текст и принимает привязку к редактируемому значению. Поэтому привязка к принятому значению позволила бы изменениям сразу влиять на него. Чтобы отложить принятие изменений, привяжите редактор к черновику. См. документацию Apple по TextEditor.
В решении три части: принятый текст, которым владеет родительское представление, снимок, создаваемый при начале редактирования, и изменяемое состояние черновика, которым владеет модальное окно. Тип String в Swift имеет семантику значений, поэтому изменение черновика не меняет принятую строку. Копирование при записи — это оптимизация реализации; поведение независимых значений остается тем же. См. String.
| Событие | Поведение черновика | Принятый текст |
|---|---|---|
| Открытие редактора | Начинается с текущего принятого текста | Не меняется |
| Ввод или удаление текста | Изменяется в модальном окне | Не меняется |
Нажатие Commit |
Передается родительскому представлению, затем окно закрывается | Заменяется черновиком |
Нажатие Cancel |
Окно закрывается, черновик не передается обратно | Не меняется |
| Повторное открытие редактора | Начинается новый сеанс | Задает новый исходный текст |
Эта таблица также служит контрактом реализации. Закрытие окна само по себе не означает принятия изменений. Явное разделение этих действий позволяет отменять правки без операции отката.
Создайте полноценный пример для macOS
Создайте в Xcode проект приложения для macOS на SwiftUI и задайте целевую версию macOS 12 или новее. Замените сгенерированные объявления приложения и представления содержимого одним файлом исходного кода ниже, удалив старое объявление приложения, чтобы в целевом модуле осталась ровно одна точка входа @main. В примере используется редактирование обычного текста, а не более новый инициализатор для форматированного текста.
Минимальная версия macOS 12 требуется из-за DismissAction модального окна; обычный текстовый TextEditor доступен начиная с macOS 11. Эти ограничения по версиям описаны Apple в документации DismissAction и 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)
}
}
Это полноценный пример приложения, в который входят начальный текст, состояние показа, редактор и точка входа. В исследовательской среде не было компилятора Swift и SDK macOS, поэтому пример сверили с полученной документацией API, но не компилировали и не запускали.
Почему принятое значение принадлежит родительскому представлению
Родительское представление владеет acceptedText, потому что отображает принятый результат и передает его как исходное значение для следующего сеанса редактирования. Дочернее представление владеет draft, потому что менять его должен только интерфейс редактирования. Это соответствует рекомендациям Apple хранить состояние в закрытом виде и размещать его на подходящем уровне иерархии представлений. См. State.
Родительское представление не передает редактору $acceptedText. Вместо этого оно передает строковое значение и замыкание. В замыкании находится единственное присваивание, которое принимает изменения: acceptedText = newText. При такой реализации к этому присваиванию нельзя обратиться ни при вводе текста, ни при отмене.
Почему при каждом открытии создается идентификатор сеанса
Необязательное значение EditSession задает состояние показа и начальный текст. Apple указывает, что sheet(item:onDismiss:content:) отображает содержимое, когда привязанный элемент не равен nil, и передает этот элемент в замыкание содержимого. См. sheet(item:onDismiss:content:).
При каждом нажатии Edit Note создается новый идентификатор. Применение этого идентификатора к редактору явно задает срок жизни состояния: один черновик соответствует одному сеансу редактирования. Инициализатор задает начальное значение состояния; он не предназначен для непрерывной синхронизации существующего черновика с последующими изменениями в родительском представлении.
Для этой задачи такое поведение со снимком подходит. Частично отредактированная заметка не должна внезапно сбрасываться из-за обновления другой части родительского представления. При повторном открытии создается новый черновик из последнего принятого текста.
Продумайте закрытие окна и сочетания клавиш
Читайте @Environment(\.dismiss) внутри показанного редактора, как в примере. Apple объясняет, что действие применяется к окружению, в котором оно объявлено. Если прочитать его в корневом представлении, которое показывает окно, действие может закрыть окно приложения вместо модального окна. См. DismissAction.
Кнопка Cancel просто закрывает модальное окно. Черновик можно отбросить, поскольку он ни разу не менял принятое значение. Кнопка Commit сначала вызывает замыкание принятия изменений, а затем закрывает окно. Не переносите принятие изменений в onDismiss: этот обработчик вызывается при закрытии, в том числе когда пользователь отменяет правки.
В примере клавиша Escape назначена кнопке Cancel с помощью .cancelAction — стандартного сочетания для отмены, задокументированного Apple. Для кнопки Commit явно заданы клавиша и модификатор Command-Return. Это сочетания для управления интерфейсом приложения. См. cancelAction и keyboardShortcut(_:modifiers:).
Клавиша Return остается доступной для многострочного редактирования, поскольку кнопке принятия изменений не назначено .defaultAction. Apple определяет .defaultAction как Return без модификаторов. Command-Return — это выбранное для такого многострочного интерфейса сочетание, а не утверждение, что его следует использовать во всех редакторах Mac. См. defaultAction.
Используйте уникальные сочетания клавиш в рамках соответствующего интерфейса. Apple указывает, что если одно сочетание назначено нескольким элементам управления, при разрешении выбирается первый найденный элемент. Проверьте сочетания клавиш, когда курсор находится в редакторе, а также при нажатии видимых кнопок. См. keyboardShortcut(_:modifiers:).
Проверьте границу принятия изменений с помощью короткой последовательности действий
В запущенном приложении для Mac выполните следующие проверки. Они проверяют поведение, которое отличает редактор черновиков от редактора с немедленным применением изменений, включая легко упускаемый случай с пустой строкой.
- Откройте
Edit Note, замените вторую строку наArrange the green tiles in a row.и нажмитеCancel. В принятой заметке должна остаться исходная вторая строка. - Откройте редактор снова. В нем должен быть исходный принятый текст без отмененной правки.
- Внесите ту же правку и нажмите
Commit. В принятой заметке теперь должна отображаться измененная строка. - Откройте редактор снова, добавьте разрыв строки и нажмите Command-Return. Весь многострочный черновик должен стать принятым текстом.
- Откройте редактор снова, внесите изменение и, находясь в режиме редактирования, проверьте клавишу Escape. Убедитесь, что после отмены принятый текст не изменился.
- Откройте редактор снова, удалите весь текст и нажмите
Commit. В родительском представлении должно отображаться(Empty note). При повторном открытии редактор должен быть пустым.
В этом примере пустой текст допустим. Реализация не удаляет пробелы и разрывы строк и не отключает принятие изменений для пустой строки. Если в вашем типе заметок должно быть содержимое, добавьте видимое правило проверки и применяйте его в действии принятия изменений, а также при определении доступности кнопки.
Сравнение hasChanges управляет только меткой состояния. Если оставить кнопку Commit активной при отсутствии изменений, принятие изменений будет безвредным присваиванием того же значения. Если позднее вы отключите ее при отсутствии изменений, оставьте кнопку Cancel доступной, чтобы у пользователя всегда был явный способ закрыть окно.
Определите, когда достаточно SwiftUI и когда пригодится AppKit
Для редактирования обычного текста в этой задаче в TextEditor уже есть все необходимое. Чтобы отделить изменения черновика от принятого текста, не нужны ни собственная верстка текста, ни мост к AppKit. Такое разделение задается архитектурой состояния приложения.
Современный SwiftUI также поддерживает инициализатор TextEditor для строк с атрибутами, доступный начиная с macOS 26. Для форматированного текста поэтому не всегда нужно отказываться от SwiftUI. Однако потребуется изменить тип черновика и учесть новые требования к целевой версии. Apple также документирует ограничения при преобразовании атрибутов форматирования в этом редакторе. См. init(text:selection:).
Если нужно поведение редактирования, специфичное для AppKit, или прямое управление компоновкой текста, поддерживаемый способ интеграции — разместить NSTextView через NSViewRepresentable. Apple описывает методы representable-протокола для создания, обновления и демонтажа представления AppKit, а также координатор для передачи изменений, делегированных представлением, обратно в SwiftUI. См. NSViewRepresentable.
Сохраняйте в этом мосте тот же контракт принятия изменений: редактирование в AppKit меняет черновик, а только явное действие принятия изменений обновляет принятую модель. В качестве меры предосторожности при реализации не заменяйте содержимое текстового представления целиком при каждом обновлении SwiftUI; различайте действительно поступившие изменения и собственные исходящие изменения редактора.
В TextKit есть дополнительная граница: NSTextView предоставляет современную компоновку через textLayoutManager и устаревшую — через layoutManager. Apple указывает, что обращение к последней в macOS 12 или новее переключает текстовое представление в режим совместимости с использованием NSLayoutManager. При добавлении функций компоновки осознанно выберите механизм. См. TextKit и NSTextView.
Наконец, операция принятия изменений в примере синхронная и выполняется в памяти. Если вы расширите ее для записи документа, определите, означает ли принятие обновление модели или завершение записи. Если документ должен записываться до закрытия окна, при ошибке записи оставьте окно открытым и сохраните черновик для повторной попытки. Если то же примечание может измениться через другой интерфейс приложения во время редактирования, перед заменой добавьте проверку версии.
Основная схема остается простой: создайте один черновик для каждого сеанса редактирования, позвольте редактору менять его и предусмотрите единственный явный способ принять изменения. При такой границе отмена остается предсказуемой по мере расширения возможностей редактирования.
Источники
- TextEditor — Apple
- String — Apple
- State — Apple
- sheet(item:onDismiss:content:) — Apple
- DismissAction — Apple
- cancelAction — Apple
- defaultAction — Apple
- keyboardShortcut(_:modifiers:) — Apple
- init(text:selection:) — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- NSTextView — Apple