На WWDC 2019 Apple представила новый способ взаимодействия с интерфейсом вашего приложения: контекстные меню. Они выглядят так:

Контекстные меню являются логичным продолжением технологии “Peek and Pop”, когда пользователь мог открыть предпросмотр элемента, сильно нажав на него. Но между ними есть и несколько существенных отличий.
- Контекстные меню работают на любых устройствах под управлением iOS 13. Поддержка 3D touch от устройства не требуется. Поэтому, в частности, их можно применять на всех iPad.
- Кнопки, позволяющие взаимодействовать с элементом, появляются сразу и не требуют свайпа вверх.
Чтобы открыть контекстное меню, пользователю достаточно удержать палец на нужном элементе или сильно на него нажать (если устройство поддерживает 3D Touch).
Рекомендации при использовании контекстных меню
Apple в Human Interface Guidelines рекомендует придерживаться следующих правил при проектировании контекстных меню.
Проектируйте правильно
Будет нехорошо, если вы добавите меню для некоторых элементов в одних местах и не добавите его для похожих элементов в других. Тогда пользователю будет казаться, что приложение работает неправильно.
Включайте в меню только необходимое
Контекстное меню – отличное место для наиболее часто использующихся команд. “Наиболее часто” – ключевая фраза. Не добавляйте туда все подряд.
Используйте вложенные меню
Используйте вложенные меню, чтобы пользователю было проще сориентироваться. Дайте пунктам меню простые и понятные названия.
Используйте не более 1 уровня вложенности
Несмотря на то, что вложенные меню могут сделать навигацию проще, они ее могут и запросто усложнить. Apple не рекомендует использовать более 1 уровня вложенности.
Располагайте наиболее часто используемые пункты в верхней части
Люди в первую очередь фокусируются на верхней части меню, поэтому так им немного проще будет сориентироваться в вашем приложении.
Используйте группировку
Группируйте похожие пункты меню
Избегайте одновременного использования контекстного меню и меню редактирования на одном элементе
Они могут конфликтовать друг с другом, потому что оба вызываются долгим тапом.

