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
- Why
- Install
- Quick start
- The two pieces you need
- Common mistakes
- More patterns
- API reference
- Debugging
- Performance
- Migrating from 1.1.x
- Example app
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).
dependencies:
hit: ^1.2.2import 'package:flutter/material.dart';
import 'package:hit/hit.dart';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.
| 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.
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.
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.
✗ 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.
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.
| 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 |
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),
),
),
),
],
),
),
),
)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.
Stable surface from package:hit/hit.dart:
HitLayer,HitScope/HitScopeState,SliverHitScope/SliverHitScopeState,HitScopeHandleHitDefer,HitDeferPaintHitLink,HitDeferRegistrationdebugPaintHitAreas,paintHitAreaDebugOverlay,ensureHitDevToolsInitialized- Optional
debugLabelonHitLayer/HitDefer/HitScope/SliverHitScope
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).
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.
See Slivers with SliverHitScope above for usage.
HitScope.of/maybeOffindSliverHitScopethe same way they findHitScope.
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
| Value | Paint |
|---|---|
none |
In place (hit only deferred) |
onTop |
After scoped subtree (composited; tracks scroll) |
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 |
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)
import 'package:hit/hit.dart';
debugPaintHitAreas = true; // or enable Flutter DevTools → Debug PaintOverflowing / 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).
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 and Select mode remotely
- browse a hierarchical Hit Scope Tree (
TreeView) of scopes and targets - inspect Hit Layer Details and Hit Analysis for the selection
- 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) - probe a global
(x, y)and explain what would hit / why a tap might miss
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:
./tool/build_devtools.shSee DevTools extensions
and devtools_extensions.
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.
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).
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 runMIT — see LICENSE.
