Чтобы направить команду меню SwiftUI в активный NSTextView, при выполнении команды определите первый ответчик ключевого окна, а затем отправьте действие этому представлению. В этом руководстве создаётся пример с двумя редакторами, в котором команда Select First Line затрагивает только редактор в фокусе.
Задача — создать пользовательское действие меню в собственном приложении для macOS. В примере используются синтетический текст, подкласс NSTextView, принадлежащий приложению, а также общедоступные API SwiftUI и AppKit. Ключевое решение при проектировании — должна ли команда следовать цепочке ответчиков AppKit или требовать определённый активный редактор.
Определите, что значит «активное текстовое представление»
В этом примере активный редактор — это экземпляр SampleTextView, который в данный момент является первым ответчиком ключевого окна. Apple определяет ключевое окно как окно, получающее события клавиатуры; его firstResponder указывает ответчик, который обычно первым получает события и действия. Чтение этих свойств в момент вызова позволяет команде направить действие текущему получателю. Документация Apple по keyWindow, документация Apple по firstResponder.
AppKit также поддерживает действия без явной цели: передайте nil в качестве цели методу NSApplication.sendAction(_:to:from:), и приложение выполнит поиск ответчика, реализующего селектор. Поиск начинается с первого ответчика ключевого окна, но может продолжиться через делегаты и главное окно до обработчиков уровня приложения. Поэтому действие без явной цели не означает «выполнить только в активном текстовом представлении этого ключевого окна». Документация Apple о маршрутизации действий.
| Требуемое поведение | Вариант маршрутизации | Последствие |
|---|---|---|
| Позволить AppKit найти подходящий обработчик | Отправить действие с to: nil |
Применяется обычный поиск по цепочке ответчиков. |
| Затрагивать только активный редактор приложения | Определить первого ответчика и проверить его тип, затем явно указать его в качестве цели | Команда прекращает работу, если этот редактор не активен. |
| Работать с определённым редактором независимо от фокуса | Указать этот редактор в качестве цели | Это другое взаимодействие, не команда для активного редактора. |
В приведённой ниже реализации выбран второй вариант. Проверка пользовательского подкласса также не позволяет редактору несвязанного текстового поля стать целью лишь потому, что он является текстовым представлением.
Создайте полный пример на SwiftUI и AppKit
Создайте проект приложения для macOS с использованием SwiftUI и выберите для этого примера целевую версию macOS 13 или новее. Замените созданную точку входа приложения следующим файлом Swift; удалите все остальные объявления @main. Программа создаёт оба редактора, задаёт для них начальный текст, добавляет меню и обработчик действия.
NSViewRepresentable в SwiftUI — это поддерживаемый способ встраивания представления AppKit. Команды сцены задают содержимое меню, а CommandMenu создаёт меню приложения в строке меню macOS. Документация Apple по NSViewRepresentable, команды сцены, меню команд.
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.
}
}
Это полный пример приложения с содержимым редакторов в памяти. Код был проверен по полученной документации API; здесь его не компилировали и не запускали, поскольку доступная среда работает на Linux и не содержит инструментария Swift.
Почему селектор достигает нужного редактора
Функция маршрутизации получает первого ответчика непосредственно перед отправкой действия. Она принимает только SampleTextView и указывает именно этот объект в качестве цели действия. Поэтому меню не хранит ссылку на редактор, по которому щёлкнули ранее, и при неудачной проверке типа не ищет редактор в другом окне.
Для метода действия используется @objc, поскольку отправка действий через механизм target-action задействует селекторы Objective-C. Выражение #selector ссылается на объявленный метод, а не на вручную заданную строку селектора. Apple описывает эту комбинацию для вызова методов Swift через API Objective-C target-action. Использование возможностей среды выполнения Objective-C в Swift.
Возвращаемое значение sendAction показывает, было ли отправлено сообщение. Оно не показывает, изменил ли обработчик выделение: обработчик может получить сообщение и сразу завершить работу, если содержимое пусто или текст помечен. В этом примере результат отправки намеренно игнорируется, поскольку получатель и метод уже известны. Документация Apple о возвращаемом значении sendAction.
Что именно выделяет команда
Обработчик запрашивает у NSString.lineRange(for:) логическую строку, содержащую начало строки. Этот диапазон включает символ окончания строки, если он есть. Таким образом, выделяется первая строка, разделённая символом новой строки, даже если на экране она переносится на несколько строк. Документация Apple о диапазонах строк.
Для заданного текста в левом редакторе выделяется Sample Alpha вместе со следующим за ним символом новой строки; в правом редакторе — Sample Beta и следующий за ним символ новой строки. Команда заменяет предыдущее выделение. Текст она не меняет.
setSelectedRange(_:) задаёт выделение, а scrollRangeToVisible(_:) прокручивает представление так, чтобы первые символы оказались видны. Проверка на пустое содержимое предотвращает попытку выделения, когда текста нет. Проверка помеченного текста — это явная политика примера: отложить пользовательское действие выделения, пока активен ввод с композицией. Диапазоны выделения, прокрутка к диапазону, помеченный текст.
Не смешивайте создание представления, редактирование и фокус
Представление-представитель создаёт NSScrollView и устанавливает пользовательское текстовое представление в качестве его документа. Редактор растёт по вертикали, а текстовый контейнер подстраивается под его ширину. Это соответствует описанной Apple схеме прокручиваемого текстового представления. Эта схема приведена в архивном руководстве Apple по AppKit; для SwiftUI-моста и API действий здесь указана актуальная документация. Размещение объекта NSTextView в NSScrollView.
SwiftUI управляет макетом представленного прокручиваемого представления. Код задаёт начальные размеры вложенного представления документа и поведение изменения его размера в AppKit; он не присваивает кадр представленного прокручиваемого представления повторно. Apple особо предостерегает от изменения свойств макета представителя, которыми управляет SwiftUI. Рекомендации Apple по макету представителя.
В примере initialText задаётся только при создании. Пустой метод обновления намеренно оставляет последующий ввод под управлением AppKit. Если приложению нужна модель документа в SwiftUI, добавьте координатор и синхронизацию на основе делегата, определив, как обрабатывать изменения модели во время редактирования. Повторное присваивание начальной строки при каждом обновлении заменило бы текущее содержимое пользователя. Apple описывает координаторы как мост для передачи взаимодействий с AppKit в SwiftUI. Рекомендации Apple по координации представителя.
Для этого примера щелчок по редактору устанавливает фокус. Если приложению нужно перемещать фокус программно, после добавления представления в окно используйте makeFirstResponder(_:) содержащего его окна. Apple прямо указывает, что не следует вызывать непосредственно becomeFirstResponder() текстового представления. Не запрашивайте фокус при каждом обновлении SwiftUI, иначе цель редактирования пользователя будет постоянно перенаправляться. Рекомендации Apple по фокусу текстового представления.
Сохраните существующую конфигурацию TextKit
Для маршрутизации меню менять механизм компоновки текста не требуется. В этом примере используется NSTextView(frame:); Apple указывает, что этот инициализатор по умолчанию использует NSLayoutManager. Явное создание с помощью NSTextLayoutManager поддерживается начиная с macOS 12. Документация Apple по инициализации текстового представления.
Для существующего редактора на TextKit 2 сохраните его способ создания и добавьте действие в его подкласс. Не обращайтесь к layoutManager только ради поиска редактора или маршрутизации к нему: Apple указывает, что обращение к этому свойству текстового представления TextKit 2 может переключить его в режим совместимости. Для маршрутизации нужны только ответчик и метод действия. Документация Apple о режиме совместимости NSTextView.
Проверьте маршрутизацию и обработку недоступных команд
Выполните эти действия на macOS, чтобы проверить ожидаемое поведение. Это ожидаемые результаты, выведенные из реализации, а не результаты фактического запуска.
- Щёлкните в левом редакторе, затем выберите
Sample Actions→Select First Line. Должна выделиться первая логическая строка левого редактора. - Щёлкните в правом редакторе и вызовите ту же команду. Должно измениться только выделение в правом редакторе.
- Щёлкните по
Window labelи вызовите команду. Оба пользовательских редактора должны остаться без изменений, поскольку первый ответчик не проходит проверку подкласса. - Удалите всё содержимое редактора и вызовите команду в нём. Проверка на пустое содержимое должна завершить работу без изменения текста.
- Добавьте несколько строк, прокрутите вниз и вызовите команду. Первая строка должна выделиться и прокрутиться в область видимости.
В этом минимальном примере команда остаётся включённой даже тогда, когда фокус находится в другом месте. Выполнение зависит от проверки в момент вызова. Одного чтения keyWindow?.firstResponder в выражении SwiftUI .disabled недостаточно, чтобы обеспечить обновление этого выражения при каждом изменении фокуса в AppKit.
В более крупном интерфейсе SwiftUI предоставьте через состояние, учитывающее фокус, нужную возможность команды, отслеживайте её с помощью @FocusedValue и задайте условие .disabled кнопки на основе этого состояния. Мост AppKit должен поддерживать соответствие этой возможности фактическому фокусу редактора. Также сохраните проверку в момент вызова. Apple описывает FocusedValue как средство наблюдения за значениями из представления в фокусе или его предков. Значения, связанные с фокусом, отключение взаимодействия.
Если вместо этого вы создаёте элемент меню AppKit, целью которого является редактор, validateMenuItem(_:) предоставляет хук проверки AppKit. Добавление этого метода в данный подкласс само по себе не проверит кнопку SwiftUI: её действие — это замыкание, которое затем отправляет другое действие. Apple требует, чтобы объектом проверки была цель элемента меню. Документация Apple о проверке элементов меню.
Выберите политику назначения цели, прежде чем добавлять новые команды. Используйте действие без явной цели, если желаемое поведение предусматривает поиск по цепочке ответчиков; определяйте первого ответчика и указывайте его в качестве цели, если операция предназначена исключительно для активного редактора. Благодаря этому выбору меню остаётся предсказуемым, когда пользователь переключается между текстовыми представлениями и другими элементами управления.
Источники
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- Использование возможностей среды выполнения Objective-C в Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- Размещение объекта NSTextView в NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple