Для списка элементов на Mac привяжите выбор к SwiftUI List и задайте основное действие через контекстное меню, учитывающее выбор. Один щелчок выбирает строку; двойной щелчок открывает ее. Храните состояние выбора отдельно от состояния отображения.

В этом руководстве показана реализация такого взаимодействия в собственном приложении для macOS с тремя синтетическими элементами, хранящимися в памяти. Также рассматриваются особенности пользовательских поверхностей SwiftUI и представлений AppKit, чтобы вы могли выбрать подход без конкуренции нескольких слоев за один и тот же щелчок.

Используйте основное действие контейнера с поддержкой выбора

Apple явно документирует выбор одним щелчком и основные действия по двойному щелчку для контейнеров с поддержкой выбора в macOS. Поддерживаемая точка входа SwiftUI — contextMenu(forSelectionType:menu:primaryAction:), доступная начиная с macOS 13. Добавьте ее к контейнеру с поддержкой выбора, например List или Table. Документация Apple о контекстном меню, учитывающем выбор.

Здесь есть три отдельные задачи: модель идентифицирует каждый элемент, список хранит выбранные элементы с помощью привязки, а обработчик основного действия определяет, что означает открытие. В этом примере открытие показывает лист с подробностями элемента. Выбор элемента лишь меняет подсветку строки и подпись выбранного элемента.

Выберите способ реализации в зависимости от представления, которому принадлежит взаимодействие:

Интерфейс Подходящая отправная точка За что отвечаете вы
Список или таблица элементов SwiftUI в macOS 13 или новее Привязка выбора и основное действие, учитывающее выбор Разрешение идентификаторов действий и отображение соответствующего содержимого
Пользовательская плитка или фигура SwiftUI Явное сочетание жестов Внешний вид выбранного элемента, правила активации и другие способы ввода
Таблица AppKit, встроенная в SwiftUI Выбор в NSTableView и doubleAction Связь выбора и активации с состоянием SwiftUI
Пользовательская поверхность для рисования в AppKit Собственная обработка событий мыши Проверка попадания, идентичность элементов и правила последовательности щелчков

Первый вариант напрямую подходит для этой задачи. Пользовательские варианты пригодятся, если интерфейсу действительно нужна другая поверхность; при этом для них придется реализовать дополнительное поведение взаимодействия. Apple описывает лежащие в их основе альтернативы в документации по сочетанию жестов, двойным действиям таблиц AppKit и обработке нажатия кнопки мыши.

Создайте полный пример с выбором одного элемента

Создайте в Xcode проект приложения для macOS с интерфейсом SwiftUI и задайте целевую версию macOS 13 или новее. Замените сгенерированные объявления приложения и представления содержимого следующим файлом. В целевом объекте оставьте только одно объявление приложения с @main.

В программе используются фиксированные целочисленные идентификаторы, три цветных круга и локальное состояние. Она не читает файлы и не открывает внешние ресурсы. Кнопка Open в панели инструментов и команда Open в контекстном меню вызывают ту же функцию, что и активация двойным щелчком.

import SwiftUI

struct SampleItem: Identifiable {
    let id: Int
    let name: String
    let tint: Color
}

@main
struct ClickSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemBrowser()
        }
    }
}

struct ItemBrowser: View {
    private let items = [
        SampleItem(id: 1, name: "Circle A", tint: .blue),
        SampleItem(id: 2, name: "Circle B", tint: .orange),
        SampleItem(id: 3, name: "Circle C", tint: .purple)
    ]

    @State private var selectedID: SampleItem.ID? = nil
    @State private var openedItem: SampleItem? = nil
    @State private var message = "Select an item."

    private var selectedItem: SampleItem? {
        items.first { $0.id == selectedID }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(selectedItem.map {
                    "Selected: \($0.name)"
                } ?? "No selection")
                Spacer()
                Button("Open") {
                    if let id = selectedID {
                        openItems([id])
                    }
                }
                .disabled(selectedItem == nil)
            }

