UIGestureRecognizer: теория, практика, кастомизация

Представляя первый iPhone в 2007 году, Стив Джобс апеллировал к устареванию концепции физической клавиатуры у мобильных устройств: “Buttons & controls can’t change”. С этого момента началось стремительное развитие устройств с качественными тачскринами, позволившими отказаться и от физических кнопок, и от стилусов. Теперь палец — главный инструмент управления девайсом и мы можем тапать по кнопкам, свайпать списки, пинчить фотографии… Давайте разберемся как это реализовано со стороны софта и научимся использовать всю мощь механизма распознавания жестов.

UITouch и его обработка

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

Что происходит, когда палец пользователя касается экрана? Система создает объект типа UITouch, который будет ассоциирован с текущим касанием на все время его жизни, пока палец пользователя не оторвется от экрана. Это значит, что на время всей цепочки событий

“палец коснулся экрана → палец движется по экрану → палец оторвался от экрана”

для каждого пальца, касающегося экрана, существует уникальный объект UITouch. Далее, используя механизм hit-testing’а, находится самая глубокая в иерархии UIView, frame которой содержит в себе точку касания экрана. Полученная UIView становится firstResponder, начинает получать уведомления о UITouch и прокидывать их дальше по responderChain.

Для обработки поступающих событий, UIView предоставляет несколько методов:

  • touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) — начало касания (экрана коснулись пальцы)
  • touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) — изменение параметров касания (позиция на экране, сила (ForceTouch))
  • touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) — конец касания (пальцы были убраны с экрана)
  • touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) — отмена касания (далее рассмотрим, что это значит)

Так как экран iPhone поддерживает мультитач, то в один момент времени может измениться состояние сразу нескольких касаний. Например, если пользователь коснется экрана двумя пальцами одновременно, система вызовет touchesBegan лишь один раз, но во множестве Set<UITouch> мы получим два объекта — каждый для своего пальца.

Первые три метода (touchesBegan, touchesMoved, touchesEnded) отвечают за “нормальный” жизненный цикл UITouch и вызываются при начале касания, изменении его параметров (позиция на экране, сила нажатия) и конце касания соответсвенно.

Однако, touchesCancelled выбивается из их ряда. На самом деле, концом жизненного цикла UITouch может быть не только физический конец жеста, но и какое-либо программное событие, в следствие которого система будет вынуждена прервать обработку данного касания. Это может произойти, например, в случае, если появился интерфейс входящего звонка и продолжение обработки касания будет некорректным — пользователь уже находится в контексте другого приложения (телефон). Ожидается, что при получении touchesCancelled приложение отменит все действия которые могли быть произведены в touchesBegan/Moved. Для понимания логики этого требования рассмотрим кнопку. Пусть пользователь нажал на нее, но еще не поднял палец и в это время происходит входящий звонок. Если обработать touchesCancelled аналогично touchesEnded и вызвать обработчик нажатия кнопки, то после окончания звонка и возврата в приложение будет произведено какое-то действие, которое пользователь мог не ожидать.

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

  1. Для каждого нового UITouch создаем DispatchWorkItem, который, будучи выполненным через 0.3c вызовет callback onLongTap.
  2. Если пользователь достаточно сильно сдвинет палец от начальной точки касания, то такой жест не надо обрабатывать как long-tap и надо отменить его DispatchWorkItem.
  3. В случае, если касание было прекращено пользователем или отменено системой, то вызываем cancel() у отвечающего ему DispatchWorkItem, тем самым предотвращая вызов callback’a, если он еще не был осуществлен.

Вот и все! Теперь мы можем распознавать долгие нажатия на нашей UIView. Но сколь прост получившейся код, столь же он и плох с точки зрения архитектуры. Представим, что мы хотим “научить” еще какую-нибудь UIView в приложении обрабатывать долгие нажатия. Или больше — перед нами стоит задача отлавливать и долгое нажатие, и двойной тап по UIView одновременно. Какие у нас варианты? Можно либо скопировать код, либо прибегнуть к наследованию. Очевидно, что ни тот, ни другой подход тут неприемлем. Так или иначе, но мы должны вынести логику по обработке UITouch событий в независимую сущность. И тут, наконец, на поле выходит UIGestureRecognizer.

