The two most important things in the big front end are interface rendering and event distribution. Let’s take a look at the whole process of a Flutter gesture event from receipt to response via source code and some examples.
The following source code is based on
- Flutter 2.5.1
- The Dart 2.14.2
Raw event capture
On the big front end, most platform or system primitives are handled similarly: press, move, lift, and other advanced events such as double click and scroll are based on this.
In a Flutter, receive events entrance begins _dispatchPointerDataPacket, by binding behind the callback will be transferred to the _handlePointerDataPacket began to handle events
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if(! locked) _flushPointerEventQueue(); }Copy the code
Packet is gesture data from the engine layer, in which packet. Data is an array. If only the mouse operates on the COMPUTER PC, its length is 1. It has some important properties
- Change (PointerChange): gesture changes, as common
hover
,down
,move
,up
Etc. - Kind (PointerDeviceKind): a device that triggers a gesture, as
touch
(mobile device screen),mouse
(mouse) etc - SignalKind (PointerSignalKind): a response in addition to gestures, such as mouse wheel scrolling
PointerEventConverter. Will expand original acquisition gesture data into PointerEvent class, different gestures will inherit it implements different classes
- PointerCancelEvent: Gesture cancellation, usually used to reset a running gesture. It can be triggered Native or called
cancelPointer
Trigger, which is often used to reset the gesture of the original page after jumping to a new page (mixed development, often encountered that the Flutter page jumped to Native and returned, the Flutter page stalled, that is, the gesture was not cancelled correctly, see my articleResolve a problem where a mixed stack jump causes a Flutter page event to freeze - PointerDownEvent: Triggered when a finger or left mouse button is pressed
- PointerMoveEvent: Triggered when a finger slides on the screen or the left mouse button is pressed down
- PointerUpEvent: Triggered when the finger moves off the screen or the left mouse button is raised
- PointerRemovedEvent: Triggered when the gesture moves out of the screen during the press
- PointerScrollEvent: Generally triggered by the mouse wheel
- PointerHoverEvent: Triggered when the pointer is above the screen, usually for mouse events, or when the touch screen is pressed
- PointerEnterEvent: Triggered when a pointer enters an area, often used for mouse events
PointerHoverEvent
Event triggers calculation to obtain - PointerExitEvent: Triggered when the mouse cursor moves out of an area
PointerHoverEvent
Event triggers calculation to obtain
Pointerevents also have some important properties
- EmbedderId (int): Platform unique identifier
- Pointer (int): Identifies a complete gesture (e.g. finger press, finger move, finger lift); / / Pointer (int): Identifies a complete gesture (e.g. finger press, finger move, finger lift)
- Kind (PointerDeviceKind): identifies the device that listens to gestures, such as fingers and mouse
- Position (Offset): indicates the position of the pointer relative to the screen
- Delta (Offset): indicates the Offset of the pointer
PointerHoverEvent
,PointerMoveEvent
There are offset value - Buttons (int): identifies mouse and stylus events, for example, when the right mouse button is pressed
PointerDownEvent
At the same time for the buttonskSecondaryButton
The Flutter is a hexadecimal number that identifies the Flutter with constants. I’ll use the mouse button as an example- KPrimaryButton (0x01): left mouse button
- KSecondaryButton (0x02): Right mouse button
- KTertiaryButton (0x04): Middle mouse key
- KBackMouseButton (0x08): Mouse back button
- KForwardMouseButton (0x08): Mouse forward button
- Down (bool): Indicates whether to press down the screen, for example
PointerDownEvent
,PointerMoveEvent
Down is true
void _flushPointerEventQueue() {
while (_pendingPointerEvents.isNotEmpty)
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
void handlePointerEvent(PointerEvent event) {
if (resamplingEnabled) {
_resampler.addOrDispatch(event);
_resampler.sample(samplingOffset, _samplingClock);
return;
}
_resampler.stop();
_handlePointerEventImmediately(event);
}
Copy the code
The _flushPointerEventQueue iterates through the list of stored Pointers and executes handlePointerEvent. ResamplingEnabled as true as saying on some special equipment can add some delay to make gestures to _handlePointerEventImmediately more smoothly.
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(! _hitTests.containsKey(event.pointer)); hitTestResult = HitTestResult(); hitTest(hitTestResult, event.position);if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
/ /...
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
hitTestResult = _hitTests[event.pointer];
}
/ /...
if(hitTestResult ! =null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position ! =null); dispatchEvent(event, hitTestResult); }}Copy the code
_handlePointerEventImmediately process has several parts, When the event is PointerDownEvent | | event is PointerSignalEvent | | event is PointerHoverEvent, will conduct a hitTest process, Also, if the event is a PointerDownEvent, the hitTest results obtained will be cached in _hitTests. HitTest is usually called hitTest, which is to collect components within the event occurrence scope. PointerDownEvent, PointerSignalEvent and PointerHoverEvent are three types of gestures that need to touch the screen and involve pointer changes.
When the event is PointerUpEvent | | event is PointerCancelEvent, on behalf of the end of a gesture, so it will result from _hitTests removed and assignment to hitTestResult, Note that the pointer property is used to determine the hit test result for the corresponding PointerDownEvent.
When event.down is true, there is currently only one gesture that goes in here, PointerMoveEvent. Why should it also be hitTest? In gestures, “finger press down”, “finger move” and “finger lift” are generally considered to be a complete gesture process. Although the position of finger move will change, the area it acts on is considered to be still in the pressed area, so just use the result of the pressed area.
hitTest
There is a front-end event bubble mechanism called a Hit Test in Flutter. Its function is to collect all widgets based on the location of the event. It does a deep walk through all the RenderObjects starting at the top node (see my other article if you don’t know RenderObject trees), then collects each node from the bottom node into a Result variable of type HitTestResult. Finally, events are distributed based on this result.
example
I’m going to take you through the hitTest process of what’s going on underneath Flutter as an example.
import 'package:flutter/material.dart';
void main() {
runApp(MyHomePage(
key: UniqueKey(),
));
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Listener(
onPointerDown: (p) {
print('down');
},
child: const ColoredBox(
color: Colors.green,
child: SizedBox(
width: 100,
height: 100,
),
),
),
Listener(
onPointerUp: (p) {
print('up');
},
child: const ColoredBox(
color: Colors.yellow,
child: SizedBox(
width: 100,
height: 100,),),),,,); }}Copy the code
When I click on the green square above, the hit test goes through the following process
RenderView’s hitTest will be called in RendBinding. RenderView will first collect subcomponents, _RenderColorBox. Rendercolorbox hitTest before looking at _RenderColorBox hitTest, let’s look at RenderBox hitTest.
bool hitTest(BoxHitTestResult result, { required Offset position }) {
if(_size! .contains(position)) {if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true; }}return false;
}
Copy the code
This is the key of hitTest. If the event occurs outside the scope of the component, the hitTest will fail directly, and there is no need to iterate over the child components.
hitTestChildren
Then we call hitTestChildren, which is very simple in this case, to hit the child component. If the child component returns true, the child component hit successfully.
HitTestChildren returns false by default because the current component may have no children. A typical scenario of Flutter is that the current component has one or more children. When there is only one child, most of the components will use the RenderProxyBoxMixin mixin, such as SizedBox, Opacity box, ColoredBox, etc. It is the hitTestChildren
bool hitTestChildren(BoxHitTestResult result, { required Offset position })
returnchild? .hitTest(result, position: position) ??false;
}
Copy the code
Quite simply, it calls the child’s hitTest directly. When there are multiple child components, such as Column, Row, Stack, Wrap, ListBody, etc., they rewrite hitTestChildren themselves, Then the direct call RenderBoxContainerDefaultsMixin defaultHitTestChildren in this method
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild;
while(child ! =null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed!);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
Copy the code
Easy to explain, it starts from the last child components, traverse, forward the result. AddWithPaintOffset is to calculate the current offset of child nodes, because the incoming position is the offset relative to the parent node. If a child hits the test and returns true, then the hitTestChildren will simply return true and not iterate over the previous component, which is why if two Stack children overlap, the top component will receive the event and the bottom component will not.
What if I want the following component to also receive events? That’s where the behavior property comes into play.
hitTestSelf
We assume that all components depend on their children to determine whether they can pass the hitTest, and we know from above that if there are no children, hitTestChildren will return false, then no component can pass the hitTest, so the entire component tree hittests will return false, HitTestSelf solves this problem.
So in hitTest, if hitTestSelf returns true that tells it that I can receive an event hit, let me respond to a gesture event. Many of the Flutter components directly return the hitTestSelf to true. For example, MouseRegion, Texture, Image, EditableText, and the RenderObject corresponding to the hitTestSelf of the Flutter components. Some components are need simple judgment, such as the Listener, ColoredBox components use RenderProxyBoxWithHitTestBehavior the mixin
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
Copy the code
It returns true by checking whether the behavior is hittestBehavior.opaque. Behavior is coming up again, so let’s see
behavior
The hitTest process does two things if it passes. We’ll call these two things A and B. A adds itself to HitTestResult result so I can respond to the event, and tells the parent component’s hitTestChildren to return true. From the hitTest in RenderBox above, we can see that both things happen at the same time, so is it possible for A to happen and B not to happen? The answer is in the RenderProxyBoxWithHitTestBehavior mixins.
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
Copy the code
Behavior plays an important role, and its type HitTestBehavior is an enumeration with three cases
- HitTestBehavior.deferToChild
DeferToChild is the default behavior, and it is up to its child components to decide whether the current component is added to the hit test when an event occurs at component scope. That is, the child component passes the test, and the child component affects the hit result of the current component.
- HitTestBehavior.opaque
Opaque indicates that when an event occurs in the component scope, the component can pass the match test regardless of whether the component can pass the match test. If you look at the hitTestSelf section above, as long as the behavior is hittestBehavior.opaque, hitTestSelf returns true.
- HitTestBehavior.translucent
3. This component always returns false, as A child that fails A hit test. The current component always adds itself to Result, telling the parent that the current component failed the hit test, i.e., that it did A, yet failed B.
So what’s the difference between them? Let’s look at some examples of HitTestBehavior.
void main() {
runApp(MyHomePage());
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Listener(
onPointerDown: (_) {
print("down");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100(), ((), ((), ((); }}Copy the code
After running, you get the following screen
To create a click area, I use ColoredBox and SizedBox to make a red area in the middle, but ColoredBox itself accepts hitTest (hitTestSelf is true). To protect against it, I set an IgnorePointer to make the Listener child fail the hit test.
Above the Listener component behaviors by default HitTestBehavior. DeferToChild, so no matter how to click on the red areas, the above onPointerDown would have log output.
Let’s change it a little bit
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down");
},
/ /...
)
Copy the code
Now click on the red area again and see that the log output is Down. In our daily development, we often encounter a situation that when we use the Container component without setting the color, the range of the GestureDetector added on its outer layer may only be on the text. After we set a transparent color to it, it will work normally. Is it because after adding the color? The Container component will nest a ColoredBox. ColoredBox can pass the hit test by itself, so it is normal. It is better to add hitTestBehavior.opaque to the GestureDetector.
Let’s change the example from above
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
textDirection: TextDirection.ltr,
children: [
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down1");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100,
),
),
),
),
Positioned(
top: 50,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down2");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.blue,
child: SizedBox(
width: 100,
height: 50(() (() (() (() [() (() [() (() [() }}Copy the code
Below the blue square is covered in red square, we click the blue square, will be output down2, but how will not output down1, if we set the behaviors on the blue squares above into HitTestBehavior. Translucent, output will
down2
down1
Copy the code
In this way, not only does the blue block receive the Down event, but the event is also passed down to the red block below, making the red block receive the event as well. As for why down2 and then down1 is the order, let’s look at the event dispatch process.
Dispatching events
dispatchEvent
// rendering/binding
@override // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
// Handle mouse events such as' PointerExitEvent 'and' PointerEnterEvent '_mouseTracker! .updateWithEvent( event, () => (hitTestResult ==null || event is PointerMoveEvent) ? renderView.hitTestMouseTrackers(event.position) : hitTestResult,
);
super.dispatchEvent(event, hitTestResult);
}
// gesturing/binding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
/ /...
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
/ /...}}}Copy the code
When hitTestResult is null, pointerRouter.route is performed, and the pointerRouter can receive callbacks from anywhere else in the framework, providing the rest of the engine with the ability to receive event notifications.
If hitTestResult is not empty, the result from the hit test is iterated over, executing its handleEvent method. Since the loop is executed from left to right, the hit test and event distribution are first-in, first-out, which explains why down2 is printed before down1 in the example above.
handleEvent
HandleEvent is a method on HitTestTarget, and RenderObject implements HitTestTarget, so all RenderObject classes and subclasses can implement handleEvent to receive event responses.
Flutter has many components that customize handleEvents to handle complex events. Let’s first look at the Listener’s handleEvent
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent)
returnonPointerDown? .call(event);if (event is PointerMoveEvent)
returnonPointerMove? .call(event);if (event is PointerUpEvent)
returnonPointerUp? .call(event);if (event is PointerHoverEvent)
returnonPointerHover? .call(event);if (event is PointerCancelEvent)
returnonPointerCancel? .call(event);if (event is PointerSignalEvent)
returnonPointerSignal? .call(event); }Copy the code
A Listener can also be called an original event Listener. Common examples such as GestureDetector and Scrollable(ListView, PageView, etc.) are implemented based on Listener.
Look again at the implementation of handleEvent on GestureBinding
@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event);
if (event is PointerDownEvent) {
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer);
} else if (event isPointerSignalEvent) { pointerSignalResolver.resolve(event); }}Copy the code
The GestureBinding is that each hit test is finally added to HitTestResult Result, so the above handleEvent method is executed at the end of each event distribution process. PointerRouter. Route is the same as if hitTestResult is null. GestureArena provides a gestureArena, which will be covered in more detail in the GestureDetector component later. PointerSignalResolver is currently only used for mouse wheel events. Mouse wheel events do not have a competitive field concept, so it provides an opportunity for mouse events to compete. For example, when multiple nested events such as ListViews occur, when PointerSignalEvent events occur, All ListViews scroll, which is obviously not true, and pointerSignalResolver solves this problem.
conclusion
In a Flutter, when a finger or the mouse touch screen to the corresponding components received incident response has two process, hit testing and event distribution, hit test to collect those components can receive events, events distribution to execute corresponding components receive function, so that components can post-processing corresponding logic in receiving events. The Listener is a very primitive event Listener component on which many of our sophisticated gestures can be implemented. When I touch and move a distance and then lift it up, PointerDownEvent, PointerMoveEvent, PointerUpEvent will fire one by one. According to the Listener component, the click event and drag event will fire simultaneously. This is obviously not reasonable. To solve this problem, Flutter introduces the concept of gesture competition, which we will discuss later.