Держим удар с hitTest

Как система обрабатывает нажатия

Что же происходит, когда пользователь касается экрана? Для каждого нажатия система создаёт событие и передаёт его в приложение. Там оно поступает в обработку к UIApplication, который с помощью метода sendEvent: пробрасывает его дальше — в window. Задача объекта класса UIWindow — запустить процесс прохода по дереву вьюх и найти ту, которой адресован этот touch.

Проход реализован через рекурсивный метод hitTest:withEvent: который для заданной view возвращает самого отдалённого наследника в иерархии её subviews (включая себя), который попадает под координаты нажатия.

func hitTest(CGPoint, with: UIEvent?)Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.

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

func point(inside: CGPoint, with: UIEvent?)Returns a Boolean value indicating whether the receiver contains the specified point.

Вообще, поиск firstResponder’а для события нажатия — это обыкновенный поиск в глубину по дереву вьюх. Класс UIWindow — корневой узел этого дерева, все его subivews – потомки. Максимально глубокий узел, удовлетворяющий условию pointInside (при условии, что все его ) – получатель события touch event. Давайте разберём этот процесс на примере.

Пусть у нас есть следующая иерархия вьюх:

Предположим, что пользователь нажал на E. Рассмотрим проход поиска по шагам:

  1. У корневой view A вызовется метод hitTest. В первую очередь метод запросит pointInside для A, и, так как координата touch попадает в bounds A, то pointInside вернёт true. Значит событие будет обработано либо вьюхой A, либо одной из её дочерних вьюх.
  2. А спросит hitTest у B. Так как pointInside для B вернёт false, то B выпадет из цикла.
  3. A спросит hitTest у C. pointInside для C вернёт true. Уже теплее.
  4. C спросит hitTest у E. pointInside для E, конечно же, вернёт true, а так как у E нет дочерних вьюх, то на ней обход прекратится, и в качестве результата hitTest она вернёт себя.
  5. C в качестве результата hitTest вернёт E
  6. A в качестве результата hitTest вернёт E

В результате прохода вьюха E станет firstResponder’ом и будет первой получать события от UIWindow :

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

Обрабатывать их или нет — это уже её дело. События будут форвардиться по правилам работы responder chain, пока один из объектов UIResponder не решит переопределить их, не вызывая super. Впрочем, это уже совсем другая история.

И что нам делать с этими знаниями?

Понимание процесса нахождения firstResponder’а для touch events открывает нам глаза на многие вещи.

Например, сразу же становится очевидным, почему выставление isUserInteractionEnabled в false у родительской view блокирует нажатия на все её дочерние вьюхи: метод hitTest сразу же вернёт nil, не опросив ни одну view из всех своих subviews. Вполне возможно, что в базовой реализации метода hitTest:withEvent: есть что-то подобное:

func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isUserInteractionEnabled else { return nil }
guard !isHidden else { return nil }
guard alpha >= 0.01 else { return nil }
guard point(inside: point, with: event) else { return nil }
...
}

Думаю, вы уже сами догадались, как можно перехитрить систему и научить объект UIView ловить нажатия, даже с отключенным isUserInteractionEnabled — достаточно просто переопределить hitTest и либо вернуть там себя, либо воспроизвести алгоритм обхода сабвьюх. Правда мне сложно придумать пример из практики, когда такое странное решение задачи было бы оправданным.

Ещё один интересный момент. Какой элемент UIView первым получит событие нажатия, если тапнуть на выпирающую часть E?

Интуитивно кажется, что она же, view E, и получит, но так ли это на самом деле, если вспомнить, как работает метод hitTest?

Note
If a touch location is outside of a view’s bounds, the hitTest(_:with:) method ignores that view and all of its subviews. As a result, when a view’s clipsToBounds property is false, subviews outside of that view’s bounds are not returned even if they happen to contain the touch.

Touch будет проигнорирован вьюхой C (а значит и всеми её subviews), так как его координаты не попадают в её область отрисовки. Соответственно, самой глубокой вьюхой, которая примет нажатие, будет view A.

Playground example

