preface
It has been a week since I left a challenge to increase the click range in the Flutter reshuffle NestedScrollView (juejin. Cn). What do you think? Today I talked about my personal ideas about increasing the range of clicks.
Debugging the source code
Let’s start by smoothing out where the gesture-related events in Flutter come from.
Where the incident came from
- Start by finding a component that we use a lot
Listener
Register an event and hit a breakpoint.
return Listener(
onPointerDown: (PointerDownEvent value) {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
child: mockButtonUI(text),
);
Copy the code
So we can see the entire Call Stack information, and we can push back.
Listener
Exposes some of the original pointer events to the callback, which is ultimately handled by the classRenderPointerListener
。
const Listener({
Key? key,
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild, Widget? child, }) ... Omit some code@override
RenderPointerListener createRenderObject(BuildContext context) {
return RenderPointerListener(
onPointerDown: onPointerDown,
onPointerMove: onPointerMove,
onPointerUp: onPointerUp,
onPointerHover: onPointerHover,
onPointerCancel: onPointerCancel,
onPointerSignal: onPointerSignal,
behavior: behavior,
);
}
Copy the code
- RenderPointerListener. Triggered the handleEvent method
onPointerDown
The callback
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// Creates a render object that forwards pointer events to callbacks.
///
/// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
RenderPointerListener({
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerSignal,
HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(behavior: behavior, child: child);
// Omit some code.@override
Size computeSizeForNoChild(BoxConstraints constraints) {
return constraints.biggest;
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, 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)
return onPointerSignal?.call(event);
}
}
Copy the code
GestureBinding.dispatchEvent
Pairs of methodshitTestResult
Distribute events
voiddispatchEvent(PointerEvent event, HitTestResult? hitTestResult) { ... Leave out some codefor (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
}
Copy the code
RendererBinding.dispatchEvent
In the callsuper.dispatchEvent(event, hitTestResult)
@override // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if(hitTestResult ! =null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position ! =null); _mouseTracker! .updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position)); }super.dispatchEvent(event, hitTestResult);
}
Copy the code
- Back to center again
GestureBinding
Through some internal methods, eventually_handlePointerDataPacket
Where to register native callbacks.
graph TD
GestureBinding._handlePointerEventImmediately --> GestureBinding.handlePointerEvent --> GestureBinding._flushPointerEventQueue --> GestureBinding._handlePointerDataPacket
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
window.onPointerDataPacket = _handlePointerDataPacket;
}
Copy the code
- The callback inside the window is actually
PlatformDispatcher.onPointerDataPacket
And, in_dispatchPointerDataPacket
Call to convert the data passed by the engine toPointerDataPacket
// Called from the engine, via hooks.dart
void _dispatchPointerDataPacket(ByteData packet) {
if(onPointerDataPacket ! =null) { _invoke1<PointerDataPacket>( onPointerDataPacket, _onPointerDataPacketZone, _unpackPointerDataPacket(packet), ); }}Copy the code
PointerDataPacket
Is through the_unpackPointerDataPacket
Method converts the data passed by the engine into the following data structure.
/// Some information about the original pointer passed from the native
/// A sequence of reports about the state of pointers.
class PointerDataPacket {
/// Creates a packet of pointer data reports.
const PointerDataPacket({ this.data = const <PointerData>[] }) : assert(data ! =null);
/// Data about the individual pointers in this packet.
///
/// This list might contain multiple pieces of data about the same pointer.
final List<PointerData> data;
}
/// Some information contained in the original pointer
/// Information about the state of a pointer.
class PointerData {
/// Creates an object that represents the state of a pointer.
const PointerData({
this.embedderId = 0.this.timeStamp = Duration.zero,
this.change = PointerChange.cancel,
this.kind = PointerDeviceKind.touch,
this.signalKind,
this.device = 0.this.pointerIdentifier = 0.this.physicalX = 0.0.this.physicalY = 0.0.this.physicalDeltaX = 0.0.this.physicalDeltaY = 0.0.this.buttons = 0.this.obscured = false.this.synthesized = false.this.pressure = 0.0.this.pressureMin = 0.0.this.pressureMax = 0.0.this.distance = 0.0.this.distanceMax = 0.0.this.size = 0.0.this.radiusMajor = 0.0.this.radiusMinor = 0.0.this.radiusMin = 0.0.this.radiusMax = 0.0.this.orientation = 0.0.this.tilt = 0.0.this.platformData = 0.this.scrollDeltaX = 0.0.this.scrollDeltaY = 0.0});Copy the code
- Finally, hooks. Dart
Github.com/flutter/flu…
@pragma('vm:entry-point')
// ignore: unused_element
void _dispatchPointerDataPacket(ByteData packet) {
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}
Copy the code
- This is the whole process of getting events
HitTest
From the above flow, we can know where the click event came from. How does Flutter know where I clicked? Remember I have leave a tip in front, GestureBinding. DispatchEvent method of hitTestResult distribute events, we see hitTestResult is come from?
Find github.com/flutter/flu… In GestureBinding dispatchEvent method. You can see that hitTestResult is passed in as a parameter, so let’s look up.
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
assert(! locked);// No hit test information implies that this is a [PointerHoverEvent],
// [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially
// routed here; other events will be routed through the `handleEvent` below.
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while dispatching a non-hit-tested pointer event'),
event: event,
hitTestEntry: null,
informationCollector: () sync* {
yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty); })); }return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while dispatching a pointer event'),
event: event,
hitTestEntry: entry,
informationCollector: () sync* {
yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty);
yield DiagnosticsProperty<HitTestTarget>('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty); })); }}}Copy the code
GestureBinding._handlePointerEventImmediately
If it is
If (the event is PointerDownEvent | | event is PointerSignalEvent | | event is PointerHoverEvent) was set up, will create a HitTestResult, and call
HitTest (hitTestResult, event.position) method.
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(! _hitTests.containsKey(event.pointer)); hitTestResult = HitTestResult();// Add yourself directly to HitTestResult because it is the root
hitTest(hitTestResult, event.position);
/ / save
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
assert(() {
if (debugPrintHitTestResults)
debugPrint('$event: $hitTestResult');
return true; } ()); }// Remove when up or cancel
else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
// Because events that occur with the pointer down (like
// [PointerMoveEvent]s) should be dispatched to the same place that their
// initial PointerDownEvent was, we want to re-use the path we found when
// the pointer went down, rather than do hit detection each time we get
// such an event.
hitTestResult = _hitTests[event.pointer];
}
assert(() {
if (debugPrintMouseHoverEvents && event is PointerHoverEvent)
debugPrint('$event');
return true; } ());if(hitTestResult ! =null ||
// dispatchEvent is sent for PointerAddedEvent the first time
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position ! =null);
/ / distribute
dispatchEvent(event, hitTestResult);
`}`
}
Copy the code
- The first trigger is
PointerAddedEvent
Enter thedispatchEvent
Because ofhitTestResult
为null
, directly callrenderView.hitTestMouseTrackers(event.position)
.
@override // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if(hitTestResult ! =null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position ! =null); _mouseTracker! .updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position)); }super.dispatchEvent(event, hitTestResult);
}
Copy the code
The hitTest and hitTestChildren methods are then called one by one from the parent node
RenderBox.hitTest
, we encountered a judgment that dealt with increasing the range of clicks_size! .contains(position)
, the click area must be within its own size range to continue the judgmenthitTestChildren
和hitTestSelf
. Student: Is that if we take this judgment out of the equation, like thischild
orchildren
You can accepthitTest
Tested?
bool hitTest(BoxHitTestResult result, { requiredOffset position }) { ... Omit some codeif(_size! .contains(position)) {if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true; }}return false;
}
Copy the code
- RenderBoxContainerDefaultsMixin hitTestChildren, this is the default of many children
hitTest
Notice, fromlastChild
Start judging. I don’t know if you have anyzIndex
The concept of FlutterStack
.Row
.Column
Components such aschildren
Added after the middlechild
Will I receivehitTest
Testing, that’s what it feels likelastChild
It’s on the top.
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return defaultHitTestChildren(result, position: position);
}
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
// The x, y parameters have the top left of the node's box as the origin.
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
summary
- Engine notification Flutter
GestureBinding
GestureBinding
throughhitTest
Method to determine whichRenderOject
Through thehitTest
Test, and joinBoxHitTestResult
.
Key points:
- The size limit
- The children accept
hitTest
The order of
- right
BoxHitTestResult
For event distribution - through
GestureDetector
.RawGestureDetector
Such as components ofListener
The retrieved events listen for conversion to various events that we can more easily accept.
To solve
Buttons A and B are next to components nearby. That is, if you want to increase the click area, you must consider the components nearby.
Pseudo code, the general structure is like this. How can we make ButtonA and ButtonB’s click area bigger?
Row(children: <Widget>[
Text(' '),
Column(children: <Widget>[
Row(children: <Widget>[
ButtonA(),
Text(' '),
ButtonB(),
],),
Text(' '),
],),
Text(' '),,)Copy the code
What would be your first reaction if you expanded the range to look like the picture below?
- My first reaction was to exploit
stack
Draw an invisible area to receivehitTest
. But it has been heard for a long timestack
The overflow part of thehitTest
If you think about it, the overflow part is already oversize
.
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
mockButtonUI(text),
Positioned(
left: - 16,
right: - 16,
top: - 16,
bottom: - 16,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
// Use invisible colors as placeholders to receive hittests
child: const ColoredBox(
color: Color(0x00100000(() [() [() [()Copy the code
RenderBoxHitTestWithoutSizeLimit
Let’s start by creating a mixin to remove the size constraint from hitTest.
mixin RenderBoxHitTestWithoutSizeLimit on RenderBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
assert(() {
if(! hasSize) {if (debugNeedsLayout) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'Cannot hit test a render box that has never been laid out.'),
describeForError(
'The hitTest() method was called on this RenderBox'),
ErrorDescription(
"Unfortunately, this object's geometry is not known at this time, "
'probably because it has never been laid out. '
'This means it cannot be accurately hit-tested.'),
ErrorHint('If you are trying '
'to perform a hit test during the layout phase itself, make sure '
"you only hit test nodes that have completed layout (e.g. the node's "
'children, after their layout() method has been called).'),]); }throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Cannot hit test a render box with no size.'),
describeForError('The hitTest() method was called on this RenderBox'),
ErrorDescription(
'Although this node is not marked as needing layout, '
'its size is not set.'),
ErrorHint('A RenderBox object must have an '
'explicit size before it can be hit-tested. Make sure '
'that the RenderBox in question sets its size during layout.'),]); }return true; } ());if (contains(position)) {
if (hitTestChildren(result, position: position) ||
hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true; }}return false;
}
// Always true
bool contains(Offset position) => true;
// size.contains(position);
}
Copy the code
StackHitTestWithoutSizeLimit
Copy the Stack source code, to infiltrate RenderBoxHitTestWithoutSizeLimit RenderStack.
class StackHitTestWithoutSizeLimit extends Stack {
/// Creates a stack layout widget.
///
/// By default, the non-positioned children of the stack are aligned by their
/// top left corners.
StackHitTestWithoutSizeLimit({
Key? key,
AlignmentDirectional alignment = AlignmentDirectional.topStart,
TextDirection? textDirection,
StackFit fit = StackFit.loose,
Clip clipBehavior = Clip.hardEdge,
List<Widget> children = const <Widget>[],
}) : super(
key: key,
children: children,
alignment: alignment,
textDirection: textDirection,
fit: fit,
clipBehavior: clipBehavior,
);
bool _debugCheckHasDirectionality(BuildContext context) {
if (alignment is AlignmentDirectional && textDirection == null) {
assert(
debugCheckHasDirectionality(context,
why: 'to resolve the \'alignment\' argument',
hint: alignment == AlignmentDirectional.topStart
? 'The default value for \'alignment\' is AlignmentDirectional.topStart, which requires a text direction.'
: null,
alternative:
'Instead of providing a Directionality widget, another solution would be passing a non-directional \'alignment\', or an explicit \'textDirection\', to the $runtimeType. ')); }return true;
}
@override
RenderStack createRenderObject(BuildContext context) {
assert(_debugCheckHasDirectionality(context));
returnRenderStackHitTestWithoutSizeLimit( alignment: alignment, textDirection: textDirection ?? Directionality.of(context), fit: fit, clipBehavior: clipBehavior, ); }}class RenderStackHitTestWithoutSizeLimit extends RenderStack
with RenderBoxHitTestWithoutSizeLimit {
RenderStackHitTestWithoutSizeLimit({
List<RenderBox>? children,
AlignmentGeometry alignment = AlignmentDirectional.topStart,
TextDirection? textDirection,
StackFit fit = StackFit.loose,
Clip clipBehavior = Clip.hardEdge,
}) : super(
alignment: alignment,
children: children,
textDirection: textDirection,
fit: fit,
clipBehavior: clipBehavior,
);
}
Copy the code
RowHitTestWithoutSizeLimit, ColumnHitTestWithoutSizeLimit
Row(children: <Widget>[
Text(' '),
Column(children: <Widget>[
Row(children: <Widget>[
ButtonA(),
Text(' '),
ButtonB(),
],),
Text(' '),
],),
Text(' '),,)Copy the code
Row and Column also need special processing because the Stack overflows into the other child areas of Row and Column.
class RowHitTestWithoutSizeLimit extends Row
with FlexHitTestWithoutSizeLimitmixin {
RowHitTestWithoutSizeLimit({
Key? key,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline?
textBaseline, // NO DEFAULT: we don't know what the text's baseline should be
List<Widget> children = const <Widget>[],
}) : super(
children: children,
key: key,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
textBaseline: textBaseline,
);
}
mixin FlexHitTestWithoutSizeLimitmixin on Flex {
@override
RenderFlex createRenderObject(BuildContext context) {
returnRenderFlexHitTestWithoutSizeLimit( direction: direction, mainAxisAlignment: mainAxisAlignment, mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, textDirection: getEffectiveTextDirection(context), verticalDirection: verticalDirection, textBaseline: textBaseline, clipBehavior: clipBehavior, ); }}class RenderFlexHitTestWithoutSizeLimit extends RenderFlex
with
RenderBoxHitTestWithoutSizeLimit.RenderBoxChildrenHitTestWithoutSizeLimit {
RenderFlexHitTestWithoutSizeLimit({
List<RenderBox>? children,
Axis direction = Axis.horizontal,
MainAxisSize mainAxisSize = MainAxisSize.max,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline? textBaseline,
Clip clipBehavior = Clip.none,
}) : super(
children: children,
direction: direction,
mainAxisSize: mainAxisSize,
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
textBaseline: textBaseline,
clipBehavior: clipBehavior,
);
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
returnhitTestChildrenWithoutSizeLimit( result, position: position, children: getChildrenAsList().reversed, ); }}Copy the code
Because of the children to accept hitTest order by default, we need to make priority RenderBoxHitTestWithoutSizeLimit hitTest.
mixin RenderBoxChildrenHitTestWithoutSizeLimit {
bool hitTestChildrenWithoutSizeLimit(
BoxHitTestResult result, {
required Offset position,
required 可迭代<RenderBox> children,
}) {
final List<RenderBox> normal = <RenderBox>[];
for (final RenderBox child in children) {
if ((child is RenderBoxHitTestWithoutSizeLimit) &&
childIsHit(result, child, position: position)) {
return true;
} else {
normal.insert(0, child); }}for (final RenderBox child in normal) {
if (childIsHit(result, child, position: position)) {
return true; }}return false;
}
bool childIsHit(BoxHitTestResult result, RenderBox child,
{required Offset position}) {
final ContainerParentDataMixin<RenderBox> childParentData =
child.parentData as ContainerParentDataMixin<RenderBox>;
final Offset offset = (childParentData as BoxParentData).offset;
final bool isHit = result.addWithPaintOffset(
offset: offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - offset);
returnchild.hitTest(result, position: transformed); });returnisHit; }}Copy the code
We can increase the click range by replacing the original component with the new one.
RowHitTestWithoutSizeLimit(children: <Widget>[
Text(' '),
ColumnHitTestWithoutSizeLimit(children: <Widget>[
RowHitTestWithoutSizeLimit(children: <Widget>[
ButtonA(),
Text(' '),
ButtonB(),
],),
Text(' '),
],),
Text(' '),
],)
Widget ButtonA()
{
return StackHitTestWithoutSizeLimit(
clipBehavior: Clip.none,
children: <Widget>[
mockButtonUI(text),
Positioned(
left: - 16,
right: - 16,
top: - 16,
bottom: - 16,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
// Use invisible colors as placeholders to receive hittests
child: const ColoredBox(
color: Color(0x00100000(() [() [() [() }Copy the code
extra_hittest_area | Flutter Package (flutter-io.cn)
In order to facilitate your use, I will commonly used components encapsulated for you to use.
Parent widgets
Like the official widgets, use them to ensure that hittests are received when the additional hitTest area exceeds the size of the parent widget.
StackHitTestWithoutSizeLimit
RowHitTestWithoutSizeLimit
.ColumnHitTestWithoutSizeLimit
.FlexHitTestWithoutSizeLimit
SizedBoxHitTestWithoutSizeLimit
Listen for widgets for click events
GestureDetectorHitTestWithoutSizeLimit
RawGestureDetectorHitTestWithoutSizeLimit
ListenerHitTestWithoutSizeLimit
parameter | description | default |
---|---|---|
extraHitTestArea | Additional hitTest area added | EdgeInsets.zero |
debugHitTestAreaColor | Background color of the hitTest area for debug | null |
. You can set the ExtraHitTestBase debugGlobalHitTestAreaColor instead of in each monitoring widget set debugHitTestAreaColor separately
Implement other HitTestWithoutSizeLimit
If the package doesn’t have the widgets you need, you can implement them yourself using the following classes.
RenderBoxHitTestWithoutSizeLimit
, RenderBoxChildrenHitTestWithoutSizeLimit
conclusion
This time we tried to solve a real development problem. It was important to understand the origin of gesture events in Flutter. How to convert raw events from the engine to Tap, onLongPress, Scale, etc.
FlutterChallenges QQ group 321954965 welcome to add new challenges or solve them.
loveFlutter
Love,candy
Welcome to join usFlutter CandiesTogether to make cute little Flutter candiesQQ group: 181398081
And finally, put Flutter Candies on it. It smells sweet.