Каждому view по всплывающему меню

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

Собираем элементы меню

Итак, мы собираемся показать всплывающие меню для некоторого UIView. Для начала определимся, какие именно элементы будет предлагать наше меню пользователю.

Каждому элементу меню соответствует объект класса UIItemView. Через него в контроллер меню передается отображаемый текст и действие, происходящее при выборе данного элемента:

let item = UIMenuItem(title: "Send", action: #selector(sendTapped))

Рассмотрим поподробнее второй параметр — action. Окей, это селектор некоторого метода, который будет вызван при выборе элемента меню. Значит, этот метод должен быть где-то реализован, но где именно? Если бы это была обычная кнопка, то получателя можно указать в методе addTarget:

sendButton.addTraget(self, action: #selector(sendTapped), for: ...)

Но в случае UIMenuItem у нас нет возможности задать получателя. Куда же тогда отправится action? Обратимся к документации:

Targets are not specified; a suitable target is found via normal traversal of the responder chain.

Responder chain? Похоже, прежде чем двигаться дальше, нам придется немного разобраться в процессе обработки событий в приложении.

Responder chain и firstResponder

Любое приложение можно представить в виде иерархии объектов класса UIResponder, наследниками которого являются UIView, UIViewController и UIApplication. Каждый такой объект способен получать события: нажатия, motion-события или UIControlEvents, и либо обрабатывать полученное событие, либо передавать его следующему responder’у в иерархии.

Иерархия UIResponder в приложении (source)

Но, прежде чем попасть в иерархию responder’ов, событие должно быть получено кем-то в первую очередь. Поэтому для каждого возникшего в приложении события UIKit определяет наиболее подходящий объект класса UIResponder, который и получает первым это событие. Этот объект становится firstResponder’ом для данного события, а цепочка responder’ов от него до UIApplicatoinresponder chain.

Алгоритм определения firstResponder’a различен для событий разного типа. К счастью, в документации Apple можно найти отличную статью, охватывающую весь механизм обработки событий, в том числе и выбор firstResponder’а. И сейчас нас интересует один конкретный пункт:

Editing menu messages: The first responder is the object that you (or UIKit) designate as the first responder.

Отлично, значит, в данном случае мы имеем дело с firstResponder’ом, управляемым посредством методов becomeFirstResponder() и resignFirstResponder().

Теперь мы можем вернуться к заданию действий для нашего меню. Так как у нас есть конкретный объект UIView, для которого мы хотим показать это меню, логичным шагом будет сделать firstResponder’ом именно его. Для этого нам придется отнаследоваться от UIView, и переопределить свойство canBecomeFirstResponder, которое по умолчанию равноfalse:

class ResponsiveView: UIView {
override var canBecomeFirstResponder: Bool {
return true
}
}

Итак, вернемся к вопросу о том, где должны быть реализованы методы, селекторы которых передаются в UIMenuItem. Теперь мы знаем, что UIKit будет искать эти методы у firstResponder’а, и выше по иерархии responder’ов. Реализовывать бизнес-логику приложения прямо во View — не самая лучшая идея, поэтому реализуем эти методы во ViewController’е, находящемся несколько дальше в responder chain:

// Элементы меню
//
// UIMenuItem(title: "Red", action: #selector(redTapped))
// UIMenuItem(title: "Green", action: #selector(greenTapped))
// UIMenuItem(title: "Blue", action: #selector(blueTapped))
//
class ViewController: UIViewController { ..... private let targetView = ResponsiveView() ..... @objc private func redTapped() {
targetView.backgroundColor = .red
targetView.resignFirstResponder()
}
@objc private func greenTapped() {
targetView.backgroundColor = .green
targetView.resignFirstResponder()
}
@objc private func blueTapped() {
targetView.backgroundColor = .blue
targetView.resignFirstResponder()
}
}

Нам все еще необходимо сделать targetView firstResponder’ом, но об этом чуть позже. Пока что можно отметить, что в каждом методе вызывается resignFirstResponder(). Это необязательный, но логичный шаг, так как targetView необходимо быть firstResponder’ом только на время вызова всплывающего меню, поэтому мы снимаем с него эту роль, как только меню скрывается.

Встроенные действия

Для часто используемых во всплывающих меню операций: Cut, Copy, Paste, и некоторых других (полный список можно найти в протоколе UIResponderStandardEditActions) элементы меню создадутся автоматически, если добавить соответствующие методы в один из responder’ов в цепочке. Отображаемый текст таких элементов меню подставляется автоматически и уже будет локализован в зависимости от языка приложения.

Добавим, для примера, в наш ViewController метод для действия Select:

extension ViewController {    @objc override func select(_ sender: Any?) {
// Select something
}
}

Вызываем меню

С отдельными элементами меню более-менее раборались, теперь наконец-то займемся его показом. Чаще всего всплывающие меню показывают по лонгтапу на требуемый объект. Добавим GestureRecognizer к targetView:

let longPressRecognizer = UILongPressGestureRecognizer(
target: self,
action: #selector(showMenu(_:))
)
targetView.addGestureRecognizer(longPressRecognizer)

И обработаем вызов меню в методе showMenu:

@objc private func showMenu(_ sender: UIGestureRecognizer) {
guard sender.state == .began else { return }
let menuController = UIMenuController.shared
guard !menuController.isMenuVisible else { return }
guard targetView.becomeFirstResponder() else { return } menuController.menuItems = [
UIMenuItem(title: "Red", action: #selector(redTapped)),
UIMenuItem(title: "Green", action: #selector(greenTapped)),
UIMenuItem(title: "Blue", action: #selector(blueTapped))
]

menuController.setTargetRect(targetView.frame, in: view)
menuController.setMenuVisible(true, animated: true)
}

Разберем поподробнее, что происходит в этом методе. Первые интересующие нас строчки:

let menuController = UIMenuController.shared
guard !menuController.isMenuVisible else { return }

Мы берем shared instance контроллера из UIMenuController.shared. Это стандартное решение, когда нужно контролировать, что на экране всегда будет только одно меню. Именно это мы и проверяем в guard на второй строке.

Далее следует вызов метода, который уже обсуждался выше, мы пытаемся сделать targetView firstResponder’ом:

guard targetView.becomeFirstResponder() else { return }

Далее идет настройка показываемого меню: мы кладем в поле menuItems массив элементов, задаем targetRect: точная позиция меню на экране будет определена относительно переданного туда frame. И наконец вызывается анимированный показ меню.

Отлично, все работает! Цель достигнута…. Но всегда есть одно “но”. Получившийся код довольно трудно реиспользовать. Если мы захотим вызвать такое же меню в другой части нашего приложения, то методы, селекторы которых передаются в UIItemView, придется реализовать в другом ViewController’e заново.

Решением могла бы стать реализация этих методов внутри View и передача обработки нажатий через публичные поля:

class MenuView: ResponsiveView {    var onRedTapped: (() -> Void)?        @objc private func redTapped() {
onRedTapped?()
}
}

Но и такое решение далеко не идеально. Если нам потребуется меню с другим набором элементов, то либо придется делать другой класс-наследник ResponsiveView, либо реализовывать в MenuView методы для всех возможных элементов меню, даже если показаны будут лишь некоторые из них.

Однако, выход есть. Немного шаманства с селекторами и магии Objective-C позволят нам создать view с возможностью вызывать всплывающее меню любого размера, и все, что для этого нужно — передать набор элементов и действия при их выборе.

Шаманим с элементами

Первым делом, займемся UIMenuItem. Что нас в них не устраивает? То, что они содержат селекторы методов, в которые нужно положить бизнес-логику обработки нажатий на элементы меню. Давайте обернем эту логику в closure и передадим в Item готовый обработчик нажатия:

class MenuItem: UIMenuItem {

init(title: String, tapHandler: @escaping () -> Void) {
onTap = tapHandler
super.init(title: title,
action: Selector("menuItem_\(title)"))
}
fileprivate let onTap: () -> Void}

Заметьте, какой селектор мы передаем в super.init. На самом деле, не важно, какую именно строку мы передадим, главное — чтобы этот селектор был уникальным.

Переносим показ меню во view

Теперь начнем создавать универсальное решение: view со встроенным всплывающим меню, вызывающимся по лонгтапу. Для начала, перенесем внутрь код, который раньше лежал во ViewController’e:

class LongPressMenuView: UIView {    var longPressMenuItems: [MenuItem] = []    override init(frame: CGRect) {
super.init(frame: frame)
addGestureRecognizer(longPressGesture)
}
private lazy var longPressGesture =
UILongPressGestureRecognizer(
target: self,
action: #selector(showMenu(_:))
)
@objc private func showMenu(_ sender: UIGestureRecognizer) {
guard sender.state == .began else { return }

let menuController = UIMenuController.shared
guard !menuController.isMenuVisible else { return }
guard becomeFirstResponder() else { return } menuController.menuItems = longPressMenuItems
menuController.setTargetRect(bounds, in: self)
menuController.setMenuVisible(true, animated: true)
}
}

Элементы меню мы теперь передаем в поле longPressMenuItems снаружи. Выглядит неплохо, но, пока что, меню просто не появится, так как методы для action‘ов меню не будут найдены. Исправим это, переопределив метод canPerformAction:

override func canPerformAction(
_ action: Selector, tt
withSender sender: Any?) -> Bool
{
let isMenuAction = longPressMenuItems.contains {
$0.action == action
}
if isMenuAction { return true }
return super.canPerformAction(action, withSender: sender)
}

Так мы обманываем UIKit, говоря, что можем обработать действия, соответствующие селекторам из MenuItem’ов. И система верит нам, и меню действительно появляется. Но, на самом-то деле, никаких методов нет, и, при выборе любого элемента меню, приложение крешнет с ошибкой unrecognized selector sent to instance XXX.

Теперь мы подходим к самому интересному: как вызвать closure, лежащий в MenuItem, вместо метода, селектор которого пытается вызвать меню. Для начала, небольшая реорганизация кода: чтобы не искать каждый раз в массиве конкретный MenuItem, положим элементы меню в Dictionary [Selector: MenuItem]:

class LongPressMenuView: UIView {    var longPressMenuItems: [MenuItem] = [] {
didSet {
menuItemsMap.removeAll()
for item in longPressMenuItems {
menuItemsMap[item.action] = item
}
}
}
..... override func canPerformAction(
_ action: Selector,
withSender sender: Any?) -> Bool
{
if menuItemsMap[action] != nil { return true }
return super.canPerformAction(action, withSender: sender)
}
..... private var menuItemsMap: [Selector: MenuItem] = [:]}

Вызываем closure вместо метода

Вернемся к основной проблеме: нам нужно как-то отловить момент попытки вызова селектора у view, и вызвать там onTap() у соответствующего этому селектору MenuItem.

В поисках решения заглянем глубже, в интерфейс NSObject. Там мы находим идеального кандидата для наших целей — метод forwardInvocation, который вызывается, если запросить у объекта вызов нереализованного метода. Переопределение forwardInvocation дает возможность перенаправить этот вызов другому объекту, но по умолчанию он вызывает ошибку unrecognized selector sent to instance:

NSObject’s implementation of forwardInvocation:simply invokes the doesNotRecognizeSelector:method; it doesn’t forward any messages.

Но, к сожалению, метод forwardInvocation доступен только из Objective-C. Неужели придется весь код LongPressMenuView переносить туда? Нет, мы поступим хитрее.

Прежде чем передать управление forwardInvocation, система вызывает другой метод, выполняющий схожую функцию: forwardingTarget(for:) В отличие от первого метода, работающего с NSInvocation, структурой, хранящей всю известную информацию о вызываемом методе, в forwardingTarget передается лишь селектор:

This method gives an object a chance to redirect an unknown message sent to it before the much more expensive forwardInvocation:machinery takes over.

С методом forwardingTarget можно работать из Swift, поэтому попробуем переопределить этот метод и вызвать в нем closure элементов меню:

override func forwardingTarget(for aSelector: Selector) -> Any? {
if let menuItem = menuItemsMap[aSelector] {
menuItem.onTap()
}
return super.forwardingTarget(for: aSelector)
}

Хорошая новость: наши onTap() действительно вызываются при выборе элементов меню. Плохая новость: приложение все еще крешится. И это логично, так как мы не перенаправили селектор на другой объект, то после вызова forwardingTarget управление перейдет в forwardInvocation. Избежать этого можно лишь одним способом: вернуть из forwardingTarget объект, который сможет обработать все наши селекторы.

SelectorSink

Итак, нам нужен объект, который может проглотить вызовы всех селекторов. Так как мы уже полностью абстрагировались от всей логики, связанной со всплывающим меню и с UIKit, спокойно перейдем в Objective-C:

// SelectorSink.h#import <Foundation/Foundation.h>@interface SelectorSink : NSObject@end

В интерфейсе — только наследование от NSObject, вся магия внутри:

// SelectorSink.m#import <Foundation/Foundation.h>
#import "SelectorSink.h"
@implementation SelectorSink- (void)forwardInvocation:(NSInvocation *)anInvocation {
return;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSMethodSignature *superResult =
[super methodSignatureForSelector:aSelector];
if (superResult != nil) {
return superResult;
} else {
return [self methodSignatureForSelector:@selector(dummy)];
}
}
- (void)dummy { }@end

Разберем по порядку, что делает этот объект. Первым идет уже знакомый нам forwardInvocation. Единственное, что нам от него требуется — чтобы он не вызывал fatalError, поэтому просто переопределяем его, убирая всю логику.

Второй реализованный метод — methodSignatureForSelector. Так как SelectorSink никак не связан с классом UIResponder, мы не можем переопределить метод canPerformAction, чтобы сообщить о том, что мы можем обработать селектор. Но мы можем опуститься глубже в Objective-C runtime и подменить NSMethodSignature, возвращаемый для всех неизвестных нашему объекту селекторов, на сигнатуру метода-заглушки.

Результат будет аналогичным: система решает, что SelectorSink может обработать любой селектор, пытается его вызвать, попадет в forwardInvocation… и заканчивает обработку селектора, так как forwardInvocation не приводит к ошибке.

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

Остается только вернуть SelectorSink в методе forwardingTarget нашего класса LongPressMenuView (не забыв добавить SelectorSink.h в bridging header проекта):

override func forwardingTarget(for aSelector: Selector) -> Any? {
if let menuItem = menuItemsMap[aSelector] {
menuItem.onTap()
return SelectorSink()
} else {
return super.forwardingTarget(for: aSelector)
}
}

Поздравляю, немного магии и мы создали наследника UIView, которому достаточно снаружи передать названия кнопок и обработчики нажатий на них, и он покажет всплывающее меню любого размера. Именно такой класс используется в Яндекс.Картах во всех случаях, когда нужно показать меню.

Ниже можно посмотреть полный код класса LongPressMenuView. Кроме всего описанного выше, в нем присутствует подписка на событие UIMenuControllerWillHideMenu в NotificationCenter, что дает возможность всегда вызывать resignFirstResponder() в момент скрытия меню.

--

--