Не добавляйте отдельную кнопку “Открыть” в меню
Пользователи могут открыть элемент, просто тапнув по нему. Дополнительная кнопка “Открыть” будет лишней.
Простейшее контекстное меню для UIView
Теперь, когда мы усвоили основные правила использования контекстных меню, перейдем к практике. Разумеется, меню работают только на iOS 13 и выше и для тестирования вам понадобится Xcode 11. Beta-версию Xcode 11 вы можете скачать здесь.
Вы можете скачать пример полностью отсюда.
Давайте добавим контекстное меню, например, на UIImageView
, как в анимации выше.
Для этого достаточно добавить объект UIImageView
на контроллер и написать несколько строк кода, например в методе viewDidLoad
:
class SingleViewController: UIViewController { @IBOutlet var imageView: UIImageView! override func viewDidLoad() { super.viewDidLoad() imageView.isUserInteractionEnabled = true let interaction = UIContextMenuInteraction(delegate: self) imageView.addInteraction(interaction) } }
В начале создается объект класса UIContextMenuInteraction
. Конструктор требует указать делегат, который будет отвечать за меню. Вернемся к этому чуть позднее. А методом addInteraction
мы добавляем наше меню к картинке.
Теперь осталось реализовать протокол UIContextMenuInteractionDelegate
. В нем только один обязательный метод, который отвечает за создание меню:
extension SingleViewController: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { actions -> UIMenu<UIAction>? in let save = UIAction(__title: "My Button", image: nil, options: []) { action in // Put button handler here } return configuration } }
Если в этом методе вернуть nil
, то контекстное меню не будет вызвано. Внутри самого метода мы создаем объект класса UIContextMenuConfiguration
. При создании мы передаем эти параметры:
identifier
– идентификатор меню.previewProvider
– кастомный контроллер, который опционально может быть отображен вместо текущего элемента в меню. Мы рассмотрим это чуть позднее.- в
actionProvider
мы передаем элементы контекстного меню.
Сами элементы создаются проще некуда: указывается название, опциональная иконка и обработчик нажатия на пункт меню. Вот и все!
Добавляем вложенное меню
Давайте немного усложним. Добавим к нашей картинке меню с двумя пунктами: “Save” и “Edit…”. По нажатии на “Edit…” откроется подменю с пунктами “Rotate” и “Delete”. Это должно выглядеть так:

Для этого надо переписать метод протокола UIContextMenuInteractionDelegate
следующим образом:
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { actions -> UIMenu<UIAction>? in // Creating Save button let save = UIAction(__title: "Save", image: UIImage(systemName: "tray.and.arrow.down.fill"), options: []) { action in // Just showing some alert self.showAlert(title: action.title) } // Creating Rotate button let rotate = UIAction(__title: "Rotate", image: UIImage(systemName: "arrow.counterclockwise"), options: []) { action in self.showAlert(title: action.title) } // Creating Delete button let delete = UIAction(__title: "Delete", image: UIImage(systemName: "trash.fill"), options: .destructive) { action in self.showAlert(title: action.title) } // Creating Edit, which will open Submenu let edit = UIMenu<UIAction>.create(title: "Edit...", children: [rotate, delete]) // Creating main context menu return UIMenu<UIAction>.create(title: "Menu", children: [save, edit]) } return configuration }
Здесь мы создаем последовательно кнопки “Save”, “Rotate” и “Delete”, добавляем последние две в подменю “Edit…” и оборачиваем все в главное контекстное меню.
Добавляем контекстное меню в UICollectionView
Давайте добавим контекстное меню в UICollectionView
. При долгом нажатии на ячейку пользователю будет показано меню с пунктом “Archive”, вот так:

Добавление контекстного меню в UICollectionView
проще простого: достаточно реализовать опциональный метод func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
протокола UICollectionViewDelegate
. Вот, что у нас вышло:
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { actions -> UIMenu<UIAction>? in let action = UIAction(__title: "Archive", image: UIImage(systemName: "archivebox.fill"), options: .destructive) { action in // Put button handler here } return UIMenu<UIAction>.create(title: "Menu", children: [action]) } return configuration }
Тут, как и прежде, создается элемент и само меню. Теперь при долгом (сильном) нажатии на ячейку пользователь увидит контекстное меню.
Добавляем контекстное меню в UITableView
Здесь все аналогично UICollectionView
. Нужно имплементировать метод contextMenuConfigurationForRowAt
протокола UITableViewDelegate
так:
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { actions -> UIMenu<UIAction>? in let action = UIAction(__title: "Custom action", image: nil, options: []) { action in // Put button handler here } return UIMenu<UIAction>.create(title: "Menu", children: [action]) } return configuration }
Но что, если мы хотим использовать кастомный экран в контекстном меню? Например, такой:

Для этого при создании UIContextMenuConfiguration
следует передать нужный UIViewController
в previewProvider
. Вот пример кода, реализующего это:
class PreviewViewController: UIViewController { static func controller() -> PreviewViewController { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewController(withIdentifier: "PreviewViewController") as! PreviewViewController return controller } } extension TableViewController: UITableViewDelegate { override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in // Return Preview View Controller here return PreviewViewController.controller() }) { _ -> UIMenu<UIAction>? in let action = UIAction(__title: "Custom action", image: nil, options: []) { action in // Put button handler here } return UIMenu<UIAction>.create(title: "Menu", children: [action]) } return configuration } }
В примере PreviewViewController
инициализируется из сториборда и отображается в контекстном меню.
Осталось добавить обработку нажатия на этот ViewController. Для этого нужно имплементировать метод willCommitMenuWithAnimator
протокола UITableViewDelegate
. Сам обработчик поместим внутрь animator.addCompletion
:
override func tableView(_ tableView: UITableView, willCommitMenuWithAnimator animator: UIContextMenuInteractionCommitAnimating) { animator.addCompletion { // Put handler here } }
Заключение
Контекстные меню — это новый мощный инструмент взаимодействия пользователя с вашим приложением. И, как видите, их реализация довольно проста. Но не следует забывать, что методы могут измениться, пока не выйдет релизная версия iOS 13.
Спасибо, если вы дочитали статью до конца. Надеюсь, она оказалась вам полезной. А еще полезным может оказаться сервис, который мы разрабатываем. Apphud — это удобная аналитика для подписок на iOS. Одна из функций Apphud — это отправка событий о подписках в вашу любимую систему аналитики (например, Amplitude, Flurry или Mixpanel). Проект сейчас находится на стадии Beta-тестирования, и вы можете поучаствовать в нем! Все что нужно — перейти на сайт Apphud и оставить свой email.