UIGestureRecognizer

UIGestureRecognizer — сущность, предоставляемая нам UIKit и инкапсулирующая в себе всю логику по работе с UITouch объектами. Имея UIGestureRecognizer легко “научить” любую UIView распознавать какой-либо жест, достаточно добавить инстанс необходимого рекогнайзера на UIView через addGestureRecognizer и настроить обработку событий посредствам паттерна Target-Action.

UIGestureRecognizer инкапсулирует в себе логику по работе с UITouch/UIPress объектами

UIKit предлагает нам на выбор несколько стандартных реализаций UIGestureRecognizer’а:

  • UITapGestureRecognizer — тапы
  • UIPinchGestureRecognizer — щипковый жест (зум) двумя пальцами
  • UIRotationGestureRecognizer — жест “поворота” двумя пальцами
  • UISwipeGestureRecognizer — свайп в любом направлении
  • UIPanGestureRecognizer — скролл в любом направлении
  • UIScreenEdgePanGestureRecognizer — скролл от края экрана (как жест возврата к предыдущему экрану через смахивание от края дисплея)
  • UILongPressGestureRecognizer — долгое нажатие

Наш код обработки долгого нажатия может быть значительно упрощен с использованием UILongPressGestureRecognizer:

Идейно все рекогнайзеры делятся на два типа:

  • дискретные (UITapGestureRecognizer, UILongPressGestureRecognizer, UISwipeGestureRecognizer)
  • непрерывные (UIPinchGestureRecognizer, UIRotationGestureRecognizer, UIPanGestureRecognizer, UIScreenEdgePanGestureRecognizer)

Работа с дискретными рекогнайзерами напоминает работу с обычной UIButton — задаем action, который будет вызван единожды по факту наступления требуемого события.

Непрерывные рекогнайзеры, напротив, будут вызывать action-метод много раз при каждом значимом для распознаваемого ими жеста изменении. Например, UIPanGestureRecognizer будет обращаться к action-методу всякий раз, как палец пользователя будет сдвигаться по экрану, чтобы мы могли обработать это движение и соответствующим образом обновить UI.

Одним из основных свойств непрерывных рекогнайзеров, которое нам необходимо обрабатывать в action-методе является state: UIGestureRecognizer.State, которое может принимать следующие значения:

  • possible — рекогнайзер готов к работе
  • began — начался распознаваемый жест
  • changed — изменение состояния, например, движение пальца при скролле
  • recognized (он же ended) — жест закончился
  • cancelled — аналог touchesCancelled
  • failed — жест не был распознан (например мы ожидали скролл двумя пальцами, но экрана коснулся лишь один)

UIGestureRecognizer и UIView

Главное что надо помнить — UIGestureRecognizer первым получает право обработки UITouch ивентов. UIGestureRecognizer’ы стоят немного в стороне от обычного механизма responder chain’а. Система, определив через hitTest самую глубокую UIView в которую попал палец пользователя, собирает все рекогнайзеры в цепочке её superview вплоть до UIWindow. Среди собранных рекогнайзеров определяется множество тех, которые буду работать и порядок их работы (это поведение задается через делегата рекогнайзера и мы рассмотрим его чуть далее). UIGestureRecognizer может перехватить UITouch так, что UIView о нём никогда не узнает. Более того, если рекогнайзер перехватит UITouch, то его не увидит вообще ни одна UIView в responderChain вне зависимости от того, на какую UIView был добавлен рекогнайзер.

Увидит ли UIView эти события зависит от реализации рекогнайзера и/или его настроек. Опустив зависимость от конкретной реализации, рассмотрим какие параметры влияют на доставку событий обрабатываемых в рекогнайзере до UIView.

  • cancelsTouchesInView: Bool (default: true)
  • delaysTouchesBegan: Bool (default: false)
  • delaysTouchesEnded: Bool (default: true)

Разберем по-порядку.