Можно ли и в этом случае перехитрить систему и научить С обрабатывать нажатия на E? Конечно! Достаточно переопределить метод pointInside и задать собственную логику прохода по subviews:

override func point(inside point: CGPoint, 
with event: UIEvent?) -> Bool
{
let inside = super.point(inside: point, with: event)
if !inside {
for subview in subviews {
let pointInSubview = subview.convert(point, from: self)
if subview.point(inside: pointInSubview, with: event) {
return true
}
}
}
return inside
}

Здесь мне пришлось воспользоваться методом convert(_:from:), который позволяет перегнать точку из одной системы координат (в нашем случае view C) в другую (subview). Если touch не попадает на C, но приходится на одну из её subviews, то наш pointInside для C вернёт true, а значит hitTest для C не прервёт процесс поиска в самом начале выполнения метода.

Идём дальше. Нужно увеличить область нажатия кнопки не трогая её фреймов? Запросто! Отнаследуемся от UIButton и снова переопределим pointInside:

class ButtonWithTouchSize: UIButton {
var touchAreaPadding: UIEdgeInsets?
override func point(inside point: CGPoint,
with event: UIEvent?) -> Bool
{
guard let insets = touchAreaPadding else {
return super.point(inside: point, with: event)
}
let rect = UIEdgeInsetsInsetRect(bounds, insets.inverted())
return rect.contains(point)
}
}

Давайте разберём, что получилось. Мы создали собственный класс ButtonWithTouchSize с публичной переменной touchAreaPadding, отвечающей за область, на которую нужно расширить «нажимабельность» кнопки. Если touchAreaPadding задана, то мы подменяем стандартную реализацию pointInside, где сначала расширяем bounds:

let rect = UIEdgeInsetsInsetRect(bounds, insets.inverted())

а затем проверяем, попадает ли точка в эту область

rect.contains(point)

Здесь мне пришлось заиспользовать вспомогательную функцию inverted(), чтобы инвертировать значения UIEdgeInsets, так как для расширения CGRect через UIEdgeInsetsInsetRect требуются отрицательные значения.

extension UIEdgeInsets {

func inverted() -> UIEdgeInsets {
return UIEdgeInsets(top: -top, left: -left,
bottom: -bottom, right: -right)
}
}

Имея такой класс можно создавать кнопку любых размеров, не беспокоясь о том, что пользователь не сможет по ней попасть.

Ну и, заключительный пример, он же мой любимый. В стандартной реализации класса UIView не предусмотрена возможность создания контейнера, пропускающего нажатия сквозь себя, но позволяющего обрабатывать тачи его сабвьюхами. Мы уже убедились, что отключение флага isUserInteractionEnabled влияет на доступность всех дочерних вьюх объекта UIView, а не только на него самого, поэтому он тут не подойдёт. А ведь такой контейнер был бы очень полезен. Вот взять, к примеру, Яндекс.Карты: кнопки, расположенные поверх карты, как раз-таки лежат в таком контейнере, который наложен на карту и позволяет пользователю взаимодействовать как с ней, так и с кнопками, которые являются его дочерними вьюхами.

Как мы это сделали? На самом деле всё просто. Мы создали собственный класс TouchPassView, в котором переопределили метод hitTest:

class TouchesPassView: UIView {

override func hitTest(_ point: CGPoint,
with event: UIEvent?) -> UIView?
{
let view = super.hitTest(point, with: event)
if view === self {
return nil
}
return view
}
}

Если нажатие попадёт на сам объект TouchPassView, а не на его дочерние вьюхи, то мы это отловим и вернём nil:

if view === self {
return nil
}

Во всех остальных случаях логика работы этого контейнера ничем не будет отличаться от стандартной.

Мы рассмотрели базовые принципы обработки нажатий в iOS, научились «обманывать» систему и применять эти навыки на практике. Но мы не затронули ещё одну большую и интересную тему, касающейся жестов — тема UIGestureRecognizer осталось нераскрытой. В одной из следующих статей мы погрузимся в этот страшный мир жестов и постараемся разобраться, как этому классу, не будучи наследником UIResponder, удаётся получать те же события touch events и что он с ними делает. Будем на связи!

--

--