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.
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:hit/hit.dart';
const primaryColor = CupertinoColors.destructiveRed;
void main() => runApp(const HitExampleApp());
class HitExampleApp extends StatelessWidget {
const HitExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const CupertinoApp(
title: 'hit example',
debugShowCheckedModeBanner: false,
theme: CupertinoThemeData(primaryColor: primaryColor),
home: HitHome(),
);
}
}
/// Tab shell: Basics demos + SliverHitScope list/grid demos. Shared settings.
class HitHome extends StatefulWidget {
const HitHome({super.key});
@override
State<HitHome> createState() => _HitHomeState();
}
class _HitHomeState extends State<HitHome> {
bool _showHitArea = true;
bool _useHit = true;
@override
void initState() {
super.initState();
debugPaintHitAreas = _showHitArea;
}
void _setShowHitArea(bool value) {
setState(() {
_showHitArea = value;
debugPaintHitAreas = value;
});
}
@override
Widget build(BuildContext context) {
return CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: const [
BottomNavigationBarItem(
icon: Icon(CupertinoIcons.square_grid_2x2),
label: 'Basics',
),
BottomNavigationBarItem(
icon: Icon(CupertinoIcons.list_bullet),
label: 'Slivers',
),
],
),
tabBuilder: (context, index) {
return CupertinoTabView(
builder: (context) {
return switch (index) {
0 => HitDemoPage(
useHit: _useHit,
showHitArea: _showHitArea,
onUseHitChanged: (v) => setState(() => _useHit = v),
onShowHitAreaChanged: _setShowHitArea,
),
_ => SliverHitDemoPage(
useHit: _useHit,
showHitArea: _showHitArea,
onUseHitChanged: (v) => setState(() => _useHit = v),
onShowHitAreaChanged: _setShowHitArea,
),
};
},
);
},
);
}
}
class HitDemoPage extends StatefulWidget {
const HitDemoPage({
super.key,
required this.useHit,
required this.showHitArea,
required this.onUseHitChanged,
required this.onShowHitAreaChanged,
});
final bool useHit;
final bool showHitArea;
final ValueChanged<bool> onUseHitChanged;
final ValueChanged<bool> onShowHitAreaChanged;
@override
State<HitDemoPage> createState() => _HitDemoPageState();
}
class _HitDemoPageState extends State<HitDemoPage> {
int _iconTaps = 0;
int _badgeTaps = 0;
int _paintOnTopTaps = 0;
int _chipTaps = 0;
int _richTextTaps = 0;
int _listTaps = 0;
int _tightWrongTaps = 0;
int _tightRightTaps = 0;
int _clipWrongTaps = 0;
int _clipRightTaps = 0;
int _missingWrongTaps = 0;
int _missingRightTaps = 0;
bool _scrollLocked = false;
double _sliderValue = 0.45;
Size _panelSize = const Size(140, 88);
double _windowWidth = 150;
/// Orphan link — nothing walks it, so deferred hits never fire.
final HitLink _orphanLink = HitLink();
bool get _useHit => widget.useHit;
bool get _showHitArea => widget.showHitArea;
void _setScrollLocked(bool locked) {
if (_scrollLocked == locked) return;
setState(() => _scrollLocked = locked);
}
@override
Widget build(BuildContext context) {
final text = CupertinoTheme.of(context).textTheme;
return CupertinoPageScaffold(
child: HitScope(
debugLabel: 'basics-page',
child: CustomScrollView(
// Scrubbers (slider / edge / handle) lock scroll so the page
// does not steal the gesture on small touch screens.
physics: _scrollLocked
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
CupertinoSliverNavigationBar(
largeTitle: const Text('hit'),
trailing: _SettingsMenuButton(
useHit: widget.useHit,
showHitArea: widget.showHitArea,
onUseHitChanged: widget.onUseHitChanged,
onShowHitAreaChanged: widget.onShowHitAreaChanged,
),
),
SliverHitScope(
debugLabel: 'sliver-hit-scope',
sliver: SliverMainAxisGroup(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
sliver: SliverToBoxAdapter(
child: Text(
'Paint/layout size and hit size are separate. '
'Open Settings for Use hit / Show hit areas. '
'Slivers tab demos SliverHitScope with list and grid.',
style: text.textStyle.copyWith(
color: CupertinoColors.secondaryLabel.resolveFrom(
context,
),
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.85,
),
delegate: SliverChildListDelegate([
_DemoTile(
title: 'HitLayer',
body: _useHit
? 'Icon lays out at 24x24. Hit target is 48x48 — '
'neighbors stay put.'
: 'Same 24x24 icon. Tap target equals paint size — '
'corners outside the glyph miss.',
footer: '$_iconTaps taps',
child: ColoredBox(
color: CupertinoColors.secondarySystemBackground
.resolveFrom(context),
child: Row(
children: [
if (_useHit)
HitLayer(
debugLabel: 'new-item',
alignment: Alignment.center,
behavior: HitTestBehavior.deferToChild,
hitChild: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _iconTaps++),
child: SizedBox(
width: 48,
height: 48,
child: _showHitArea
? const _HitGhost()
: null,
),
),
paintChild: const IgnorePointer(
child: Icon(CupertinoIcons.add, size: 24),
),
)
else
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _iconTaps++),
child: SizedBox(
width: 24,
height: 24,
child: Stack(
fit: StackFit.expand,
children: [
const Icon(
CupertinoIcons.add,
size: 24,
),
if (_showHitArea)
const IgnorePointer(
child: _HitGhost(),
),
],
),
),
),
const SizedBox(width: 8),
const Expanded(child: Text('New item')),
],
),
),
),
_DemoTile(
title: 'HitDefer',
body: _useHit
? 'Badge hangs outside the card. '
'HitScope still delivers the tap.'
: 'Badge hangs outside the card. '
'Taps outside the parent bounds miss.',
footer: '$_badgeTaps taps',
child: _OverflowBadge(
useHit: _useHit,
showHitArea: _showHitArea,
debugLabel: 'card-hit-defer',
paint: HitDeferPaint.none,
onTap: () => setState(() => _badgeTaps++),
),
),
_DemoTile(
title: 'HitDefer · paint: onTop',
body: _useHit
? 'A cover sits above the badge in the tree. '
'HitDeferPaint.onTop still draws the badge last.'
: 'A cover sits above the badge in the tree. '
'Without onTop paint the badge stays buried.',
footer: '$_paintOnTopTaps taps',
// HitScope wraps the whole tile so the hanging badge
// stays inside scope layout (tile padding absorbs -12).
scope: true,
child: _OverflowBadge(
useHit: _useHit,
showHitArea: _showHitArea,
debugLabel: 'card-hit-defer-top',
paint: HitDeferPaint.onTop,
onTap: () => setState(() => _paintOnTopTaps++),
cover: true,
),
),
_DemoTile(
title: 'Chip dismiss',
body: _useHit
? 'Dense chip stays tight; x hit expands to 44x44.'
: 'x is only as big as the glyph — easy to miss.',
footer: '$_chipTaps dismisses',
child: _ChipDismiss(
useHit: _useHit,
showHitArea: _showHitArea,
onDismiss: () => setState(() => _chipTaps++),
),
),
_DemoTile(
title: 'Text.rich · WidgetSpan',
body: _useHit
? 'Inline @mention lays out tight in Text.rich; '
'hit expands past the placeholder.'
: 'Inline @mention hit equals paint — corners miss.',
footer: '$_richTextTaps taps',
child: _RichTextHit(
useHit: _useHit,
showHitArea: _showHitArea,
onTap: () => setState(() => _richTextTaps++),
),
),
_DemoTile(
title: 'Resize handle',
body: _useHit
? 'Drag the tiny grip — hit expands outside the panel.'
: 'Grip hit equals paint — hard to grab and drag.',
footer:
'${_panelSize.width.round()}x${_panelSize.height.round()}',
child: _ResizeHandle(
useHit: _useHit,
showHitArea: _showHitArea,
size: _panelSize,
onDraggingChanged: _setScrollLocked,
onSizeChanged: (s) =>
setState(() => _panelSize = s),
),
),
_DemoTile(
title: 'Window edge',
body: _useHit
? 'Drag the 1px edge — thick hit strip expands outside.'
: 'Only the 1px line receives drag hits.',
footer: '${_windowWidth.round()} wide',
child: _WindowEdge(
useHit: _useHit,
showHitArea: _showHitArea,
width: _windowWidth,
onDraggingChanged: _setScrollLocked,
onWidthChanged: (w) =>
setState(() => _windowWidth = w),
),
),
_DemoTile(
title: 'List row action',
body: _useHit
? 'Trailing icon sits flush; hit expands outward.'
: 'Trailing icon hit equals glyph size.',
footer: '$_listTaps actions',
child: _ListAction(
useHit: _useHit,
showHitArea: _showHitArea,
onTap: () => setState(() => _listTaps++),
),
),
_DemoTile(
title: 'Slider thumb',
body: _useHit
? 'Small thumb for polish; oversized hit for scrubbing.'
: 'Thumb hit equals paint — scrubbing is fiddly.',
footer: '${(_sliderValue * 100).round()}%',
child: _SliderThumb(
useHit: _useHit,
showHitArea: _showHitArea,
value: _sliderValue,
onDraggingChanged: _setScrollLocked,
onChanged: (v) => setState(() => _sliderValue = v),
),
),
]),
),
),
],
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Common mistakes',
style: text.navTitleTextStyle.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Flutter only hit-tests inside a child’s layout box. '
'HitScope can deliver overflow taps — but only if '
'events reach a scope that covers them. Try the '
'corners on Wrong vs Right.',
style: text.textStyle.copyWith(
color: CupertinoColors.secondaryLabel.resolveFrom(
context,
),
),
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 48),
sliver: SliverList.list(
children: [
_MistakeCompare(
title: 'Scope too small',
body:
'Wrapping HitScope only around the 24×24 paint '
'leaves the 48×48 hit outside the scope. Parents '
'never walk there — pad under the scope (or lift '
'it) so the overflow stays inside.',
wrongFooter: '$_tightWrongTaps taps',
rightFooter: '$_tightRightTaps taps',
wrong: _TightScopeDemo(
padded: false,
showHitArea: _showHitArea,
onTap: () => setState(() => _tightWrongTaps++),
),
right: _TightScopeDemo(
padded: true,
showHitArea: _showHitArea,
onTap: () => setState(() => _tightRightTaps++),
),
),
const SizedBox(height: 12),
_MistakeCompare(
title: 'Clip above HitScope',
body:
'ClipRect / a tight box above HitScope blocks the '
'walk before deferred hits run. Put HitScope '
'above the clip, or drop the clip and pad so the '
'hanging control stays inside the scope.',
wrongFooter: '$_clipWrongTaps taps',
rightFooter: '$_clipRightTaps taps',
wrong: _ClipScopeDemo(
clipped: true,
showHitArea: _showHitArea,
onTap: () => setState(() => _clipWrongTaps++),
),
right: _ClipScopeDemo(
clipped: false,
showHitArea: _showHitArea,
onTap: () => setState(() => _clipRightTaps++),
),
),
const SizedBox(height: 12),
_MistakeCompare(
title: 'Missing HitScope',
body:
'Overflowing HitLayer needs a HitScope (or a link '
'owned by one). Wrong uses an orphan HitLink — '
'nothing delivers deferred hits. Right wraps a '
'covering HitScope.',
wrongFooter: '$_missingWrongTaps taps',
rightFooter: '$_missingRightTaps taps',
wrong: _MissingScopeDemo(
link: _orphanLink,
showHitArea: _showHitArea,
onTap: () => setState(() => _missingWrongTaps++),
),
right: _MissingScopeDemo(
link: null,
showHitArea: _showHitArea,
onTap: () => setState(() => _missingRightTaps++),
),
),
],
),
),
],
),
),
);
}
}
/// Demo tab: overflowing hits delivered by [SliverHitScope] inside a
/// [CustomScrollView] — [SliverList] rows and [SliverGrid] cells.
class SliverHitDemoPage extends StatefulWidget {
const SliverHitDemoPage({
super.key,
required this.useHit,
required this.showHitArea,
required this.onUseHitChanged,
required this.onShowHitAreaChanged,
});
final bool useHit;
final bool showHitArea;
final ValueChanged<bool> onUseHitChanged;
final ValueChanged<bool> onShowHitAreaChanged;
@override
State<SliverHitDemoPage> createState() => _SliverHitDemoPageState();
}
class _SliverHitDemoPageState extends State<SliverHitDemoPage> {
final Map<int, int> _listTaps = <int, int>{};
final Map<int, int> _gridTaps = <int, int>{};
@override
Widget build(BuildContext context) {
final text = CupertinoTheme.of(context).textTheme;
final secondary = CupertinoColors.secondaryLabel.resolveFrom(context);
return CupertinoPageScaffold(
child: CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
CupertinoSliverNavigationBar(
largeTitle: const Text('Slivers'),
trailing: _SettingsMenuButton(
useHit: widget.useHit,
showHitArea: widget.showHitArea,
onUseHitChanged: widget.onUseHitChanged,
onShowHitAreaChanged: widget.onShowHitAreaChanged,
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
sliver: SliverToBoxAdapter(
child: Text(
widget.useHit
? 'List and grid sit under SliverHitScope. '
'Trash / icon paint stays small; expanded hits '
'still work while you scroll.'
: 'Same SliverHitScope, but controls use paint-sized '
'hits only — corners outside the glyph miss.',
style: text.textStyle.copyWith(color: secondary),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 8),
sliver: SliverToBoxAdapter(
child: Text(
'SliverList',
style: text.navTitleTextStyle.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
SliverHitScope(
debugLabel: 'slivers-inbox-list',
sliver: SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverList.builder(
itemCount: 24,
itemBuilder: (context, index) {
final taps = _listTaps[index] ?? 0;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _SliverInboxRow(
index: index,
taps: taps,
useHit: widget.useHit,
showHitArea: widget.showHitArea,
onTap: () => setState(() {
_listTaps[index] = taps + 1;
}),
),
);
},
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SliverGrid',
style: text.navTitleTextStyle.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Each cell paints a 24×24 glyph with a 48×48 hit. '
'Neighbors keep their layout.',
style: text.textStyle.copyWith(color: secondary),
),
],
),
),
),
SliverHitScope(
debugLabel: 'slivers-icon-grid',
sliver: SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 48),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
delegate: SliverChildBuilderDelegate((context, index) {
final taps = _gridTaps[index] ?? 0;
return _SliverGridCell(
index: index,
taps: taps,
useHit: widget.useHit,
showHitArea: widget.showHitArea,
onTap: () => setState(() {
_gridTaps[index] = taps + 1;
}),
);
}, childCount: 18),
),
),
),
],
),
);
}
}
class _SliverInboxRow extends StatelessWidget {
const _SliverInboxRow({
required this.index,
required this.taps,
required this.useHit,
required this.showHitArea,
required this.onTap,
});
final int index;
final int taps;
final bool useHit;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final label = CupertinoColors.label.resolveFrom(context);
final secondary = CupertinoColors.secondaryLabel.resolveFrom(context);
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 6, 10),
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemBackground.resolveFrom(context),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Message ${index + 1}',
style: TextStyle(fontWeight: FontWeight.w600, color: label),
),
const SizedBox(height: 2),
Text(
taps == 0 ? 'Tap the trash corner' : '$taps deletes',
style: TextStyle(fontSize: 13, color: secondary),
),
],
),
),
_ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'sliver-inbox-trash-$index',
onTap: onTap,
hitSize: const Size(48, 48),
paintChild: Icon(
CupertinoIcons.trash,
size: 20,
color: CupertinoColors.destructiveRed.resolveFrom(context),
),
),
],
),
);
}
}
class _SliverGridCell extends StatelessWidget {
const _SliverGridCell({
required this.index,
required this.taps,
required this.useHit,
required this.showHitArea,
required this.onTap,
});
final int index;
final int taps;
final bool useHit;
final bool showHitArea;
final VoidCallback onTap;
static const _icons = <IconData>[
CupertinoIcons.heart,
CupertinoIcons.star,
CupertinoIcons.bookmark,
CupertinoIcons.flag,
CupertinoIcons.bell,
CupertinoIcons.tag,
];
@override
Widget build(BuildContext context) {
final secondary = CupertinoColors.secondaryLabel.resolveFrom(context);
final icon = _icons[index % _icons.length];
return Container(
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemBackground.resolveFrom(context),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (useHit)
HitLayer(
debugLabel: 'sliver-grid-icon-$index',
alignment: Alignment.center,
behavior: HitTestBehavior.deferToChild,
hitChild: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: const SizedBox(width: 48, height: 48),
),
paintChild: IgnorePointer(child: Icon(icon, size: 24)),
)
else
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Icon(icon, size: 24),
),
const SizedBox(height: 8),
Text(
taps == 0 ? '#${index + 1}' : '$taps',
style: TextStyle(fontSize: 13, color: secondary),
),
],
),
);
}
}
/// Settings gear that opens a [CupertinoMenuAnchor] with toggle items.
class _SettingsMenuButton extends StatelessWidget {
const _SettingsMenuButton({
required this.useHit,
required this.showHitArea,
required this.onUseHitChanged,
required this.onShowHitAreaChanged,
});
final bool useHit;
final bool showHitArea;
final ValueChanged<bool> onUseHitChanged;
final ValueChanged<bool> onShowHitAreaChanged;
@override
Widget build(BuildContext context) {
return CupertinoMenuAnchor(
consumeOutsideTaps: true,
builder: (context, controller, child) {
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(CupertinoIcons.settings),
);
},
menuChildren: [
CupertinoMenuItem(
subtitle: Text(
useHit
? 'HitLayer / HitDefer active'
: 'Plain Flutter — no hit package',
),
trailing: useHit
? const Icon(CupertinoIcons.check_mark)
: const SizedBox.shrink(),
requestCloseOnActivate: false,
onPressed: () => onUseHitChanged(!useHit),
child: const Text('Use hit'),
),
const CupertinoMenuDivider(),
CupertinoMenuItem(
subtitle: Text(
showHitArea
? 'Dashed overlay + DevTools labels (debugPaintHitAreas)'
: 'Hit areas hidden',
),
trailing: showHitArea
? const Icon(CupertinoIcons.check_mark)
: const SizedBox.shrink(),
requestCloseOnActivate: false,
onPressed: () => onShowHitAreaChanged(!showHitArea),
child: const Text('Show hit areas'),
),
],
);
}
}
class _DemoTile extends StatelessWidget {
const _DemoTile({
required this.title,
required this.body,
required this.footer,
required this.child,
this.scope = false,
});
final String title;
final String body;
final String footer;
final Widget child;
final bool scope;
@override
Widget build(BuildContext context) {
final text = CupertinoTheme.of(context).textTheme;
Widget tile = Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: CupertinoColors.systemBackground.resolveFrom(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: CupertinoColors.separator.resolveFrom(context),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: text.textStyle.copyWith(
fontWeight: FontWeight.w600,
fontSize: 17,
),
),
const SizedBox(height: 6),
Text(
body,
style: text.tabLabelTextStyle.copyWith(
color: CupertinoColors.secondaryLabel.resolveFrom(context),
height: 1.35,
),
),
const SizedBox(height: 16),
Expanded(
child: Align(alignment: Alignment.centerLeft, child: child),
),
const SizedBox(height: 8),
Text(
footer,
style: text.textStyle.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
if (scope) {
tile = HitScope(debugLabel: 'tile-$title', child: tile);
}
return tile;
}
}
class _OverflowBadge extends StatelessWidget {
const _OverflowBadge({
required this.useHit,
required this.showHitArea,
required this.paint,
required this.onTap,
this.debugLabel,
this.cover = false,
});
final bool useHit;
final bool showHitArea;
final HitDeferPaint paint;
final VoidCallback onTap;
final String? debugLabel;
final bool cover;
@override
Widget build(BuildContext context) {
final primary = CupertinoTheme.of(context).primaryColor;
Widget badge = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 36,
height: 36,
child: Stack(
fit: StackFit.expand,
children: [
Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary,
shape: BoxShape.circle,
border: Border.all(color: CupertinoColors.white, width: 3),
),
child: const Icon(
CupertinoIcons.add,
size: 16,
color: CupertinoColors.white,
),
),
// Manual overlay only for plain Flutter; HitDefer paints via
// debugPaintHitAreas on the enclosing HitScope.
if (!useHit && showHitArea)
const IgnorePointer(child: _HitGhost(circular: true)),
],
),
),
);
if (useHit) {
badge = switch (paint) {
HitDeferPaint.onTop => HitDefer(
debugLabel: debugLabel,
paint: HitDeferPaint.onTop,
behavior: HitTestBehavior.opaque,
child: badge,
),
HitDeferPaint.none => HitDefer(
debugLabel: debugLabel,
behavior: HitTestBehavior.opaque,
child: badge,
),
};
}
final coverLayer = cover
? Positioned(
left: 0,
right: 0,
top: 0,
bottom: 28,
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.35),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: const Center(
child: Text(
'cover',
style: TextStyle(color: CupertinoColors.white, fontSize: 12),
),
),
),
)
: null;
// Badge first so without HitDeferPaint.onTop the cover buries it.
final List<Widget> stackChildren = [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: CupertinoColors.separator.resolveFrom(context),
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Text(cover ? 'Covered card' : 'Card'),
),
),
),
Positioned(right: -12, top: -12, child: badge),
?coverLayer,
];
return SizedBox(
width: double.infinity,
height: 110,
child: Stack(clipBehavior: Clip.none, children: stackChildren),
);
}
}
/// Shared HitLayer / plain-Flutter control used by several demos.
class _ExpandHit extends StatelessWidget {
const _ExpandHit({
required this.useHit,
required this.showHitArea,
required this.paintChild,
this.debugLabel,
this.onTap,
this.hitSize = const Size(44, 44),
this.alignment = Alignment.center,
this.drag = _ExpandDrag.none,
this.onDragStart,
this.onDragUpdate,
this.onDragEnd,
this.onDragCancel,
this.cursor,
});
final bool useHit;
final bool showHitArea;
final String? debugLabel;
final VoidCallback? onTap;
final _ExpandDrag drag;
final GestureDragStartCallback? onDragStart;
final GestureDragUpdateCallback? onDragUpdate;
final GestureDragEndCallback? onDragEnd;
final VoidCallback? onDragCancel;
final Widget paintChild;
final Size hitSize;
final Alignment alignment;
final MouseCursor? cursor;
@override
Widget build(BuildContext context) {
Widget interact({required Widget child}) {
Widget detector = child;
if (drag != _ExpandDrag.none) {
// Accept immediately so the page scroll view cannot steal the scrub.
detector = RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
if (drag == _ExpandDrag.horizontal)
_EagerHorizontalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
_EagerHorizontalDragGestureRecognizer
>(() => _EagerHorizontalDragGestureRecognizer(), (
_EagerHorizontalDragGestureRecognizer instance,
) {
instance
..onStart = onDragStart
..onUpdate = onDragUpdate
..onEnd = onDragEnd
..onCancel = onDragCancel;
}),
if (drag == _ExpandDrag.pan)
_EagerPanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
_EagerPanGestureRecognizer
>(() => _EagerPanGestureRecognizer(), (
_EagerPanGestureRecognizer instance,
) {
instance
..onStart = onDragStart
..onUpdate = onDragUpdate
..onEnd = onDragEnd
..onCancel = onDragCancel;
}),
},
child: child,
);
}
if (onTap != null) {
detector = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: detector,
);
} else if (drag == _ExpandDrag.none) {
detector = GestureDetector(
behavior: HitTestBehavior.opaque,
child: detector,
);
}
if (cursor == null) return detector;
return MouseRegion(cursor: cursor!, child: detector);
}
if (!useHit) {
return interact(
child: Stack(
alignment: Alignment.center,
children: [
paintChild,
if (showHitArea)
const Positioned.fill(child: IgnorePointer(child: _HitGhost())),
],
),
);
}
return HitLayer(
debugLabel: debugLabel,
alignment: alignment,
behavior: HitTestBehavior.deferToChild,
hitChild: interact(
child: SizedBox(width: hitSize.width, height: hitSize.height),
),
paintChild: IgnorePointer(child: paintChild),
);
}
}
enum _ExpandDrag { none, pan, horizontal }
/// Wins the arena on pointer down so nested scrollables cannot steal scrubs.
class _EagerPanGestureRecognizer extends PanGestureRecognizer {
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
resolve(GestureDisposition.accepted);
}
}
class _EagerHorizontalDragGestureRecognizer
extends HorizontalDragGestureRecognizer {
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
resolve(GestureDisposition.accepted);
}
}
class _ChipDismiss extends StatelessWidget {
const _ChipDismiss({
required this.useHit,
required this.showHitArea,
required this.onDismiss,
});
final bool useHit;
final bool showHitArea;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final primary = CupertinoTheme.of(context).primaryColor;
return Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 6, 4, 6),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Design',
style: TextStyle(
color: primary,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(width: 2),
_ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'chip-dismiss',
onTap: onDismiss,
hitSize: const Size(44, 44),
paintChild: Icon(
CupertinoIcons.xmark_circle_fill,
size: 18,
color: primary,
),
),
],
),
),
);
}
}
/// [HitLayer] inside [Text.rich] / [WidgetSpan] — layout follows the chip;
/// overflow taps still land via the enclosing [HitScope].
class _RichTextHit extends StatelessWidget {
const _RichTextHit({
required this.useHit,
required this.showHitArea,
required this.onTap,
});
final bool useHit;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final primary = CupertinoTheme.of(context).primaryColor;
final secondary = CupertinoColors.secondaryLabel.resolveFrom(context);
final baseStyle = CupertinoTheme.of(
context,
).textTheme.textStyle.copyWith(fontSize: 15, height: 1.4, color: secondary);
final mention = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'@hit',
style: TextStyle(
color: primary,
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.2,
),
),
);
return Text.rich(
TextSpan(
style: baseStyle,
children: [
const TextSpan(text: 'Ping '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'rich-text-mention',
onTap: onTap,
hitSize: const Size(44, 44),
paintChild: mention,
),
),
const TextSpan(text: ' without growing the line box.'),
],
),
);
}
}
class _ResizeHandle extends StatelessWidget {
const _ResizeHandle({
required this.useHit,
required this.showHitArea,
required this.size,
required this.onSizeChanged,
required this.onDraggingChanged,
});
final bool useHit;
final bool showHitArea;
final Size size;
final ValueChanged<Size> onSizeChanged;
final ValueChanged<bool> onDraggingChanged;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth;
final maxH = constraints.maxHeight.isFinite
? constraints.maxHeight
: 120.0;
final w = size.width.clamp(72.0, maxW);
final h = size.height.clamp(56.0, maxH);
return Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
height: h,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.separator.resolveFrom(context),
),
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('Panel'),
),
),
),
Positioned(
right: 0,
bottom: 0,
child: _ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'resize-handle',
hitSize: const Size(44, 44),
alignment: Alignment.center,
cursor: SystemMouseCursors.resizeUpLeftDownRight,
drag: _ExpandDrag.pan,
onDragStart: (_) => onDraggingChanged(true),
onDragEnd: (_) => onDraggingChanged(false),
onDragCancel: () => onDraggingChanged(false),
onDragUpdate: (details) {
onSizeChanged(
Size(
(w + details.delta.dx).clamp(72.0, maxW),
(h + details.delta.dy).clamp(56.0, maxH),
),
);
},
paintChild: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: CupertinoTheme.of(context).primaryColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(2),
bottomRight: Radius.circular(4),
),
),
),
),
),
],
),
),
);
},
);
}
}
class _WindowEdge extends StatelessWidget {
const _WindowEdge({
required this.useHit,
required this.showHitArea,
required this.width,
required this.onWidthChanged,
required this.onDraggingChanged,
});
final bool useHit;
final bool showHitArea;
final double width;
final ValueChanged<double> onWidthChanged;
final ValueChanged<bool> onDraggingChanged;
@override
Widget build(BuildContext context) {
final border = CupertinoColors.separator.resolveFrom(context);
return LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth;
final w = width.clamp(80.0, maxW);
const height = 96.0;
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: w,
height: height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.systemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border, width: 1),
),
child: const Center(child: Text('Window')),
),
),
Positioned(
top: 0,
bottom: 0,
right: 0,
child: _ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'window-edge',
// Wide enough for a thumb; expands outside the window.
hitSize: const Size(28, height),
alignment: Alignment.center,
cursor: SystemMouseCursors.resizeLeftRight,
drag: _ExpandDrag.horizontal,
onDragStart: (_) => onDraggingChanged(true),
onDragEnd: (_) => onDraggingChanged(false),
onDragCancel: () => onDraggingChanged(false),
onDragUpdate: (details) {
onWidthChanged((w + details.delta.dx).clamp(80.0, maxW));
},
paintChild: Container(
width: 1,
height: height,
color: CupertinoTheme.of(context).primaryColor,
),
),
),
],
),
),
);
},
);
}
}
class _ListAction extends StatelessWidget {
const _ListAction({
required this.useHit,
required this.showHitArea,
required this.onTap,
});
final bool useHit;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Expanded(
child: Text(
'Inbox message',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'list-row-trash',
onTap: onTap,
hitSize: const Size(44, 44),
paintChild: Icon(
CupertinoIcons.trash,
size: 18,
color: CupertinoColors.destructiveRed.resolveFrom(context),
),
),
],
),
);
}
}
class _SliderThumb extends StatelessWidget {
const _SliderThumb({
required this.useHit,
required this.showHitArea,
required this.value,
required this.onChanged,
required this.onDraggingChanged,
});
final bool useHit;
final bool showHitArea;
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<bool> onDraggingChanged;
@override
Widget build(BuildContext context) {
final primary = CupertinoTheme.of(context).primaryColor;
return LayoutBuilder(
builder: (context, constraints) {
final trackWidth = constraints.maxWidth;
final thumbX = value.clamp(0.0, 1.0) * trackWidth;
return SizedBox(
height: 48,
width: double.infinity,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.centerLeft,
children: [
Container(
height: 4,
width: trackWidth,
decoration: BoxDecoration(
color: CupertinoColors.systemGrey4.resolveFrom(context),
borderRadius: BorderRadius.circular(2),
),
),
Container(
height: 4,
width: thumbX,
decoration: BoxDecoration(
color: primary,
borderRadius: BorderRadius.circular(2),
),
),
Positioned(
left: thumbX - 6,
child: _ExpandHit(
useHit: useHit,
showHitArea: showHitArea,
debugLabel: 'slider-thumb',
hitSize: const Size(44, 44),
drag: _ExpandDrag.horizontal,
onDragStart: (_) => onDraggingChanged(true),
onDragEnd: (_) => onDraggingChanged(false),
onDragCancel: () => onDraggingChanged(false),
onDragUpdate: (details) {
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize || trackWidth <= 0) {
return;
}
final local = box.globalToLocal(details.globalPosition);
onChanged((local.dx / trackWidth).clamp(0.0, 1.0));
},
paintChild: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: primary,
shape: BoxShape.circle,
border: Border.all(
color: CupertinoColors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: CupertinoColors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
),
],
),
);
},
);
}
}
class _MistakeCompare extends StatelessWidget {
const _MistakeCompare({
required this.title,
required this.body,
required this.wrong,
required this.right,
required this.wrongFooter,
required this.rightFooter,
});
final String title;
final String body;
final Widget wrong;
final Widget right;
final String wrongFooter;
final String rightFooter;
@override
Widget build(BuildContext context) {
final text = CupertinoTheme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: CupertinoColors.systemBackground.resolveFrom(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: CupertinoColors.separator.resolveFrom(context),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: text.textStyle.copyWith(
fontWeight: FontWeight.w600,
fontSize: 17,
),
),
const SizedBox(height: 6),
Text(
body,
style: text.tabLabelTextStyle.copyWith(
color: CupertinoColors.secondaryLabel.resolveFrom(context),
height: 1.35,
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _MistakeSide(
label: 'Wrong',
ok: false,
footer: wrongFooter,
child: wrong,
),
),
const SizedBox(width: 12),
Expanded(
child: _MistakeSide(
label: 'Right',
ok: true,
footer: rightFooter,
child: right,
),
),
],
),
],
),
);
}
}
class _MistakeSide extends StatelessWidget {
const _MistakeSide({
required this.label,
required this.ok,
required this.footer,
required this.child,
});
final String label;
final bool ok;
final String footer;
final Widget child;
@override
Widget build(BuildContext context) {
final text = CupertinoTheme.of(context).textTheme;
final accent = ok
? CupertinoColors.activeGreen.resolveFrom(context)
: CupertinoColors.destructiveRed.resolveFrom(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: text.tabLabelTextStyle.copyWith(
color: accent,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accent.withValues(alpha: 0.35)),
),
child: child,
),
const SizedBox(height: 8),
Text(
footer,
style: text.textStyle.copyWith(fontWeight: FontWeight.w600),
),
],
);
}
}
/// Shared 24 paint / 48 hit icon used by mistake demos.
class _MistakeIconLayer extends StatelessWidget {
const _MistakeIconLayer({
required this.showHitArea,
required this.onTap,
this.debugLabel,
this.link,
});
final bool showHitArea;
final VoidCallback onTap;
final String? debugLabel;
final HitLink? link;
@override
Widget build(BuildContext context) {
return HitLayer(
debugLabel: debugLabel,
link: link,
alignment: Alignment.center,
behavior: HitTestBehavior.deferToChild,
hitChild: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: const SizedBox(width: 48, height: 48),
),
paintChild: const IgnorePointer(
child: Icon(CupertinoIcons.add, size: 24),
),
);
}
}
class _TightScopeDemo extends StatelessWidget {
const _TightScopeDemo({
required this.padded,
required this.showHitArea,
required this.onTap,
});
final bool padded;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final layer = _MistakeIconLayer(
debugLabel: padded ? 'mistake-tight-ok' : 'mistake-tight-bad',
showHitArea: showHitArea,
onTap: onTap,
);
// Nested HitScope so the page-level scope does not rescue the tight case.
return HitScope(
debugLabel: padded ? 'mistake-tight-scope-ok' : 'mistake-tight-scope-bad',
child: padded
? Padding(padding: const EdgeInsets.all(12), child: layer)
: layer,
);
}
}
class _ClipScopeDemo extends StatelessWidget {
const _ClipScopeDemo({
required this.clipped,
required this.showHitArea,
required this.onTap,
});
final bool clipped;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final card = SizedBox(
width: 72,
height: 72,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6.resolveFrom(context),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: CupertinoColors.separator.resolveFrom(context),
),
),
child: const Padding(
padding: EdgeInsets.all(8),
child: Align(
alignment: Alignment.bottomLeft,
child: Text('Card', style: TextStyle(fontSize: 12)),
),
),
),
),
Positioned(
right: -14,
top: -14,
child: HitDefer(
debugLabel: clipped
? 'mistake-clip-badge-bad'
: 'mistake-clip-badge-ok',
behavior: HitTestBehavior.opaque,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 36,
height: 36,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: CupertinoTheme.of(context).primaryColor,
shape: BoxShape.circle,
border: Border.all(
color: CupertinoColors.white,
width: 2,
),
),
child: const Icon(
CupertinoIcons.add,
size: 14,
color: CupertinoColors.white,
),
),
),
),
),
),
],
),
);
if (clipped) {
// Clip above HitScope — overflow never receives events.
return ClipRect(
child: SizedBox(
width: 72,
height: 72,
child: HitScope(debugLabel: 'mistake-clip-scope-bad', child: card),
),
);
}
// HitScope covers the hanging badge via padding; no clip above.
return HitScope(
debugLabel: 'mistake-clip-scope-ok',
child: Padding(padding: const EdgeInsets.all(16), child: card),
);
}
}
class _MissingScopeDemo extends StatelessWidget {
const _MissingScopeDemo({
required this.link,
required this.showHitArea,
required this.onTap,
});
/// Non-null orphan link = Wrong (nothing walks it). Null = use HitScope.
final HitLink? link;
final bool showHitArea;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final layer = _MistakeIconLayer(
debugLabel: link != null
? 'mistake-missing-scope-bad'
: 'mistake-missing-scope-ok',
link: link,
showHitArea: showHitArea,
onTap: onTap,
);
if (link != null) {
// Deliberately no HitScope on this link — deferred hits never fire.
return Padding(padding: const EdgeInsets.all(12), child: layer);
}
return HitScope(
debugLabel: 'mistake-missing-scope-ok',
child: Padding(padding: const EdgeInsets.all(12), child: layer),
);
}
}
class _HitGhost extends StatelessWidget {
const _HitGhost({this.circular = false});
final bool circular;
@override
Widget build(BuildContext context) {
return CustomPaint(painter: _HitGhostPainter(circular: circular));
}
}
class _HitGhostPainter extends CustomPainter {
_HitGhostPainter({required this.circular});
final bool circular;
@override
void paint(Canvas canvas, Size size) {
final Rect rect = Offset.zero & size;
if (circular) {
final double radius = size.shortestSide / 2;
paintHitAreaDebugOverlay(canvas, rect, radius: radius);
} else {
paintHitAreaDebugOverlay(canvas, rect);
}
}
@override
bool shouldRepaint(covariant _HitGhostPainter oldDelegate) =>
oldDelegate.circular != circular;
}