cancelsTouchesInView — если рекогнайзер распознает жест, то все UITouch, которые уже были переданы к этому моменту в UIView, но имеющие отношение к жесту, будут отменены (touchesCancelled). Те UITouch жеста, которые еще не были переданы в UIView, никогда до нее не дойдут.

Для понимания рассмотрим пример: пусть есть ThreeFingerRecognizer, детектирующий касание тремя пальцами. Переопределим методы touches{XXX} в UIView, добавив в них print и посмотрим как будет меняться вывод в зависимости от значения флага cancelsTouchesInView.

В обоих случаях вывод в консоль достаточно похож. Все начинается с того, что пользователь палец за пальцем касается экрана. Различие в поведении наступает в момент касания экрана третьим пальцем. Если у рекогнайзера поднят флаг cancelsTouchesInView, то он полностью “захватывает” жесты и у UIView вызывается touchesCancelled для этих объектов UITouch. Более UIView не будет получать уведомления о состоянии этих UITouch объектов. Если же cancelsTouchesInView сброшен, то факт начала распознавания рекогнайзером жеста проходит для UIView прозрачно и события touches{XXX} продолжают исправно поступать в UIView.

delaysTouchesBegan — ни один UITouch не попадет в UIView до тех пор, пока рекогнайзер не перейдет в состояние failed. После перехода в failed у UIView будут вызваны методы touchesBegan и touchesMoved (если надо).

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

Установим флаг delaysTouchesBegan в true и изучим вывод для двух реализаций рекогнайзера.

Видно, что флаг, оправдывая свое название, задерживает передачу событий о UITouch, который потенциально могут быть частью жеста, до момента, когда не станет точно понятно, что жест распознан не будет. При этом, если рекогнайзер не перейдет в состояние failed, то UIView и вовсе никогда не получит эти ивенты.

delaysTouchesEnded — ни для одного UITouch, обрабатываемого рекогнайзером, не будет вызван метод touchesEnded у UIView до тех пор пока рекогнайзер не перейдет в состояние failed. В случае успешного распознавания жеста UIView получит touchesCancelled для данных UITouch. Это гарантирует вызов touchesCancelled для UITouch являющихся частью распознанного жеста.

Поведение флага напоминает собой таковое в случае delaysTouchesBegan за исключением того, что тут UIView может начать обрабатывать жест сразу, не дожидаясь пока рекогнайзер перейдет в состояние failed. Это позволит реализовать более отзывчивый интерфейс.

Комбинируя данные флаги можно добиваться требуемого результата. Однако, надо быть аккуратным. Нужно помнить, что часть UI-элементов обрабатывает жесты пользователя через рекогнайзеры (например, UIScrollView), а часть — через прямую обработку UITouch (например, UIButton). Неудачная конфигурация может сделать обработку жестов непредсказуемой.

Если же стоит задача сделать полностью “прозрачный” для UIView рекогнайзер, то надо смело выставлять все три флага (cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEnded)в false.

UIGestureRecognizerDelegate

Разобравшись с настройкой отношения UIGestureRecognizer ← → UIView, настало время перейти к конфигурации взаимной работы многих рекогнайзеров.

Кроме задания action-метода мы можем определить делегата UIGestureRecognizer’a. Представим, что мы хотим обрабатывать простой тап и двойной тап по одной и той же UIView одновременно. При этом, если пользователь делает двойной тап, то он не должен быть распознан как два простых последовательных. Для этого необходимо настроить взаимосвязь между рекогнайзерами посредствам реализации методов делегата:

func gestureRecognizer(UIGestureRecognizer, shouldRequireFailureOf: UIGestureRecognizer) -> Bool
Должен ли рекогнайзер начинать свою работу только после того, как какой-то другой рекогнайзер перешел в состояние failed
func gestureRecognizer(UIGestureRecognizer, shouldBeRequiredToFailBy: UIGestureRecognizer) -> Bool
Может ли какой-то другой рекогнайзер начать свою работу до перехода текущего в состояние faild

Очевидно, что для корректной реализации поставленной задачи рекогнайзеру простого тапа необходимо дождаться перехода рекогнайзера двойного тапа в состояние failed. Это значит, что двойного тапа не произошло и данный жест можно обработать как нечто другое. Если же имел место именно двойной тап, то рекогнайзер простого тапа не начнет работу и не распознает жест как два отдельных тапа.

