hit 0.3.0 copy "hit: ^0.3.0" to clipboard
hit: ^0.3.0 copied to clipboard

Decouple Flutter layout size from hit-test size, and deliver taps outside parent bounds.

example/lib/main.dart

import 'package:flutter/cupertino.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: HitDemoPage(),
    );
  }
}

class HitDemoPage extends StatefulWidget {
  const HitDemoPage({super.key});

  @override
  State<HitDemoPage> createState() => _HitDemoPageState();
}

class _HitDemoPageState extends State<HitDemoPage> {
  int _iconTaps = 0;
  int _badgeTaps = 0;
  int _paintOnTopTaps = 0;
  int _paintUnderTaps = 0;
  int _chipTaps = 0;
  int _listTaps = 0;
  bool _showHitArea = true;
  bool _useHit = true;
  bool _settingsOpen = false;
  double _sliderValue = 0.45;
  Size _panelSize = const Size(140, 88);
  double _windowWidth = 150;

  @override
  Widget build(BuildContext context) {
    final text = CupertinoTheme.of(context).textTheme;

    return CupertinoPageScaffold(
      child: HitScope(
        child: Stack(
          children: [
            CustomScrollView(
              slivers: [
                SliverPadding(
                  padding: const EdgeInsets.fromLTRB(24, 56, 72, 16),
                  sliver: SliverToBoxAdapter(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'hit',
                          style: text.navLargeTitleTextStyle.copyWith(
                            fontWeight: FontWeight.w700,
                          ),
                        ),
                        const SizedBox(height: 8),
                        Text(
                          'Paint/layout size and hit size are separate. '
                          'Open Settings for Use hit / Show hit areas.',
                          style: text.textStyle.copyWith(
                            color: CupertinoColors.secondaryLabel.resolveFrom(
                              context,
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                SliverPadding(
                  padding: const EdgeInsets.fromLTRB(16, 0, 16, 48),
                  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(
                                  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: 'Hit.defer',
                        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,
                          paint: _DeferPaint.none,
                          onTap: () => setState(() => _badgeTaps++),
                        ),
                      ),
                      _DemoTile(
                        title: 'Hit.defer · paintOnTop',
                        body: _useHit
                            ? 'A cover sits above the badge in the tree. '
                                  'paintOnTop still draws the badge last.'
                            : 'A cover sits above the badge in the tree. '
                                  'Without paintOnTop 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,
                          paint: _DeferPaint.onTop,
                          onTap: () => setState(() => _paintOnTopTaps++),
                          cover: true,
                        ),
                      ),
                      _DemoTile(
                        title: 'Hit.before · paintUnder',
                        body: _useHit
                            ? 'Badge is last in the stack but Hit.before '
                                  'paints it under the whole scope.'
                            : 'Badge is last in the stack — it paints on top.',
                        footer: '$_paintUnderTaps taps',
                        scope: true,
                        child: _OverflowBadge(
                          useHit: _useHit,
                          showHitArea: _showHitArea,
                          paint: _DeferPaint.under,
                          onTap: () => setState(() => _paintUnderTaps++),
                          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: '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,
                          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,
                          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,
                          onChanged: (v) => setState(() => _sliderValue = v),
                        ),
                      ),
                    ]),
                  ),
                ),
              ],
            ),
            if (_settingsOpen)
              Positioned.fill(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTap: () => setState(() => _settingsOpen = false),
                  child: const ColoredBox(color: Color(0x33000000)),
                ),
              ),
            Positioned(
              top: 48,
              right: 16,
              child: _SettingsMenuButton(
                open: _settingsOpen,
                useHit: _useHit,
                showHitArea: _showHitArea,
                onOpenChanged: (v) => setState(() => _settingsOpen = v),
                onUseHitChanged: (v) => setState(() => _useHit = v),
                onShowHitAreaChanged: (v) => setState(() => _showHitArea = v),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Gear + iOS-style popover. Menu hangs outside the button and uses [Hit.defer].
class _SettingsMenuButton extends StatelessWidget {
  const _SettingsMenuButton({
    required this.open,
    required this.useHit,
    required this.showHitArea,
    required this.onOpenChanged,
    required this.onUseHitChanged,
    required this.onShowHitAreaChanged,
  });

  final bool open;
  final bool useHit;
  final bool showHitArea;
  final ValueChanged<bool> onOpenChanged;
  final ValueChanged<bool> onUseHitChanged;
  final ValueChanged<bool> onShowHitAreaChanged;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 44,
      height: 44,
      child: Stack(
        clipBehavior: Clip.none,
        alignment: Alignment.topRight,
        children: [
          CupertinoButton(
            padding: EdgeInsets.zero,
            onPressed: () => onOpenChanged(!open),
            child: Icon(
              CupertinoIcons.gear_solid,
              size: 22,
              color: CupertinoTheme.of(context).primaryColor,
            ),
          ),
          if (open)
            Positioned(
              top: 44,
              right: 0,
              child: Hit.defer(
                paintOnTop: true,
                behavior: HitTestBehavior.opaque,
                child: _SettingsPopover(
                  useHit: useHit,
                  showHitArea: showHitArea,
                  onUseHitChanged: onUseHitChanged,
                  onShowHitAreaChanged: onShowHitAreaChanged,
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _SettingsPopover extends StatelessWidget {
  const _SettingsPopover({
    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 Container(
      width: 260,
      decoration: BoxDecoration(
        color: CupertinoColors.secondarySystemGroupedBackground.resolveFrom(
          context,
        ),
        borderRadius: BorderRadius.circular(14),
        boxShadow: [
          BoxShadow(
            color: CupertinoColors.black.withValues(alpha: 0.18),
            blurRadius: 24,
            offset: const Offset(0, 8),
          ),
        ],
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _SettingsToggle(
            title: 'Use hit',
            subtitle: useHit
                ? 'HitLayer / Hit.defer active'
                : 'Plain Flutter — no hit package',
            value: useHit,
            onChanged: onUseHitChanged,
          ),
          Container(
            height: 0.5,
            color: CupertinoColors.separator.resolveFrom(context),
          ),
          _SettingsToggle(
            title: 'Show hit areas',
            subtitle: showHitArea
                ? 'Green dashed outline = tappable region'
                : 'Hit areas hidden',
            value: showHitArea,
            onChanged: onShowHitAreaChanged,
          ),
        ],
      ),
    );
  }
}

class _SettingsToggle extends StatelessWidget {
  const _SettingsToggle({
    required this.title,
    required this.subtitle,
    required this.value,
    required this.onChanged,
  });

  final String title;
  final String subtitle;
  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final text = CupertinoTheme.of(context).textTheme;
    return Padding(
      padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  title,
                  style: text.textStyle.copyWith(fontWeight: FontWeight.w600),
                ),
                const SizedBox(height: 2),
                Text(
                  subtitle,
                  style: text.tabLabelTextStyle.copyWith(
                    color: CupertinoColors.secondaryLabel.resolveFrom(context),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 8),
          CupertinoSwitch(value: value, onChanged: onChanged),
        ],
      ),
    );
  }
}

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(child: tile);
    }
    return tile;
  }
}

enum _DeferPaint { none, onTop, under }

class _OverflowBadge extends StatelessWidget {
  const _OverflowBadge({
    required this.useHit,
    required this.showHitArea,
    required this.paint,
    required this.onTap,
    this.cover = false,
  });

  final bool useHit;
  final bool showHitArea;
  final _DeferPaint paint;
  final VoidCallback onTap;
  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,
              ),
            ),
            if (showHitArea)
              const IgnorePointer(child: _HitGhost(circular: true)),
          ],
        ),
      ),
    );

