Эта статья поможет вам глубже понять принципы передачи событий жестами в Flutter, распределение этих событий, конфликты и соперничество между ними, а также плавность прокрутки. Она поможет вам создать полный цикл знаний о жестах в Flutter.
По умолчанию в Flutter все события происходят от класса io.flutter.view.FlutterView
, являющегося подклассом SurfaceView
. Процесс событий жестов фактически проходит через три этапа: JAVA => C++ => Dart. Этот процесс одинаков как для Android, так и для iOS. Оригинальный слой просто собирает все события и отправляет их дальше; например, на Android информация о жестах упаковывается в объект ByteBuffer
и затем распаковывается в методе _dispatchPointerDataPacket
уровня Dart, где он становится доступен в виде объекта PointerDataPacket
.
Как именно Flutter распределяет и использует события жестов?
Как мы видели на диаграмме выше, все события жестов начинаются с метода _dispatchPointerDataPacket
на уровне Dart. Затем они проверяются через контекст выполнения Zone
, после чего вызывается метод _handlePointerEvent
в классе-обёртке GestureBinding
. *(Если у вас есть вопросы относительно Zone
или GestureBinding
, вы можете обратиться к предыдущим статьям)*Как показано ниже, метод _handlePointerEvent
класса GestureBinding
состоит главным образом из двух шагов: hitTest
для определения списка элементов управления, требующих обработки (HitTestResult
), и dispatchEvent
для распределения событий и создания конкуренции, чтобы выбрать победителя.
void _обрабатывать_событие_поинтера(PointerEvent event) {
assert(!locked);
HitTestResult hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent) {
hitTestResult = HitTestResult();
/// Начался процесс проверки столкновений, который добавляет все контролы в список для дальнейшей обработки
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
/// Механизм повторного использования, при отпускании или отмене события, нет необходимости выполнять hitTest, просто удаляем
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
/// Механизм повторного использования, когда палец находится в состоянии перемещения, нет необходимости выполнять hitTest
hitTestResult = _hitTests[event.pointer];
}
if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
/// Начинается распределение событий
dispatchEvent(event, hitTestResult);
}
}
Поняв результат, можно более детально рассмотреть эти два ключевых метода:
hitTest
Метод hitTest
предназначен для получения объекта HitTestResult
, который содержит список List<HitTestEntry>
. Этот список используется для распределения и конкуренции событий, а каждый HitTestEntry.target
хранит RenderObject
каждого контроля.Так как RenderObject
по умолчанию реализует интерфейс HitTestTarget
, то можно сказать, что HitTestTarget
в большинстве случаев является RenderObject
, а HitTestResult
представляет собой список контроллов после выполнения теста столкновения.
На самом деле, метод hitTest
является частью абстрактного класса HitTestable
, и во Flutter все классы, реализующие HitTestable
, это GestureBinding
и RendererBinding
, которые являются миксинами в классе WidgetsFlutterBinding
. Из-за порядка миксинов, метод hitTest
в RendererBinding
вызывается раньше, чем в GestureBinding
.
Тогда, какие действия выполняют эти два метода?
В методе RendererBinding.hitTest
вызывается renderView.hitTest(result, position: position);
, как показано ниже. Внутри renderView.hitTest
вызывается child.hitTest
, который пытается добавить подходящий child-контроль в HitTestResult
, а затем добавляет себя.
// RendererBinding
bool hitTest(HitTestResult result, {Offset position}) {
if (child != null) child.hitTest(result, position: position);
result.add(HitTestEntry(this));
return true;
}
```Анализируя исходный код метода `child.hitTest`, представленного ниже, видим, что внутри `RenderObject` метод `hitTest` проверяет, находится ли текущее положение внутри размера с помощью `_size.contains()`. После того как область отклика подтверждается, выполняются методы `hitTestChildren()` и `hitTestSelf()`, чтобы попытаться добавить нижестоящие child-элементы и самого себя. Такой **рекурсивный процесс позволяет получить список контролов, отвечающих за события, сверху вниз, где самый нижний child-контроль оказывается самым верхним**.```dart
// RenderObject
bool hitTest(HitTestResult result, {required Offset position}) {
if (_size.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
```#### 1.3 Гестюрбиндинг.хиттест
Метод `GestureBinding.hitTest` в конце концов добавляет сам объект `GestureBinding` в `HitTestResult`. Это делается потому что нам потребуется вернуться к `GestureBinding`, чтобы завершить последующие шаги нашего процесса.
#### 1.4 dispatchEvent
В методе `dispatchEvent` происходит распределение событий, а также обработка этих событий с помощью `target.handleEvent`, добавленной выше. Как показано ниже:
@override // от HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
///Если нет результатов хита, то событие будет передано глобальной обработке через pointerRouter.route
.
if (hitTestResult == null) {
try {
pointerRouter.route(event);
} catch (exception, stack) {
return;
}
}
///Как мы знаем, в HitTestEntry цели представляют собой контролы сверху вниз,
///включая renderView и GestureBinding
///Каждый handleEvent выполняется в цикле
for (HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event, entry);
} catch (exception, stack) {
}
}
}
На самом деле, не все контролы, являющиеся подклассами `RenderObject`, обрабатывают события `handleEvent`. В большинстве случаев это происходит только у контролов, содержащих `RenderPointerListener` (RenderObject) / `Listener` (Widget). Из вышеприведённого кода видно, что выполнение `handleEvent` не может быть перехвачено или прервано.Тогда возникает вопрос: если несколько контроллов внутри одного региона реализуют `handleEvent`, кто должен получить и обработать это событие?
Более конкретно, проблема заключается в следующем: **например, на странице списка есть возможность прокрутки вверх/вниз и клика по элементам. Как Flutter распределяет эти жестовые события?** Это связано с конкуренцией за события.
> **Основная идея вот-вот появится, будьте готовы к высокой нагрузке!!!**
## 2 Конкуренция за события
При проектировании конкуренции за события Flutter использует интересную концепцию: **контроллы участвуют в конкурсе на "поляне", победителем становится тот, кто первым достигнет своей цели или останется до самого конца.** Чтобы анализировать эту "войну", нам нужно понять несколько ключевых концепций:
- **`GestureRecognizer`**: базовый класс для распознавания жестов, основные события жестов в `RenderPointerListener`, такие как события нажатия, свайпов и тапов, передаются соответствующим экземплярам класса `GestureRecognizer`. Обрабатываются и затем распространяются дальше. Примерами таких классов могут быть: `OneSequenceGestureRecognizer`, `MultiTapGestureRecognizer`, `VerticalDragGestureRecognizer`, `TapGestureRecognizer` и так далее.
- **`GestureArenaManager`**: управляет процессом "войны" жестами, условием победы в конкурсе является первый участник, выигравший конкурс, либо последний участник, который не был отвергнут.- **`GestureArenaEntry`** : Элемент, предоставляющий информацию о событиях конкурса жестов, внутри которого содержатся участники события конкурса.
- **`GestureArenaMember`** : Абстрактный объект участника конкурса, содержащий методы `acceptGesture` и `rejectGesture`, представляющие участников конкурса жестов. По умолчанию все реализаторы `GestureRecognizer` имплементируют его. Все участники конкурса можно рассматривать как соперничество между различными `GestureRecognizer`.
- **`_GestureArena`** : Арена конкурса внутри `GestureArenaManager`, которая хранит список участников `members`. Официальное объяснение арены конкурса гласит: если жест пытается победить при открытом доступе к арене (`isOpen = true`), он становится объектом с атрибутом "желание победить". Когда арена закрывается (`isOpen = false`), она ищет объект с атрибутом "желание победить", чтобы стать новым участником. Если в этот момент остается только один участник, то этот участник будет считаться победителем этого конкурса.
Теперь, когда мы знаем эти концепции, давайте рассмотрим процесс. Мы знаем, что `GestureBinding` проверяет наличие результата `HitTestResult` перед выполнением `dispatchEvent`. Обычно это так, поэтому прямой цикл выполняется через `entry.target.handleEvent`.
#### 2.1 PointerDownEvent
При выполнении цикла известно, что `entry.target.handleEvent` вызывает `handleEvent` у `RenderPointerListener`. Первое событие обычно является `PointerDownEvent`.> Процесс `PointerDownEvent` играет ключевую роль в процессе конкурса событий, поскольку он запускает метод `GestureRecognizer.addPointer`.
**Метод `GestureRecognizer.addPointer` позволяет связать событие `PointerDownEvent` со своим экземпляром и добавить его в маршрутизацию событий `PointerRouter` в `GestureBinding` и в управление конкурсом событий `GestureArenaManager`. Только после этого следующие события могут быть обработаны и принять участие в конкурсе.**

> На самом деле событие **Down** в Flutter обычно используется для добавления проверок. При наличии конкурентов результат обычно не выводится сразу, а событие **Move** может проявляться по-разному в различных `GestureRecognizer`. После события **Up** обычно получается окончательный результат. Поэтому мы знаем, что **когда событие начинает распространяться в `GestureBinding`, при событии `PointerDownEvent` должны отозваться события `GestureRecognizer`, которые вызывают метод `addPointer`, чтобы добавиться в список участников. В последующих этапах, если нет особых случаев, обычно процесс продолжается до последнего участника списка конкурентов, то есть до самого `GestureBinding`.**
Как показано ниже, когда достигает `handleEvent` в `GestureBinding`, в потоках событий Down обычно `pointerRouter.route` не выполняет много логики, а затем следует закрытие арены с помощью `gestureArena.close`, пытаясь получить победителя.```markdown
@override // из HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
/// Навигационное событие запускает метод `handleEvent` в `GestureRecognizer`.
/// Обычно при событии `PointerDownEvent` в `route` мало что происходит.
pointerRouter.route(event);
/// `gestureArena` - это `GestureArenaManager`.
if (event is PointerDownEvent) {
/// Закрывает эту арену для события Down, пытаясь найти победителя.
/// Если победитель не найден, он будет найден при событиях Move или Up.
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
/// Уже событие Up, вынужденное получение результата.
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
```Давайте рассмотрим метод `close` в `GestureArenaManager`. Как видно из следующего кода, если участник не был добавлен в `_arenas` с помощью `addPointer` во время события Down, он даже не сможет принять участие в конкурсе. После того как процесс достигнет `_tryToResolveArena`, **если `state.members.length == 1`, это значит, что у нас один участник, и поэтому нет необходимости в конкуренции; этот участник автоматически становится победителем и реагирует на все последующие события.** Если же участников несколько, они будут участвовать в дальнейшей конкуренции.
```markdown
void close(int указатель) {
/// Получаем членов, добавленных с помощью метода addPointer выше
final _GestureArena состояние = _arenas[указатель];
if (состояние == null)
return; // Это арена либо никогда не существовала, либо была разрешена ранее.
состояние.isOpen = false;
/// Начнем разрешение конфликтов
_попробоватьРазрешитьАрену(указатель, состояние);
}
void _попробоватьРазрешитьАрену(int указатель, _GestureArena состояние) {
if (состояние.members.length == 1) {
scheduleMicrotask(() => _разрешитьПоУмолчанию(указатель, состояние));
} else if (состояние.members.isEmpty) {
_arenas.remove(указатель);
} else if (состояние.eagerWinner != null) {
_разрешитьВПользу(указатель, состояние, состояние.eagerWinner);
}
}
TapGestureRecognizer
. Если в области компонента присутствуют два TapGestureRecognizer
, то победителя не будет выбран при событии PointerDownEvent
. В этом случае, если событие MOVE
не прервет процесс, при событии UP
выполнится метод gestureArena.sweep(event.pointer);
для принудительного выбора одного из них.
Метод выбора также прост: это state.members.first
, то есть самый внутренний потомок дерева компонентов согласно результатам предыдущего hitTest
. Выигравший участник затем вызывает метод members.first.acceptGesture(pointer)
, который передается в метод TapGestureRecognizer.acceptGesture
, где переменная _wonArenaForPrimaryPointer
устанавливается как true, чтобы указать на победу, после чего выполняются методы _checkDown
и _checkUp
, которые генерируют события для этого компонента.
Здесь интересной особенностью является то, что метод _checkUp
внутри метода acceptGesture
в потоке DOWN
не будет выполнен, так как нет значения _finalPosition
. Переменная _finalPosition
получает свое значение в методе handlePrimaryPointer
, где проверяется флаг _wonArenaForPrimaryPointer
, и только тогда метод _checkUp
будет успешно выполнен.
handlePrimaryPointer
вызывается в потокеUP
черезpointerRouter.route
, который активирует методhandleEvent
вTapGestureRecognizer
.
**И вот вопрос: если методы _checkDown
и _checkUp
выполняются одновременно при событии UP
, то что произойдет, если я буду долго нажимать? Не будет ли метод _checkDown
недоступен для корректного выполнения?**Конечно, это не проблема. Внутри TapGestureRecognizer
существует механизм didExceedDeadline
. В потоке DOWN
, при добавлении указателя в метод addPointer
, создается таймер, время которого равно kPressTimeout = 100 миллисекунд
. Если вы продолжите длительно нажимать, то этот таймер завершит свой отсчет и вызовет метод didExceedDeadline
, который выполнит _checkDown
и отправит событие onTabDown
.
При выполнении метода
_checkDown
используется флаг_sentTapDown
, который проверяет, был ли уже отправлен данный сигнал. Если сигнал уже отправлен, он повторно не отправляется; вместо этого процесс переходит обратно к конкурентному состоянию. После того, как палец поднимется, победитель будет определен, а флаг_sentTapDown
будет сброшен.
Это позволяет анализировать несколько сценариев нажатия:
TapGestureRecognizer
: при событии DOWN
победитель сразу определяется в момент закрытия соревнования close
, вызывая метод acceptGesture
, который выполняет _checkUp
. При событии UP
метод _checkUp
снова выполняется в методе handlePrimaryPointer
, завершая процесс.TapGestureRecognizer
: при событии DOWN
в конкурсе "close" победителя не выбирается, а при событии UP
после прохождения маршрута через метод handlePrimaryPointer
устанавливается значение _finalPosition
. Затем происходит этап "sweep", где выбирается первый участник как победитель, вызывается метод acceptGesture
, выполняются методы _checkDown
и _checkUp
.##### После долгого нажатия и отпускания:TapGestureRecognizer
: за исключением того, что событие Down вызывает _checkDown
при превышении срока в методе didExceedDeadline
, остальное почти не отличается от вышеописанного.TapGestureRecognizer
: при событии Down в конкурсе "close" победителя не выбирается, но запускается таймер didExceedDeadline
, который сначала вызывает _checkDown
, затем после этапа "sweep" выбирается первый участник как победитель, вызывается метод acceptGesture
, и запускается метод _checkUp
.И снова вопрос: если внутри области есть два TapGestureRecognizer
, то при долгом нажатии будут ли они оба вызывать _checkDown
при превышении срока?
Ответ: да! Потому что таймеры запускают метод didExceedDeadline
, поэтому _checkDown
будет выполнен для обоих, и они оба получат событие onTapDown
. Однако после последующего конкурса будет выполнен только один _checkUp
, так что только один компонент ответит на событие onTap
.
Участники, потерпевшие поражение в конкурсе, исключаются из него, и больше не могут принимать участие в последующих конкурсах, например, TapGestureRecognizer
при получении события PointerMoveEvent
сразу отклоняется (rejected
) и вызывает метод rejectGesture
, затем таймер закрывается, и вызывается событие onTapCancel
, после чего сбрасываются метки.Подведём итог:
Событие Down добавляет участника GestureRecognizer
в область конкурса через метод addPointer
. До удаления участника он может участвовать в последующих конкурсах. Удалённые участники не смогут принять участие в последующих событиях. Если нет победителей в конкурсе, в процессе Up первым участником автоматически считается победителем.
Событие свайпа также требует добавления указателя через метод addPointer
в процессе Down, а затем выполнения метода DragGestureRecognizer.handleEvent
после события MOVE через метод PointerRouter.route
.
В методе DragGestureRecognizer.handleEvent
при событии PointerMoveEvent
проверяется условие через метод _hasSufficientPendingDragDeltaToAccept
, например:
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
Если условие выполняется, то сразу выполняется resolve(GestureDisposition.accepted);
. Это возвращает процесс обратно в арену, где затем вызывается метод acceptGesture
, после чего срабатывают события onStart
и onUpdate
.Перейдём к нашему списку с возможностью прокрутки вверх и вниз, который можно было бы нажать. В этом случае всё становится очевидным: если это клик, то событие MOVE
не происходит, поэтому DragGestureRecognizer
не принимается, и первый элемент (child
) откликается на клик. Если же событие MOVE
произошло, то DragGestureRecognizer
будет принят методом acceptGesture
, а событие клика (TapGestureRecognizer
) будет удалено из конкуренции за события, следовательно, последующее событие UP
не будет происходить.А как работает событие onUpdate
?
Допустим, что мы рассматриваем ListView
. Исходный код показывает, что onUpdate
в конце вызывает метод _handleDragUpdate
класса Scrollable
, при этом вызывается метод Drag.update
.
Исходный код также указывает, что реализация Drag
в ListView
фактически является ScrollDragController
, который связан с ScrollPositionWithSingleContext
внутри Scrollable
. А что такое ScrollPositionWithSingleContext
?
ScrollPositionWithSingleContext
— это ключевой компонент прокрутки, являющийся подклассом ScrollPosition
, который, в свою очередь, является подклассом ViewportOffset
, который, в свою очередь, является ChangeNotifier
. Таким образом, имеют место следующие отношения:
Наследование: ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier
Поэтому ViewportOffset
является ключевым моментом прокрутки. Мы знаем, что после того, как область DragGestureRecognizer
выиграла конкуренцию, вызывается метод Drag.update
, что в конечном итоге приводит к вызову метода applyUserOffset
класса ScrollPositionWithSingleContext
, что приводит к изменению внутреннего значения позиции pixels
и вызову метода notifyListeners
родительского класса ChangeNotifier
для уведомления о необходимости обновления.
Внутри ListView
в классе RenderViewportBase
этот ViewportOffset
связывается через _offset.addListener(markNeedsLayout)
. Поэтому, когда происходит движение пальцем, вызывается метод Drag.update
, что в конечном итоге приводит к вызову метода markNeedsLayout
класса RenderViewportBase
, что запускает обновление страницы.Что касается того, как метод markNeedsLayout
обновляет интерфейс и список прокрутки, здесь подробное описание пока не требуется, но вот диаграмма для наглядности:
наконец, тринадцатая статья завершена! (///▽///)
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )