Repository navigation
Expanded hitTest area #31728
Description
It would be useful to be able to expand the hit test area of a widget. Often, the size of a widget can't be increased without ugly hacks or at all, while the current size is too small to make it easily tappable.
The code below contains two examples where this would be useful (and a failed attempt to achieve this). In the first, the red container might be the thumb of a slider at the bottom of an app bar or a handle at the top of a panel for resizing it. In the second case, the button with "<" is inside several widgets to align it with "Title" below it. (In our code, there are several more layout widgets between the padding and "<" to handle animations) In both cases, increasing the size of the widget inside the gesture detector, while keeping the layout visually the same, would lead to a awkward layout.
The ExpandedHitTestArea widget is an attempt to achieve an expansion without affecting the size. However, it doesn't work because all parent widgets don't hit test their children when the hit point is outside the parent.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(home: HitTest());
}
class HitTest extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
SizedBox(
width: 100,
height: 100,
child: Container(
alignment: Alignment.bottomCenter,
color: Colors.yellow,
height: 100,
width: 100,
child: GestureDetector(
onTap: () => print("I'm hit! I'm hit!"),
child: ExpandedHitTestArea(
child: Container(width: 20, height: 10, color: Colors.red),
),
),
),
),
Container(
height: 100,
color: Colors.black12,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
GestureDetector(
onTap: () => print("Tapped"),
child: ExpandedHitTestArea(child: Text("<")),
),
SizedBox(height: 20),
Text("Title"),
],
),
),
),
],
),
);
}
}
class ExpandedHitTestArea extends SingleChildRenderObjectWidget {
const ExpandedHitTestArea({
Key key,
Widget child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) => RenderExpandedHitTestArea();
}
class RenderExpandedHitTestArea extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
// trivial implementations left out to save space: computeMinIntrinsicWidth, computeMaxIntrinsicWidth, computeMinIntrinsicHeight, computeMaxIntrinsicHeight
@override
void performLayout() {
child.layout(constraints, parentUsesSize: true);
size = child.size;
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final BoxParentData childParentData = child.parentData;
context.paintChild(child, childParentData.offset + offset);
}
}
@override
bool hitTest(HitTestResult result, {Offset position}) {
const minimalSize = 44;
final deltaX = (minimalSize - size.width).clamp(0, double.infinity) / 2;
final deltaY = (minimalSize - size.height).clamp(0, double.infinity) / 2;
if (Rect.fromLTRB(-deltaX, -deltaY, size.width + deltaX, size.height + deltaY).contains(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
return false;
}
}[✓] Flutter (Channel unknown, v1.4.18, on Mac OS X 10.14.4 18E226, locale en-NL)
• Flutter version 1.4.18 at /Users/spkersten/Development/.../flutter
• Framework revision 8bea3fb2eb (2 weeks ago), 2019-04-11 13:11:22 -0700
• Engine revision 72986c39ea
• Dart version 2.2.1 (build 2.2.1-dev.3.1 None)

Activity
jonahwilliams commentedon Apr 28, 2019
This is something I looked at fairly extensively when we refreshed the material widget catalog to meet android accessibility guidelines. Specifically, Android recommends at least 48 by 48 minimum hit-testable area (and iOS 44 by 44).
One of the first solutions I looked at was something similar to what you've proposed above. The problem is - how do you adjust hit testing to work without requiring a single gesture to hit test every single render box? Once the layout bounds and the hit testable bounds don't line up you always need to walk the tree.
Ultimately, what we found is that making a visual element's hit testable bounds larger without increasing the size of the element is actually the hack, and not the other way around. While some wiggle room is okay, It's not intuitive if the two don't agree. The correct solution is to adjust the designs and UX.
If the widget needs small adjustments, use padding. This is the approach we've taken with many of the material widgets. This can be configured via https://docs.flutter.dev/flutter/material/MaterialTapTargetSize-class.html in the theme, (so that we can remove the padding on desktop platforms).
(Caution) you may increase the size of a child hit testable region to at least the size of a parent. This is done for the dismissible section of the material chips. The render object that wraps the chip redirects taps from the right third of the chip to make it easier to tap.
flutter/packages/flutter/lib/src/material/chip.dart
Line 1719 in 0bf0f5c
jonahwilliams commentedon Apr 28, 2019
Oh, and the other issue with increasing hit test area: it's not possible to guarantee that these hit test regions do not overlap, unless we did another layout pass
spkersten commentedon May 27, 2019
@jonahwilliams In the cases we would need this, the parent is limiting the size the child can be expanded to, so your enumerated solutions don't work there. Saying the problem is the UI design sounds a bit too easy for me. There are cases where element are far enough away in the design, but where the natural structure of widgets make it hard to expand tappable widgets. For example cards in a sliver grid with axis spacing: Making cards invisibly bigger and reducing the spacing is an awkward solution.
Widgets could hitTest their children when the event happens within their bounds extended by a small margin (44/2 or 48/2). Or widgets could report a sizeForHitTesting upstream besides the normal size.
TonyDowney commentedon Jul 25, 2020
Being able to modify the hitTestArea of a widget would also solve the issues with OverflowBox, where anything in the "overflow" area can't receive any hitTest events, since it's outside of the parent's boundaries.
malikwang commentedon Sep 27, 2020
Kind of hack, but works well. The main idea is: add a Member into GestureArena.
malikwang commentedon Sep 29, 2020
I build a new package: expand_tap_area
esDotDev commentedon Jun 21, 2021
If something is less code, easier to change, easier to understand and more resilient, I don't really understand how it can be called a "hack". For better or worse, Material is a boxy ui, with paradigms that lend themselves well to this approach, but this assumption is being pushed too deep into the framework itself.
It's biased against fun playful UI's that break rules, and overlap content, and have things peek from behind other things, etc... which is a shame, cause Flutter has all the tools to make these sorts of UI's easily, but is handcuffed by this "in the box" thinking wrt hit detection.
Adobe AIR did this performantly, at 60fps, on an iPad 2, 10 years ago. I know that might not be completely fair comparison but it's hard to justify this limitation for performance reasons, in 2021, after 10 cycles of moores law.
Hixie commentedon Jun 30, 2021
I would be interested in an apples-to-apples comparison here. What exactly did Adobe AIR do? What exactly was the performance of hit-testing in that environment? What's the performance of the equivalent in Flutter today on the same hardware?
esDotDev commentedon Jul 1, 2021
Well, simply put: AIR allowed pixel perfect UI hit detection in at a reasonable cost, without caring about parent bounds or negative margins. Hit areas were as big as they looked visually (though this was easily overridden), if it was on-screen and visible you could touch it, and it didn't matter where you where in your parent's coordinates.
This is super flexible and robust, and really lets the author express themselves in whatever way they see fit, without battling the framework.
Here's a quick demo I put together, close to 1000 buttons in each.
https://user-images.githubusercontent.com/736973/124080755-f7af0200-da07-11eb-92e2-40fa704a738b.mp4
Both AIR and Flutter use about 3% of my CPU at peak when doing mouse-overs for 10 seconds. The AS3 buttons are offset in negative space in their parent, just to show it does not matter.
If I drop the button count to a more reasonable 40 buttons, we see about 0.5% CPU usage, and again both mechanisms seem to have similar performance.
AS3:
https://github.com/esDotDev/air-flutter-hit-tests/blob/master/air_hit_tests/src/Main.as
https://github.com/esDotDev/air-flutter-hit-tests/blob/master/air_hit_tests/src/Button.as
Flutter:
https://github.com/esDotDev/air-flutter-hit-tests/blob/master/flutter_hit_tests/lib/main.dart
Hixie commentedon Jul 1, 2021
How deep is the tree in AS3? What was its hit testing algorithm? How did it determine sibling order (e.g. when two "cousin" buttons overlap, which one gets the events)? Were all objects able to get events or only those who registered as event targets? How efficient were tree updates (e.g. reordering buttons, or moving them about such that the hit order was different)?
11 remaining items
JasCodes commentedon Apr 13, 2022
https://pub.dev/packages/extra_hittest_area
This works for me
forumics commentedon Dec 9, 2022
Since flutter framework has more or less decided not to fix this, i've come up with my own solution which is a hacky way of doing things but for now this is the best approach for me
shtse8 commentedon Nov 16, 2023
Flutter needs to support this io. Hit test will be failed after transformation when the Box goes outside. I am making a physic engine using flutter. Elements are transformed using translate and rotationto display correctly. I can't find a way to make it work with gesture detection.
denysbohatyrov commentedon Feb 14, 2024
Any news here?
aytunch commentedon Mar 23, 2024
@matthew-carroll did you find a solution for this?
MouseRegionsonHoverandonEnterhappen only when cursor is exactly on the 1px divider which is not ideal. Making the divider larger than 1px is not a solution, it takes unwanted real estate.Secondly, when we use the
GestureDetectorsonPanUpdateto slide/move the panels,onExitfires and user loses the visual clue (I make the divider different color on onHover)matthew-carroll commentedon Mar 23, 2024
@aytunch I never found any built-in solution. I think I paused my work on the resizable panel system. I'll probably come back to it.
I think my approach at this point would be to place an invisible hit area in the app
Overlay, which is aligned with the divider widget that changes color when you hover over it.This alignment might be accomplished with
CompositedTransformTargetandCompositedTransformFollower. The Flutter Bounty Hunters also has our own version of those widgets that add more control over positioning, calledLeaderandFollowerwhich are available infollow_the_leader: https://pub.dev/packages/follow_the_leaderWould you like the Flutter Bounty Hunters to build a package specifically for resizable panels for desktop apps? If so, would you like to collaborate on that?
aytunch commentedon Mar 23, 2024
@matthew-carroll thanks for the detailed explanation.
follow_the_leaderhas potential to mimic the behavior we are looking for. I will need to experiment to see if it covers all edge cases. Second thing I will try will be to create a render object and make theHitTestlarger than theRenderBox. I will write my findings if I have any luck.I am just playing around with Flutter desktop to create a panel system. This is harder than I think, but Flutter community could greatly benefit from it. I can think of so many desktop apps being built on top of such a design system (code editors, image/video/audio editors, browsers, tools, etc)
CodesbyRobot commentedon Nov 22, 2024
Adobe did it 15 years ago and Flutter still can't do it