            List(items, selection: $selectedID) { item in
                HStack(spacing: 10) {
                    Circle()
                        .fill(item.tint)
                        .frame(width: 18, height: 18)
                    Text(item.name)
                }
                .padding(.vertical, 4)
            }
            .contextMenu(forSelectionType: SampleItem.ID.self) { ids in
                if ids.count == 1 {
                    Button("Open") {
                        openItems(ids)
                    }
                }
            } primaryAction: { ids in
                openItems(ids)
            }

            Text(message)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 360, minHeight: 280)
        .sheet(item: $openedItem) { item in
            VStack(spacing: 16) {
                Circle()
                    .fill(item.tint)
                    .frame(width: 80, height: 80)
                Text(item.name)
                    .font(.title2)
                Button("Close") {
                    openedItem = nil
                }
            }
            .padding(32)
            .frame(minWidth: 260, minHeight: 220)
        }
    }

    private func openItems(_ ids: Set<SampleItem.ID>) {
        guard ids.count == 1, let id = ids.first else {
            message = "Choose exactly one item to open."
            return
        }
        guard let item = items.first(where: { $0.id == id }) else {
            message = "That item is no longer available."
            return
        }
        openedItem = item
        message = "Opened: \(item.name)"
    }
}

Объявления и ссылки проверены, однако этот пример не собирался и не запускался здесь: в доступной среде нет инструментов Swift и графического интерфейса macOS. Соберите его в Xcode и выполните описанные ниже проверки взаимодействия.

Разделяйте идентичность, выбор и открытие

SampleItem соответствует протоколу Swift Identifiable. Его фиксированный id обозначает один и тот же элемент при обновлении представления. Apple описывает этот протокол как способ задавать стабильную идентичность; отображаемое имя — отдельное свойство. В большом приложении сохраняйте идентификатор элемента, даже если меняются его имя или внешний вид. Документация Apple по Identifiable.

Необязательный selectedID создает список с выбором одного элемента. Множество идентификаторов вместо этого включило бы выбор нескольких элементов. В документации SwiftUI по спискам описаны обе формы привязки и отображение выбранного элемента, обеспечиваемое контейнером. Документация Apple по List.

openedItem независимо управляет отображением. Присваивание ему элемента показывает лист; присваивание nil закрывает его. Это соответствует документированному API листа, управляемого элементом. Поскольку выбор строки никогда не присваивает значение openedItem, в этой реализации один лишь выбор не может показать лист. Документация Apple по листу, управляемому элементом.

Разбирайте идентификаторы, переданные действию

API контекстного меню принимает тип идентификатора контейнера, а действие получает множество идентификаторов. Поэтому в примере используется SampleItem.ID.self, соответствующий выбору в списке. Обработчик разрешает аргумент обратного вызова, а не подменяет его отдельно прочитанным значением выбора. Документация Apple о параметрах контекстного меню.

Обработчик намеренно принимает ровно один идентификатор. Пустое множество выводит сообщение, а неразрешенный идентификатор — другое сообщение. Если позднее вы включите выбор нескольких элементов, сначала определите правила открытия, а затем меняйте эту проверку: показывать подробности группы, открывать отдельные окна или требовать выбора одного элемента. Выбор произвольного первого элемента скрыл бы отсутствие определенных правил.

Почему два обработчика нажатий требуют другого решения

Для пользовательской плитки могут понадобиться собственные жесты. SwiftUI onTapGesture(count:perform:) принимает требуемое число щелчков или касаний. Однако в документации не сказано, что размещение двух независимых модификаторов на представлении создает исключительное правило для одиночного и двойного щелчка. Документация Apple по обработке касаний.

Сначала решите, что должен означать выбор при двойном щелчке. В обычном браузере элементов полезно выбрать элемент перед его открытием; это согласуется с решением на основе контейнера. Если требуется выполнять действие одиночного щелчка только в том случае, когда последовательность не превращается в двойной щелчок, действия нужно согласовать.

Для пользовательской поверхности SwiftUI предоставляет exclusively(before:): срабатывает только один компонентный жест, причем приоритет отдается первому. Ниже приведен фрагмент выражения жеста, а не полная замена примера со списком: Документация Apple по эксклюзивным жестам.

TapGesture(count: 2)
    .exclusively(before: TapGesture(count: 1))

