hit 1.2.4
hit: ^1.2.4 copied to clipboard
Decouple Flutter layout size from hit-test size, and deliver taps outside parent bounds.
Separate paint/layout size from hit size in Flutter, and deliver taps that fall outside a widget’s layout box.
Use it when a control must stay visually small (icon, grip, 1px edge, chip ×) but still meet a comfortable / WCAG touch target without pushing neighbors.
Live demo: hit-one-snowy.vercel.app
Table of contents #
- Why
- Install
- Quick start
- The two pieces you need
- Common mistakes
- More patterns
- API reference
- Debugging
- Performance
- Migrating from 1.1.x
- Example app
Why #
Flutter layout and hit-testing share the same box. Growing padding to enlarge a tap target also grows layout. Overflowing a child past its parent usually stops receiving hits.
hit splits those concerns: the visual stays small; the tap target can be larger (or hang outside a parent) without breaking neighbors.
Deferred out-of-bounds hit testing is inspired by defer_pointer (gskinnerTeam/flutter-defer-pointer): a handler higher in the tree (HitScope) receives hits for targets that opt out of local hit-testing (HitDefer / overflowing HitLayer).
Install #
dependencies:
hit: ^1.2.3
import 'package:flutter/material.dart';
import 'package:hit/hit.dart';
Quick start #
Minimum icon with a 48×48 hit target — layout stays 24×24.
HitScope must sit on an ancestor whose layout box covers the expanded hit area. Pad the scope (or place it on a larger panel / page) so the overflow stays inside.
HitScope(
// 12px pad absorbs the overflow of a centered 24→48 expansion.
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
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),
),
),
const SizedBox(width: 8),
const Text('New item'),
],
),
),
)
Whenever hitChild overflows paintChild, wrap a covering ancestor in HitScope. Prefer several small scopes (per padded row / panel) over one app-wide scope.
The two pieces you need #
| Piece | Role |
|---|---|
HitLayer |
Layout follows paintChild; hitChild can be larger and overflow |
HitScope |
Delivers overflow / out-of-bounds hits to registered targets |
Later you may also use HitDefer (hanging widgets without HitLayer) and SliverHitScope (same idea inside scroll slivers). See More patterns.
Common mistakes & troubleshooting #
Most “taps don’t work on the overflow” bugs are the same root cause: Flutter only hit-tests a child inside that child’s layout box. HitScope can deliver deferred hits, but only if a pointer event actually reaches the scope.
Mistake 1 — scope too small #
Wrapping HitScope only around the tiny control leaves the expanded hit area outside the scope’s layout box. Parents never walk there, so the overflow never gets a chance.
✗ Wrong — HitScope == paint size (24×24)
hit area (48×48) — outside scope, never tested
┌ · · · · · · · · ┐
· ┌───────────┐ ·
· │ HitScope │ ·
· │ ┌───────┐ │ ·
· │ │ paint │ │ ·
· │ │ 24×24 │ │ ·
· │ └───────┘ │ ·
· └───────────┘ ·
└ · · · · · · · · ┘
✓ Right — HitScope covers the expanded hit (pad / larger ancestor)
┌─────────────────────┐
│ HitScope + padding │
│ ┌─────────────┐ │
│ │ hit 48×48 │ │
│ │ ┌───────┐ │ │
│ │ │ paint │ │ │
│ │ │ 24×24 │ │ │
│ │ └───────┘ │ │
│ └─────────────┘ │
└─────────────────────┘
Fix: add padding under the scope, or move HitScope up to a panel / row / page that already covers the overflow.
Mistake 2 — clip or tight parent above the scope #
✗ ClipRect / tight box above HitScope
┌──────── ClipRect ────────┐
│ ┌──── HitScope ────┐ │ ← clip’s layout box
│ │ hit overflows… │····│···· ← events never enter here
│ └──────────────────┘ │
└──────────────────────────┘
Fix: put HitScope above the clip, or remove / relax the clip for that region. Same idea for tight SizedBox / OverflowBox parents that shrink the walk.
Mistake 3 — missing HitScope #
Overflowing HitLayer and HitDefer need a scope (or an explicit HitLink wired to one). Without it, only the layout box is hittable; debug builds assert.
pointer → parent walk → HitLayer layout box only
└── overflow corners: ignored
Fix: wrap a covering ancestor in HitScope.
Checklist #
| Symptom | Likely cause | Fix |
|---|---|---|
| Corners of a 48×48 hit miss | Scope / parent same size as paint | Pad under scope, or lift scope |
| Works in center, fails on overflow | Scope too tight or clip above | Cover overflow; move scope above clip |
| Assert / no hits outside box | No HitScope |
Add one that covers the hit area |
| Wrong nested target wins | Nearest scope / walk order | Use explicit link, or restructure scopes |
More patterns #
Hanging badge with HitDefer #
For widgets that hang outside a parent without using HitLayer. Keep the hanging child inside the scope’s layout box (padding is the usual fix):
HitScope(
child: Padding(
// Absorbs the badge hanging 12px outside the card.
padding: const EdgeInsets.all(12),
child: SizedBox(
width: 100,
height: 100,
child: Stack(
clipBehavior: Clip.none,
children: [
const Positioned.fill(child: ColoredBox(color: Colors.white)),
Positioned(
right: -12,
top: -12,
child: HitDefer(
paint: HitDeferPaint.onTop, // or none (default)
behavior: HitTestBehavior.opaque,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onBadgeTap,
child: const CircleAvatar(radius: 18),
),
),
),
],
),
),
),
)
Slivers with SliverHitScope #
Same deferred contract as HitScope, for sliver subtrees inside a CustomScrollView (or other viewport):
CustomScrollView(
slivers: [
SliverHitScope(
// link: myLink,
sliver: SliverList.list(
children: [
// HitLayer / HitDefer descendants
],
),
),
],
)
Put it in the slivers list (or nest it under another sliver parent). Do not pass a box child directly — wrap boxes with SliverToBoxAdapter / list / grid slivers as usual.
Coverage rule: the pointer must land inside this sliver’s hit-test extent (and cross-axis extent). Overflow that leaves the sliver (or the viewport) still needs a larger enclosing scope.
API reference #
Stable surface from package:hit/hit.dart:
HitLayer,HitScope/HitScopeState,SliverHitScope/SliverHitScopeState,HitScopeHandleHitDefer,HitDeferPaintHitLink,HitDeferRegistrationdebugPaintHitAreas,debugHitSelectEnabled,debugHitProbeEnabled,paintHitAreaDebugOverlay,ensureHitDevToolsInitialized- Optional
debugLabelonHitLayer/HitDefer/HitScope/SliverHitScope
HitLayer #
paintChild— visual layer; defines layout sizehitChild— gesture / hover layer; may be largeralignment— where paint sits inside the hit box (Alignment.centerby default)behavior— how paint and hit interact (HitTestBehavior; defaultopaque)link— optionalHitLink; defaults to the nearestHitScope
When hitChild overflows layout, hits are delivered through HitScope. Non-overflowing layers stay on the normal local hit path.
Wrap paintChild in IgnorePointer when you want only hitChild to receive gestures (typical with deferToChild).
HitScope #
Ancestor that hit-tests (and optionally paints) deferred targets.
HitScope(
// link: myLink, // optional shared HitLink
child: /* … */,
)
- Nesting is supported; nearest scope wins (
HitScope.maybeOf/of), including acrossHitScopeandSliverHitScope. - Prefer many small scopes over one app-wide scope — but each scope’s layout box must cover the deferred hit areas it serves.
- Pass an explicit
linkto register with an outer scope instead of the nearest one. HitScope.ofthrows aFlutterErrorwhen no scope is found; usemaybeOfwhen absence is allowed.
Deferred hit walk order is newest-first.
SliverHitScope #
See Slivers with SliverHitScope above for usage.
HitScope.of/maybeOffindSliverHitScopethe same way they findHitScope.
HitDefer #
See Hanging badge with HitDefer above for a full example.
paint—HitDeferPaint.none(default: paint in place) oronTop(after the scoped subtree via composited follower; tracks scroll)behavior— defaults totranslucent; useopaquewhen a hit should stop further deferred scanning and skip the scoped subtreelink— optional; defaults to the nearestHitScope
HitDeferPaint #
| Value | Paint |
|---|---|
none |
In place (hit only deferred) |
onTop |
After scoped subtree (composited; tracks scroll) |
HitTestBehavior #
Defaults differ by API and are intentional:
| API | Default |
|---|---|
HitLayer |
opaque |
HitDefer |
translucent |
| Value | Meaning |
|---|---|
translucent |
On HitLayer: test paint and hit when both overlap. On deferred targets: hit and still walk the scoped subtree |
deferToChild |
Prefer paint; hit only if paint missed (HitLayer) |
opaque |
On HitLayer: same as defer for paint vs hit. On deferred targets: stop further deferred scanning and skip the scoped subtree |
HitLink #
Registry of deferred targets for a scope. Usually owned by HitScope; pass explicitly to share or target a non-nearest scope. HitDeferRegistration is the extension contract implemented by deferred targets.
Membership uses identity (identical) for O(1) contains / add. Paint and geometry notifications are split:
paintListenable—add/remove/descendantNeedsPaint(scopes repaint followers)geometryListenable—markGeometryDirtyonly (does not force a scope repaint; hit testing reads live transforms, andonToppaint tracks scroll via compositing)
Debugging #
Hit-area overlays #
import 'package:hit/hit.dart';
debugPaintHitAreas = true; // or enable Flutter DevTools → Debug Paint
Overflowing / deferred hit bounds are drawn as a dashed overlay (including regions outside layout size, painted from the enclosing scope so clips do not hide them).
DevTools extension #
Apps that depend on package:hit get a hit tab in Dart DevTools (enable
it from the Extensions menu the first time). The tab can:
- toggle hit-area overlays, Select, and Probe mode remotely
- browse a hierarchical Hit Scope Tree (
TreeView) of scopes and targets - inspect Details for the selection, or a Probe list after tapping the app
- tap a debug-painted hit area in the app (with Select on) to jump/highlight
the matching tree node and open the
HitLayer/HitDefer/HitScopecall site in the IDE (via Flutter Widget Inspector navigate) - with Probe on, tap the app to list every hit area under that point and explain why a tap might miss; tap a row to highlight and jump in the tree
Service extensions (ext.hit.*) register automatically in debug/profile when
any hit widget mounts. Prefer setting debugLabel on targets/scopes so the
tree is readable:
HitLayer(
debugLabel: 'compose-send',
// ...
)
Rebuild the embedded web assets after changing the
extension UI (run before pub publish):
./tool/build_devtools.sh
That script tree-shakes icons and drops unused local CanvasKit so the published archive stays small (~1 MB compressed).
See DevTools extensions
and devtools_extensions.
Performance notes #
Deferred hit-testing is O(n) over registered targets on that scope. To keep it fast:
- Keep
HitScopetight around overflow regions — but still large enough to cover them - Prefer non-overflowing
HitLayerwhen the hit fits in paint size (no registration) - Prefer
deferToChild/opaqueovertranslucentwhen you do not need dual hits - Keep
hitChildshallow (GestureDetector+SizedBox) - Avoid nesting deferred targets under one huge root scope
- Use
HitDeferonly when you need out-of-bounds delivery
A handful of min-target / edge / handle layers is cheap. Hundreds of deferred targets under one scope is not.
Migrating from 1.1.x #
1.2.0 is a breaking release for deferred hits:
Before (≤1.1) |
After (1.2) |
|---|---|
Hit.defer(child: w) |
HitDefer(child: w) |
Hit.defer(paintOnTop: true, child: w) |
HitDefer(paint: HitDeferPaint.onTop, child: w) |
Hit.before(child: w) |
HitDefer(paint: HitDeferPaint.onTop, child: w) |
target.deferPaintOnTop / deferPaintUnder |
target.deferPaint (HitDeferPaint) |
link.addListener / removeListener |
addPaintListener / removePaintListener |
HitDeferPaint: none (default, paint in place) or onTop (after scoped
subtree, composited scroll tracking). Under-scope deferred paint
(Hit.before / paintUnder) is removed — use onTop instead.
HitLink splits paint vs geometry notifications: markGeometryDirty no
longer forces a scope repaint (hit testing uses live transforms; onTop
paint tracks scroll via compositing).
Example app #
Live demo: https://hit-one-snowy.vercel.app/
One-page demo covering HitLayer, HitDefer, common controls (chip dismiss, Text.rich / WidgetSpan, resize handle, window edge, list action, slider thumb), and Wrong vs Right for the common mistakes above:
cd example
flutter run
License #
MIT — see LICENSE.
