A Flutter Package Decouples Paint from Hit-Testing So Click Zones Can Overflow Layout Bounds
definev/hit is a third-party package that supports click effects which can flexibly overflow the widget's size. In extreme cases, it can even make a widget stay in place while its click area extends everywhere. Simply put, its purpose is to solve the requirement where "the widget's visual size needs to be small, but the click hot zone needs to be large."
In Flutter, the default layout size and hit-test size are bound together, which leads to two problems:
- To enlarge a widget's click area, you usually add padding, but this also expands the layout and squeezes adjacent widgets.
- If a child widget visually overflows its parent container's boundaries, Flutter does not perform hit-testing on the overflow part by default, making the overflow area unclickable, such as a small dot badge hanging from the top-right corner of a card.
The
hitpackage decouples the concepts of "what to paint" and "where to hit," so you can make the click hot zone larger than the visual size, and even allow it to overflow outside the parent container's layout box while still being clickable.
For example, in scenarios like dragging a progress bar or resizing a window, enlarging the click hot zone can significantly improve the interactive experience without changing the UI layout.
To achieve this, the project implements several concepts: HitLayer, HitLink, HitScope, and Hit.defer / Hit.before:
HitScope(
child: Padding(
padding: const EdgeInsets.all(12),
child: HitLayer(
alignment: Alignment.center,
behavior: HitTestBehavior.deferToChild,
hitChild: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: const SizedBox(width: 48, height: 48),
),
paintChild: const IgnorePointer(
child: Icon(Icons.add, size: 24),
),
),
),
)
The relationship between them is shown in the figure below. First:
HitLayersplits the layout layer and the click layer into two child nodes.HitLinkmaintains a registry of out-of-bounds targets.HitScopeuniformly scans out-of-bounds targets.Hit.deferprovides a mechanism for widgets that have been placed outside their parent's boundaries.
First, HitLayer is a MultiChildRenderObjectWidget that internally contains two widgets:
HitLayer
├── hitChild Click area
└── paintChild Visual content + layout size
Where:
paintChilddetermines the final layout size ofHitLayer.hitChildcan be laid out independently, allowing it to be larger thanpaintChild.alignmentdetermines how the two are aligned.
Its corresponding RenderObject is RenderHitLayer. In performLayout(), the code first lays out paintChild according to normal constraints, then sets its own size to paintChild.size. It then lays out hitChild using constraints.loosen(), allowing the latter to obtain a larger size. The logic is roughly equivalent to:
paint.layout(constraints);
size = paint.size;
hit.layout(constraints.loosen());
hit.offset = alignment.calculateOffset(
paintSize - hitSize,
);
The whole process is similar to:
hitChild: 48×48
paintChild: 24×24
alignment: center
hitChild offset = (-12, -12)
hitChild 48×48
┌────────────────┐
│ │
│ ┌──────┐ │
│ │paint │ │
│ │24×24 │ │
│ └──────┘ │
│ │
└────────────────┘
The size reported by HitLayer to the parent node is still only 24×24.
Then, RenderHitLayer calculates the corresponding clickable area:
Rect hitRect
Rect layoutRect
At this point, it can determine whether hitChild has exceeded its own layout area:
hitRect.left < layoutRect.left
hitRect.top < layoutRect.top
hitRect.right > layoutRect.right
hitRect.bottom > layoutRect.bottom
Only when an actual overflow occurs and a HitScope or explicit HitLink exists will it be registered as a deferred hit target. That is:
- Normal
HitLayerwithout overflow: continues to use Flutter's native hit path. HitLayerwith overflow: handed over toHitScope.- Not all widgets are stuffed into an additional global scan list.
So when it enters deferred mode, the local
hitTest()directly returnsfalseto prevent the same target from being hit twice by both the local path and the Scope path.
Then, HitLink is used to save the registry of out-of-bounds targets. HitLink is essentially:
class HitLink extends ChangeNotifier {
final List<HitDeferRegistration> _targets = [];
}
That is, all out-of-bounds targets implement the unified interface HitDeferRegistration, which provides:
- Which RenderBox to use for coordinate transformation.
- What the actual hit area is.
- How to execute deferred hit-test.
- Whether it is
opaqueortranslucent. - Whether it needs to be repainted by the Scope.
- Whether to use compositing layers to track position.
HitLink is responsible for registration, removal, and notifying the Scope to repaint. The hit order is newest-first, which basically simulates the visual behavior where widgets that appear later and are on top receive events first.
Then there is HitScope, which uniformly scans out-of-bounds targets. HitScope is a StatefulWidget that exposes HitLink to the subtree via an internal InheritedWidget.
When a Pointer enters the Scope, it will:
- Scan the targets in
HitLinkfrom back to front. - Transform each target's coordinates to the current Scope.
- Quickly determine if the Pointer is within the target's bounding box.
- Convert the Pointer to the target's local coordinates.
- Call the target's own
hitTestDeferred. - Finally, optionally continue testing the normal subtree.
The implementation code here corresponds to something like:
target.hitTestBox.getTransformTo(this)
MatrixUtils.transformRect(...)
result.addWithPaintTransform(...)
In other words, it does not simply save a fixed global Rect; hit performs coordinate transformation according to the current RenderObject's transform.
From the repository's test cases, you can also see that it basically covers scenarios like
Transform.translate, nested Scopes, explicit outerHitLink, ClipRect blocking, and opaque targets skipping the normal subtree.
Of course, there is also Hit.defer. What is the difference between it and HitLayer?
These two are indeed somewhat different. For example, HitLayer is suitable for situations where "at the same position, the visual layer and the click layer have different sizes," such as:
Visual icon: 24×24
Click area: 48×48
Common scenarios generally include:
- Icon buttons
- Slider thumb
- Resize edge
- Small drag handles
Because in this case, its layout size is determined by
paintChild.
But Hit.defer is different. It is suitable for situations where "the entire Widget has already been placed outside the parent's boundaries," such as:
Stack 100×100
Badge located at right: -12
That is, it does not redesign the widget's internal layout, but instead chooses to have the existing child completely exit the local hit-test and register with an ancestor HitScope. RenderHitDefer.hitTest() always returns false and can only be hit via the Scope.
The hit mechanism of Hit.before is the same as Hit.defer, but its painting is placed below the entire subtree via the Scope, making it more suitable for:
- Background decorations that extend outward
- Edge shadows
- Special areas that need to receive events below the parent content
So it can be seen that the entire project has done a very thorough adaptation for different scenarios.
Moreover, the project has made quite good adaptations. For example, in a scrollable list, Hit.defer(paintOnTop: true) would normally have a problem:
Suppose an out-of-bounds target is located inside a scrollable list, but it is drawn via an outer
HitScope. If only a global coordinate is calculated once in the Scope, after scrolling occurs, the target's position may have changed, and the Scope may not necessarily repaint completely.
The project's solution uses Flutter's compositing layer mechanism:
Original position: LeaderLayer
Outer Scope: FollowerLayer
RenderHitDefer places a LeaderLayer at the original position but does not actually draw the child. Then RenderHitScope creates a corresponding FollowerLayer on top and draws the child within it.
This way, during the GPU compositing phase, the Follower can follow the Leader's scrolling and Transform, without needing to recalculate the entire Scope's drawing every frame.
This is also the most technically complex and valuable part of the entire package. Compared to currently common overflow solutions, the advantages of hit are quite obvious, and it also solves the previous defect of HitSlop in ListView, with a high degree of overall adaptability:
| Solution | Enlarges click area | Does not change layout | Supports clicks outside parent bounds | Supports reordering paint layers |
|---|---|---|---|---|
| Padding | Yes | No | No | No |
| Transparent Container | Yes | No | No | No |
Stack + clipBehavior: none |
No | Yes | No | Can only draw overflow |
| OverflowBox | No | Partially | No | No |
| Transform | Follows Transform behavior | Yes | Limited by parents | No |
| Overlay / Portal | Yes | Yes | Yes | Yes, but structure is heavier |
hit |
Yes | Yes | Yes | Supports top / before |
So if you have overflow click scenarios, or situations where you need to improve manipulation UX, hit is quite suitable.
Top 2 from juejin.cn, machine-translated. The original thread is authoritative.
Direct native interop will come out with the August update. Really looking forward to it!
Troublesome open-source library author, please settle the advertising fee [arrogant][arrogant]