    if (useHit) {
      badge = switch (paint) {
        _DeferPaint.onTop => Hit.defer(
          paintOnTop: true,
          behavior: HitTestBehavior.opaque,
          child: badge,
        ),
        _DeferPaint.under => Hit.before(
          behavior: HitTestBehavior.opaque,
          child: badge,
        ),
        _DeferPaint.none => Hit.defer(
          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: Center(
                child: Text(
                  paint == _DeferPaint.under ? 'over badge' : 'cover',
                  style: const TextStyle(
                    color: CupertinoColors.white,
                    fontSize: 12,
                  ),
                ),
              ),
            ),
          )
        : null;

    // paintUnder: badge last in the tree so without Hit.before it paints on top.
    // paintOnTop: badge first so without paintOnTop 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
                  ? (paint == _DeferPaint.under
                        ? 'Foreground card'
                        : 'Covered card')
                  : 'Card',
            ),
          ),
        ),
      ),
      if (paint == _DeferPaint.under) ...[
        ?coverLayer,
        Positioned(right: -12, top: -12, child: badge),
      ] else ...[
        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.onTap,
    this.hitSize = const Size(44, 44),
    this.alignment = Alignment.center,
    this.onPanUpdate,
    this.cursor,
  });

  final bool useHit;
  final bool showHitArea;
  final VoidCallback? onTap;
  final GestureDragUpdateCallback? onPanUpdate;
  final Widget paintChild;
  final Size hitSize;
  final Alignment alignment;
  final MouseCursor? cursor;

  @override
  Widget build(BuildContext context) {
    Widget interact({required Widget child}) {
      final detector = GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: onTap,
        onPanUpdate: onPanUpdate,
        child: child,
      );
      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(
      alignment: alignment,
      behavior: HitTestBehavior.deferToChild,
      hitChild: interact(
        child: SizedBox(
          width: hitSize.width,
          height: hitSize.height,
          child: showHitArea ? const _HitGhost() : null,
        ),
      ),
      paintChild: IgnorePointer(child: paintChild),
    );
  }
}

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,
              onTap: onDismiss,
              hitSize: const Size(44, 44),
              paintChild: Icon(
                CupertinoIcons.xmark_circle_fill,
                size: 18,
                color: primary,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _ResizeHandle extends StatelessWidget {
  const _ResizeHandle({
    required this.useHit,
    required this.showHitArea,
    required this.size,
    required this.onSizeChanged,
  });

  final bool useHit;
  final bool showHitArea;
  final Size size;
  final ValueChanged<Size> onSizeChanged;

  @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,
                    hitSize: const Size(30, 30),
                    // Paint at top-left of hit box → hit expands outside the panel.
                    alignment: Alignment.center,
                    cursor: SystemMouseCursors.resizeUpLeftDownRight,
                    onPanUpdate: (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,
  });

  final bool useHit;
  final bool showHitArea;
  final double width;
  final ValueChanged<double> onWidthChanged;

  @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,
                    hitSize: Size(16, height),
                    // Paint on left of hit box → hit expands outside the window.
                    alignment: Alignment.center,
                    cursor: SystemMouseCursors.resizeLeftRight,
                    onPanUpdate: (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,
            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,
  });

  final bool useHit;
  final bool showHitArea;
  final double value;
  final ValueChanged<double> onChanged;

  @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,
                  hitSize: const Size(44, 44),
                  onTap: null,
                  onPanUpdate: (details) {
                    final next = ((thumbX + details.delta.dx) / trackWidth)
                        .clamp(0.0, 1.0);
                    onChanged(next);
                  },
                  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 _HitGhost extends StatelessWidget {
  const _HitGhost({this.circular = false});

  final bool circular;

  @override
  Widget build(BuildContext context) {
    final color = primaryColor.resolveFrom(context);
    return CustomPaint(
      painter: _HitGhostPainter(
        circular: circular,
        fill: color.withValues(alpha: 0.2),
        stroke: color,
      ),
    );
  }
}