Рассмотрим еще один пример: зум и поворот фотографии. Оба этих жеста должны обрабатываться одновременно, а, значит, сделать какой-либо рекогнайзер приоритетнее как в предыдущем примере не выйдет — необходима их одновременная работа. Для реализации требуемого поведения необходимо обратиться к методу делегата

func gestureRecognizer(UIGestureRecognizer, shouldRecognizeSimultaneouslyWith: UIGestureRecognizer) -> Bool

Определив его соответствующим образом можно добиться одновременной работы нескольких рекогнайзеров.

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

Практика

Но лучше один раз увидеть.

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

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

  1. Максимальное число касаний в жесте — 1 (перетаскиваем одним пальцем).
  2. Сохраняем точку начала жеста.
  3. При каждом сдвиге пальца обновляем позицию UIView на экране на требуемую величину.

Дальше по списку зум — щипковым жестом двумя пальцами изменять размер UIView. С этим нам поможет UIPinchGestureRecognizer. Общая идея аналогична подходу с перетаскиванием.

Запустив приложение можно заметить, что в поведении есть изъян. Что будет, если начать двигать квадрат одним пальцем, а потом, коснувшись экрана вторым, попытаться изменить его размер? UIPinchGestureRecognizer не срабатывает и размер UIView не меняется. И наоборот, если начать зумить квадрат двумя пальцами, то система уже не даст его сдвинуть с места. Настает время делегата!

Запустив приложение еще раз можно убедиться, что теперь все работает как и планировалось!

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

Добавим еще одно требование к нашему приложению. Пусть по тапу на UIView будет меняться её цвет, а по двойному тапу возвращаться к начальным значениям размер и позиция. Для обработки тапов будем использовать два UITapGestureRecognizer’а и не забудем настроить порядок их работы через определение методов делегата.

  1. Ключевой момент примера — реализация метода делегата, что позволяет задать корректный порядок работы двух UITapGestureRecognizer’ов.

Но не всегда обязательно писать так много кода для работы с делегатами. Если в одной точке имеется доступ к обоим рекогнайзерам, то реализовать схожее поведение можно через явное определение зависимости посредствам метода require(toFail otherGestureRecognizer: UIGestureRecognizer).

Кастомный UIGestureRecognizer

UIKit предоставляем нам богатый набор рекогнайзеров всех основных “iOS жестов”, конфигурируя которые можно покрыть большинство стандартных требований и кейсов. Но что, если поставленная задача все же выходит за рамки возможностей стандартных рекогнайзеров? Безусловно можно написать свою реализацию!

Первым шагом реализации собственного рекогнайзера должно быть добавление строчки import UIKit.UIGestureRecognizerSubclass, что подключит категорию класса UIGestureRecognizer с переопределением свойства state и сделает его доступным на запись.

Реализация своего рекогнайзера сводится к переопределению уже знакомых нам методов touchesBegan/Moved/Ended/Cancelled в которых должна быть сосредоточена вся логика по обработке событий и вычленения из них требуемых жестов. Также необходимо переопределить метод reset() в котором необходимо очищать состояние рекогнайзера. И, последнее, что может нам пригодится — метод ignore(_ touch: UITouch, for event: UIEvent), посредствам которого, мы можем сообщить системе, что конкретный UITouch не относится к нашему жесту и мы не хотим далее получать уведомления о его изменениях.

Собирая все воедино, давайте напишем свой непрерывный рекогнайзер, который будет распознавать сильное нажатие (ForceTouch) и, если сила превысила заданный порог, то уведомлять target о её изменении.

Как видите, код сильно похож на то, что мы писали в примере про распознавание долгого нажатия на UIView. Но надо не забывать про корректную реализацию метода reset().

Вместо заключения

Из этой статьи вы могли получить основные знания для работы с UIGestureRecognizer’ами и реализации своих собственных.

Полный код примеров можно найти на GitHub.

Кроме того никогда не бывает лишним почитать официальную документацию: