hit 0.3.0
hit: ^0.3.0 copied to clipboard
Decouple Flutter layout size from hit-test size, and deliver taps outside parent bounds.
hit #
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
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 (Hit.defer / overflowing HitLayer).
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:
| Piece | Role |
|---|---|
HitLayer |
Layout follows paintChild; hitChild can be larger and overflow |
HitScope |
Delivers overflow / out-of-bounds hits to registered targets |
Hit.defer / Hit.before |
Explicit deferred hit (and optional paint) outside parent bounds |
Install #
dependencies:
hit: ^0.3.0
import 'package:flutter/material.dart';
import 'package:hit/hit.dart';
Supported API #
Stable surface from package:hit/hit.dart:
HitLayer,HitScope/HitScopeStateHit.defer/Hit.beforeHitLink,HitDeferRegistration
Render objects (RenderHitLayer, RenderHitScope, RenderHitDefer) are not part of the supported public API.
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. A scope wrapped only around the tiny HitLayer (or a short row the same height as the icon) cannot receive taps on the overflow — parents never hit-test outside that box. 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.
API #
HitLayer #
Two-child render object:
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, the layer registers on the link and local hitTest returns false so Flutter does not clip overflow away. Hits are delivered through HitScope.
Non-overflowing layers stay on the normal local hit path and do not register.
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). - Prefer many small scopes over one app-wide scope — but each scope’s layout box must cover the deferred hit areas it serves (use padding, or a larger panel / page).
- 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.
The scope itself does not cull deferred hits to its size, but parents only hit-test a child inside that child’s layout box. A scope that is smaller than the overflow region will never see those events. Intermediate parents (ClipRect, tight boxes) above the scope can also block the walk.
Deferred hit walk order is newest-first.
Hit.defer / Hit.before #
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: Hit.defer(
behavior: HitTestBehavior.opaque,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onBadgeTap,
child: const CircleAvatar(radius: 18),
),
),
),
],
),
),
),
)
Hit.defer— deferred hit; optionalpaintOnTop: trueto paint after the scoped subtree (uses a composited leader/follower so paint tracks scroll without a fullHitScoperepaint)Hit.before— deferred hit and paint under the scoped subtree vialocalToGlobaleach paint (the scope must repaint when the target scrolls; preferpaintOnTopwhen you need composited scroll tracking)behaviordefaults totranslucent; useopaquewhen a hit should stop further deferred scanning and skip the scoped subtree
Local hitTest is always false; delivery is only via the scope.
HitTestBehavior #
Defaults differ by API and are intentional:
| API | Default |
|---|---|
HitLayer |
opaque |
Hit.defer / Hit.before |
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.
Performance notes #
Deferred hit-testing is O(n) over registered targets on that scope (AABB cached per hit-test pass; transforms are recomputed so scroll stays correct). 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
Hit.deferonly 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.
Example #
Live demo: https://hit-one-snowy.vercel.app/
One-page demo covering HitLayer, Hit.defer / Hit.before, and common controls (chip dismiss, resize handle, window edge, list action, slider thumb):
cd example
flutter run
License #
MIT — see LICENSE.