class _HitGhostPainter extends CustomPainter {
  _HitGhostPainter({
    required this.circular,
    required this.fill,
    required this.stroke,
  });

  final bool circular;
  final Color fill;
  final Color stroke;

  @override
  void paint(Canvas canvas, Size size) {
    final shape = circular
        ? RRect.fromRectAndRadius(
            Offset.zero & size,
            Radius.circular(size.shortestSide / 2),
          )
        : RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(4));
    canvas.drawRRect(shape, Paint()..color = fill);
    final paint = Paint()
      ..color = stroke
      ..style = PaintingStyle.stroke
      ..strokeWidth = 0.5;
    final path = Path()..addRRect(shape.deflate(0.6));
    for (final metric in path.computeMetrics()) {
      var d = 0.0;
      while (d < metric.length) {
        canvas.drawPath(
          metric.extractPath(d, (d + 4).clamp(0, metric.length)),
          paint,
        );
        d += 7;
      }
    }
  }

  @override
  bool shouldRepaint(covariant _HitGhostPainter oldDelegate) =>
      oldDelegate.circular != circular ||
      oldDelegate.fill != fill ||
      oldDelegate.stroke != stroke;
}
51
likes
0
points
559
downloads

Publisher

verified publisherzennn.dev

Weekly Downloads

Decouple Flutter layout size from hit-test size, and deliver taps outside parent bounds.

Repository (GitHub)
View/report issues

Topics

#flutter #widget #gestures #hit-testing

License

unknown (license)

Dependencies

flutter

More

Packages that depend on hit