Так распознавание двойного щелчка получает приоритет. Добавьте составной жест к пользовательскому элементу и обрабатывайте его результат согласно правилам выбора и открытия. В документации TapGesture описано распознавание по требуемому количеству касаний. Документация Apple по TapGesture.

У такого правила есть последствие, связанное со временем: чтобы определить, что последовательность является исключительно одиночным щелчком, нужно исключить двойной щелчок. Это следствие требований к взаимодействию, а не документированная фиксированная задержка SwiftUI. Не обещайте мгновенное завершение одиночного щелчка, если одновременно требуется, чтобы он никогда не выполнялся в последовательности двойного щелчка.

На пользовательской плитке также определите область, по которой можно щелкнуть. contentShape(Rectangle()) позволяет прямоугольной области с отступами участвовать в проверке попадания, но не задает приоритет жестов. При поиске причин пропущенных щелчков рассматривайте эти две задачи отдельно. Документация Apple по форме содержимого.

Сохраняйте владение событиями при использовании AppKit

Если строками управляет существующая таблица AppKit, используйте поддерживаемый механизм активации этой таблицы. NSTableView.doubleAction вызывает селектор цели при двойном щелчке. Apple также документирует clickedRow и clickedColumn для определения места активации, в том числе заголовков столбцов. Перед открытием убедитесь, что это место соответствует элементу. Документация Apple по doubleAction.

Для пользовательской поверхности AppKit mouseDown(with:) получает событие нажатия левой кнопки мыши, а NSEvent.clickCount сообщает число щелчков, связанное с событиями нажатия или отпускания кнопки мыши. Чтение этого свойства для постороннего события выходит за рамки документированного контракта и может вызвать исключение. Обрабатывайте такие события внутри представления, которому они предназначены. Документация Apple по нажатию кнопки мыши, документация Apple по числу щелчков.

Используйте NSViewRepresentable, чтобы встроить принадлежащее вам представление AppKit в SwiftUI. Создавайте его в makeNSView, синхронизируйте текущую конфигурацию в updateNSView и используйте координатор, если для связи через target-action или делегат нужен мост. Apple описывает создание, обновление, координацию и освобождение ресурсов как этапы этой интеграции. Документация Apple по NSViewRepresentable.

Накладной слой, получающий события мыши, может изменить представление, которое обрабатывает щелчок. AppKit использует проверку попадания, чтобы определить получателя событий нажатия кнопки мыши, поэтому слой, перехватывающий события, не следует считать невидимым наблюдателем. В этой задаче оставьте распознавание в контейнере с поддержкой выбора или на пользовательской поверхности, которой действительно принадлежит взаимодействие с элементом. Документация Apple по проверке попадания.

Проверяйте выбор и открытие как отдельные результаты

После сборки примера выполните следующие действия:

  1. Один раз щелкните Circle A. Его строка должна стать выбранной, подпись выбора должна назвать его, а лист появляться не должен.
  2. Дважды щелкните Circle B. Убедитесь, что в листе с подробностями показан Circle B.
  3. Закройте лист и один раз щелкните Circle C. Убедитесь, что выбор меняется, а новый лист не открывается.
  4. Активируйте видимую кнопку Open. Убедитесь, что она открывает выбранный элемент через тот же обработчик.
  5. Вызовите контекстную команду Open для строки и проверьте, какой элемент она открывает.
  6. Щелкните один раз и дважды по пустому месту списка. Убедитесь, что это не открывает посторонний элемент.
  7. Если вы добавили встроенные элементы управления, перетаскивание или накладные слои, повторите проверки в этих конкретных областях.

Если при выборе неожиданно открываются подробности, проверьте, где присваивается состояние отображения. Если активация открывает не тот элемент, проверьте идентификаторы обратного вызова и поиск в модели. Если на части пользовательской плитки щелчки не срабатывают, перед изменением приоритета жестов проверьте ее компоновку и область проверки попадания. Эти проверки позволяют изолировать состояние, выбор элемента для действия и доставку событий, не меняя все три составляющие одновременно.

Для обычного списка элементов на Mac стройте реализацию на привязке выбора и основном действии контейнера. Добавляйте пользовательское распознавание, только если этого требует поверхность или взаимодействие, и во всех случаях открытия находите нужный элемент через один явный обработчик.

Источники