跪拜 Guibai
← Back to the summary

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:

The hit package 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:

First, HitLayer is a MultiChildRenderObjectWidget that internally contains two widgets:

HitLayer
├── hitChild      Click area
└── paintChild    Visual content + layout size

Where:

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:

So when it enters deferred mode, the local hitTest() directly returns false to 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:

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:

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 outer HitLink, 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:

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:

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.

Links

https://github.com/definev/hit

Comments

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]