flutterdartoverlayflutter-custompaintercustom-painter

Flutter Custom Painter not Repainting


I am trying to get familiar with CustomPainters in Flutter, and tried to use a custom painter to draw a barrier with a cutout around a specific widget over the screen, to behave like a tutorial.

I have a stack with a custom painter and I'm using overlays to overlay a tutorial barrier over the main page. My thought was to have a list of widgets to highlight, with the "tutorial" walking the user widget-by-widget as they click-through, with the painter redrawing to highlight the next target widget or dismissing on the last.

Unfortunately, the custom painter only paints the first variation, and then disappears*. I can confirm through debugPrint statements that the painter is receiving updated bounds, but it just isn't repainting properly (this is true with/without overriding the == and hashCode defs and whether or not shouldRepaint is always set to true or has additional logic within).

GIF displaying expected behavior with help from breakpoints. Clicking on tooltip causes painter to be dismissed and next painter rendered as expected]

*On dartpad.dev, the painter doesn't render at all, even the first one.

I've also tried giving keys to the Stack, the Positioned, and the CustomPaint widgets - no change.

In debug mode, if I set a breakpoint in the paint function of my HolePainter, after hitting play on each repaint and returning to the app, it behaves as expected.

GIF Displaying undesired behavior. Clicking on tooltip causes painter to be dismissed and next painter not rendered.

I'm looking for some help in understanding why the painter isn't re-rendering...

What am I missing in the documentation? Is there a known trick to prompt the expected behavior?

Thank you in advance.

My code snippet:

import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DebugPageTutorial(),
    );
  }
}

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

  @override
  State<DebugPageTutorial> createState() => _DebugPageTutorialState();
}

class _DebugPageTutorialState extends State<DebugPageTutorial> {
  final GlobalKey _overlayKey1 = GlobalKey();
  final GlobalKey _overlayKey2 = GlobalKey();
  final GlobalKey _overlayKey3 = GlobalKey();

  OverlayEntry? currentOverlayEntry;

  @override
  void initState() {
    super.initState();
    currentOverlayEntry = null;
  }

  @override
  void dispose() {
    _onTutorialFinished();
    super.dispose();
  }

  void _onTutorialFinished() {
    if (currentOverlayEntry?.mounted ?? false) currentOverlayEntry?.remove();
    currentOverlayEntry = null;
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                key: _overlayKey1,
                'This is a tutorial page',
                style: const TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                key: _overlayKey2,
                'This is a tutorial page',
              ),
              Padding(
                padding: const EdgeInsets.all(32.0),
                child: Container(
                  key: _overlayKey3,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.cyan,
                  ),
                  height: 50.0,
                  width: 50.0,
                ),
              ),
              MaterialButton(
                onPressed: () async {
                  currentOverlayEntry = TutorialOverlay.createOverlayEntry(
                    context,
                    overlayKeys: [
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey1,
                        overlayTooltip: const Text('Hello'),
                      ),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey2,
                        overlayTooltip: const Text('World'),
                        padding: const EdgeInsets.all(16.0),
                        shapeBorder: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.all(
                            Radius.circular(
                              8.0,
                            ),
                          ),
                        ),
                        color: Colors.green,
                      ),
                      TutorialOverlayTooltip(
                          overlayKey: _overlayKey3,
                          overlayTooltip: const Text('Beep'),
                          shapeBorder: CircleBorder(),
                          padding: const EdgeInsets.all(16.0),
                          color: Colors.red),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey1,
                        overlayTooltip: const Text('Bop'),
                      ),
                    ],
                    onTutorialFinished: _onTutorialFinished,
                  );
                  Overlay.of(context).insert(currentOverlayEntry!);
                },
                child: const Text('Show Tutorial'),
              ),
            ],
          ),
        ),
      );
}

class TutorialOverlay extends StatefulWidget {
  /// A list of keys and overlay tooltips to display when the overlay is
  /// displayed. The overlay will be displayed in the order of the list.
  final List<TutorialOverlayTooltip> overlayKeys;

  /// A global key to use as the ancestor for the overlay entry, ensuring that
  /// the overlay entry is not shifted improperly when the overlay is only being
  /// painted on a portion of the screen. If null, the overlay will be painted
  /// based on the heuristics of the entire screen.
  final GlobalKey? ancestorKey;

  final FutureOr<void> Function()? onTutorialFinished;

  const TutorialOverlay({
    super.key,
    required this.overlayKeys,
    this.ancestorKey,
    this.onTutorialFinished,
  });

  static OverlayEntry createOverlayEntry(
    BuildContext context, {
    required List<TutorialOverlayTooltip> overlayKeys,
    GlobalKey? ancestorKey,
    FutureOr<void> Function()? onTutorialFinished,
  }) =>
      OverlayEntry(
        builder: (BuildContext context) => TutorialOverlay(
          overlayKeys: overlayKeys,
          ancestorKey: ancestorKey,
          onTutorialFinished: onTutorialFinished,
        ),
      );

  @override
  State<TutorialOverlay> createState() => _TutorialOverlayState();
}

class _TutorialOverlayState extends State<TutorialOverlay> {
  late List<TutorialOverlayTooltip> _overlayKeys;
  int _currentIndex = 0;
  TutorialOverlayTooltip? get _currentTooltip =>
      _currentIndex < _overlayKeys.length ? _overlayKeys[_currentIndex] : null;
  final ValueNotifier<int> _repaintKey = ValueNotifier<int>(0);

  @override
  void initState() {
    super.initState();
    _overlayKeys = widget.overlayKeys;
  }

  Rect? _getNextRenderBox(GlobalKey? key) {
    final RenderBox? renderBox =
        key?.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox != null && renderBox.hasSize) {
      final Offset offset = renderBox.localToGlobal(
        Offset.zero,
        ancestor: widget.ancestorKey?.currentContext?.findRenderObject(),
      );
      return Rect.fromLTWH(
        offset.dx,
        offset.dy,
        renderBox.size.width,
        renderBox.size.height,
      );
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final Rect? nextRenderBox = _getNextRenderBox(_currentTooltip?.overlayKey);
    if (nextRenderBox == null) return const SizedBox();

    // Determine position for the tutorial text
    double textTop = nextRenderBox.top > 100
        ? nextRenderBox.top - 40
        : nextRenderBox.bottom + 20;

    debugPrint('build: ${nextRenderBox.toString()}');

    return Stack(
      children: [
        // Paint the background
        Positioned.fill(
          key: ValueKey('tutorial_paint:$_currentIndex'),
          left: 0.0,
          right: 0.0,
          top: 0.0,
          bottom: 0.0,
          child: CustomPaint(
            painter: HolePainter(
              repaint: _repaintKey,
              targetRect: nextRenderBox,
              shapeBorder: _currentTooltip?.shapeBorder ??
                  const RoundedRectangleBorder(),
              color: _currentTooltip?.color ?? const Color(0x90000000),
              direction: _currentTooltip?.direction ?? TextDirection.ltr,
              padding: _currentTooltip?.padding ?? EdgeInsets.zero,
            ),
          ),
        ),
        // Tutorial text box
        Positioned(
          top: textTop,
          left: nextRenderBox.left,
          child: Material(
            color: Colors.transparent,
            child: Container(
              padding: const EdgeInsets.all(8),
              color: Colors.white,
              child: GestureDetector(
                behavior: HitTestBehavior.translucent,
                onTap: () {
                  if (_currentIndex + 1 < _overlayKeys.length) {
                    return setState(() {
                      _currentIndex = _currentIndex + 1;
                      _repaintKey.value = _currentIndex;
                    });
                  }
                  widget.onTutorialFinished?.call();
                },
                child: _currentTooltip?.overlayTooltip ?? const SizedBox(),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

/// Contains information for drawing a tutorial overlay over a given widget
/// based on the provided global key.
class TutorialOverlayTooltip {
  /// The key of the widget to highlight in the cutout of the tutorial overlay
  final GlobalKey overlayKey;

  /// The widget to render by the cutout of the totorial overlay
  final Widget overlayTooltip;

  /// The padding around the widget to render by the cutout of the totorial
  /// overlay. Default is EdgeInsets.zero
  final EdgeInsets padding;

  /// The shape of the cutout of the totorial overlay. Default is a rounded
  /// rectangle with no border radius
  final ShapeBorder shapeBorder;

  /// The color of the barrier of the totorial overlay. Default is
  /// Black with 50% opacity
  final Color color;

  /// The direction of the cutout of the totorial overlay. Default is
  /// [TextDirection.ltr]
  final TextDirection direction;

  const TutorialOverlayTooltip({
    required this.overlayKey,
    required this.overlayTooltip,
    this.padding = EdgeInsets.zero,
    this.shapeBorder = const RoundedRectangleBorder(),
    this.color = const Color(0x90000000), // Black with 50% opacity
    this.direction = TextDirection.ltr,
  });
}

/// A painter that covers the area with a shaped hole around a target box
class HolePainter extends CustomPainter {
  /// The key of the widget to highlight in the cutout of the tutorial overlay
  final ValueNotifier? repaint;

  /// The target rect to paint a hole around
  final Rect targetRect;

  /// The padding around the target rect in the hole
  final EdgeInsets padding;

  /// The shape of the hole to paint around the target rect
  final ShapeBorder shapeBorder;

  /// The color of the barrier that the hole is cut from.
  final Color color;

  /// The direction of the hole. Default is [TextDirection.ltr]
  final TextDirection direction;

  const HolePainter({
    this.repaint,
    required this.targetRect,
    this.shapeBorder =
        const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
    this.color = const Color(0x90000000), // Black with 50% opacity
    this.padding = EdgeInsets.zero,
    this.direction = TextDirection.ltr,
  }): super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
      ..color = color
      ..blendMode = BlendMode.dstOver;

    // Create a padded rectangle from the targetRect using padding
    final Rect paddedRect = Rect.fromLTRB(
      targetRect.left - padding.left,
      targetRect.top - padding.top,
      targetRect.right + padding.right,
      targetRect.bottom + padding.bottom,
    );

    // Create the background path covering the entire canvas
    Path backgroundPath = Path()
      ..addRect(
        Rect.fromLTWH(
          0,
          0,
          size.width,
          size.height,
        ),
      );

    // Create the hole path depending on the shapeBorder
    Path holePath = Path();
    if (shapeBorder is RoundedRectangleBorder) {
      BorderRadiusGeometry borderRadiusGeometry =
          (shapeBorder as RoundedRectangleBorder).borderRadius;
      BorderRadius borderRadius = borderRadiusGeometry.resolve(direction);
      holePath.addRRect(
        RRect.fromRectAndCorners(
          paddedRect,
          topLeft: borderRadius.topLeft,
          topRight: borderRadius.topRight,
          bottomLeft: borderRadius.bottomLeft,
          bottomRight: borderRadius.bottomRight,
        ),
      );
    } else if (shapeBorder is CircleBorder) {
      // Use the smaller side to ensure it fits within the padded rect
      double radius = paddedRect.width < paddedRect.height
          ? paddedRect.width / 2
          : paddedRect.height / 2;
      holePath.addOval(
        Rect.fromCircle(
          center: paddedRect.center,
          radius: radius,
        ),
      );
    } else {
      // Only support RoundedRectangleBorder and CircleBorder for now
      throw Exception('Unsupported shape border type');
    }

    // Combine the paths to create a cut-out effect
    Path combinedPath = Path.combine(
      PathOperation.difference,
      backgroundPath,
      holePath,
    );
    // Draw to the canvas
    canvas.drawPath(
      combinedPath,
      paint,
    );
    debugPrint('paint: ${targetRect.toString()}');
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    if (oldDelegate is HolePainter)
      debugPrint(
        'repaint: ${oldDelegate.targetRect.toString()} => ${targetRect.toString()}',
      );
    return true;
  }
}


Solution

  • Based on @pskink's comments on the thread under the question:

    The custom painter's expected behavior can be better accomplished by altering the painter method (no repaint listenable is needed) and setting state which will cause a repaint of the custom painter. Furthermore, by making use of an ImplicitlyAnimatedWidget, we were able to smooth the jank between the different paints.

    Code below:

    import 'dart:async';
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          debugShowCheckedModeBanner: false,
          home: DebugPageTutorial(),
        );
      }
    }
    
    class DebugPageTutorial extends StatefulWidget {
      const DebugPageTutorial({super.key});
    
      @override
      State<DebugPageTutorial> createState() => _DebugPageTutorialState();
    }
    
    class _DebugPageTutorialState extends State<DebugPageTutorial> {
      final GlobalKey _overlayKey1 = GlobalKey();
      final GlobalKey _overlayKey2 = GlobalKey();
      final GlobalKey _overlayKey3 = GlobalKey();
    
      OverlayEntry? currentOverlayEntry;
    
      @override
      void initState() {
        super.initState();
        currentOverlayEntry = null;
      }
    
      @override
      void dispose() {
        _onTutorialFinished();
        super.dispose();
      }
    
      void _onTutorialFinished() {
        if (currentOverlayEntry?.mounted ?? false) currentOverlayEntry?.remove();
        currentOverlayEntry = null;
      }
    
      @override
      Widget build(BuildContext context) => Scaffold(
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    key: _overlayKey1,
                    'This is a tutorial page',
                    style: const TextStyle(
                      fontSize: 24,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  Text(
                    key: _overlayKey2,
                    'This is a tutorial page',
                  ),
                  Padding(
                    padding: const EdgeInsets.all(32.0),
                    child: Container(
                      key: _overlayKey3,
                      decoration: const BoxDecoration(
                        shape: BoxShape.circle,
                        color: Colors.cyan,
                      ),
                      height: 50.0,
                      width: 50.0,
                    ),
                  ),
                  MaterialButton(
                    onPressed: () async {
                      currentOverlayEntry = TutorialOverlay.createOverlayEntry(
                        context,
                        overlayKeys: [
                          TutorialOverlayTooltip(
                            overlayKey: _overlayKey1,
                            overlayTooltip: ConstrainedBox(
                              constraints: const BoxConstraints(maxWidth: 150),
                              child: const Text('Excepteur irure exercitation consequat esse aute occaecat voluptate nulla minim.'),
                            ),
                            color: Colors.indigo.shade900.withOpacity(0.9),
                          ),
                          TutorialOverlayTooltip(
                            overlayKey: _overlayKey2,
                            overlayTooltip: ConstrainedBox(
                              constraints: const BoxConstraints(maxWidth: 125),
                              child: const Text('Proident qui proident dolore dolor minim voluptate mollit dolore eiusmod nostrud nulla.'),
                            ),
                            padding: const EdgeInsets.all(16.0),
                            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
                            color: Colors.orange,
                          ),
                          TutorialOverlayTooltip(
                            overlayKey: _overlayKey3,
                            overlayTooltip: ConstrainedBox(
                              constraints: const BoxConstraints(maxWidth: 150),
                              child: const Text('Sint elit officia non Lorem magna id.'),
                            ),
                            shape: const CircleBorder(),
                            padding: const EdgeInsets.all(24.0),
                            color: Colors.green.shade900.withOpacity(0.9),
                          ),
                        ],
                        onTutorialFinished: _onTutorialFinished,
                      );
                      Overlay.of(context).insert(currentOverlayEntry!);
                    },
                    child: const Text('Show Tutorial'),
                  ),
                ],
              ),
            ),
          );
    }
    
    class TutorialOverlay extends StatefulWidget {
      /// A list of keys and overlay tooltips to display when the overlay is
      /// displayed. The overlay will be displayed in the order of the list.
      final List<TutorialOverlayTooltip> overlayKeys;
    
      /// A global key to use as the ancestor for the overlay entry, ensuring that
      /// the overlay entry is not shifted improperly when the overlay is only being
      /// painted on a portion of the screen. If null, the overlay will be painted
      /// based on the heuristics of the entire screen.
      final GlobalKey? ancestorKey;
    
      final FutureOr<void> Function()? onTutorialFinished;
    
      const TutorialOverlay({
        super.key,
        required this.overlayKeys,
        this.ancestorKey,
        this.onTutorialFinished,
      });
    
      static OverlayEntry createOverlayEntry(
        BuildContext context, {
        required List<TutorialOverlayTooltip> overlayKeys,
        GlobalKey? ancestorKey,
        FutureOr<void> Function()? onTutorialFinished,
      }) =>
          OverlayEntry(
            builder: (BuildContext context) => TutorialOverlay(
              overlayKeys: overlayKeys,
              ancestorKey: ancestorKey,
              onTutorialFinished: onTutorialFinished,
            ),
          );
    
      @override
      State<TutorialOverlay> createState() => _TutorialOverlayState();
    }
    
    class _TutorialOverlayState extends State<TutorialOverlay> {
      late List<TutorialOverlayTooltip> _overlayKeys;
      int _currentIndex = 0;
      TutorialOverlayTooltip? get _currentTooltip =>
          _currentIndex < _overlayKeys.length ? _overlayKeys[_currentIndex] : null;
    
      @override
      void initState() {
        super.initState();
        _overlayKeys = widget.overlayKeys;
      }
    
      Rect? _getNextRenderBox(GlobalKey? key) {
        final renderBox = key?.currentContext?.findRenderObject() as RenderBox?;
        if (renderBox != null && renderBox.hasSize) {
          final Offset offset = renderBox.localToGlobal(
            Offset.zero,
            ancestor: widget.ancestorKey?.currentContext?.findRenderObject(),
          );
          return offset & renderBox.size;
        }
        return null;
      }
      @override
      Widget build(BuildContext context) {
        final Rect? nextRenderBox = _getNextRenderBox(_currentTooltip?.overlayKey);
        if (nextRenderBox == null) return const SizedBox();
    
        // debugPrint('build: ${nextRenderBox.toString()}');
    
        final tooltipColor = HSLColor.fromColor(_currentTooltip?.color ?? const Color(0x90000000));
        return AnimatedTutorial(
          duration: Durations.long2,
          targetRect: nextRenderBox,
          shape: _currentTooltip?.shape ?? const RoundedRectangleBorder(),
          color: _currentTooltip?.color ?? const Color(0x90000000),
          padding: _currentTooltip?.padding ?? EdgeInsets.zero,
          curve: Curves.ease,
          child: Material(
            color: tooltipColor.withAlpha(1).withLightness(0.75).toColor(),
            borderRadius: BorderRadius.circular(6),
            elevation: 3,
            clipBehavior: Clip.antiAlias,
            child: InkWell(
              splashColor: Colors.white24,
              highlightColor: Colors.transparent,
              onTap: () {
                int newIndex = _currentIndex + 1;
                if(newIndex >= _overlayKeys.length) {
                  widget.onTutorialFinished?.call();
                  return;
                }
                setState(() => _currentIndex = newIndex);
              },
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: AnimatedSize(
                  duration: Durations.medium2,
                  curve: Curves.ease,
                  child: AnimatedSwitcher(
                    duration: Durations.medium2,
                    child: KeyedSubtree(
                      key: UniqueKey(),
                      child: _currentTooltip?.overlayTooltip ?? const SizedBox(),
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      }
    }
    
    /// Contains information for drawing a tutorial overlay over a given widget
    /// based on the provided global key.
    class TutorialOverlayTooltip {
      /// The key of the widget to highlight in the cutout of the tutorial overlay
      final GlobalKey overlayKey;
    
      /// The widget to render by the cutout of the totorial overlay
      final Widget overlayTooltip;
    
      /// The padding around the widget to render by the cutout of the totorial
      /// overlay. Default is EdgeInsets.zero
      final EdgeInsets padding;
    
      /// The shape of the cutout of the totorial overlay. Default is a rounded
      /// rectangle with no border radius
      final ShapeBorder shape;
    
      /// The color of the barrier of the totorial overlay. Default is
      /// Black with 50% opacity
      final Color color;
    
      const TutorialOverlayTooltip({
        required this.overlayKey,
        required this.overlayTooltip,
        this.padding = EdgeInsets.zero,
        this.shape = const RoundedRectangleBorder(),
        this.color = const Color(0x90000000), // Black with 50% opacity
      });
    }
    
    class AnimatedTutorial extends ImplicitlyAnimatedWidget {
      AnimatedTutorial({
        super.key,
        required super.duration,
        required this.targetRect,
        required this.padding,
        required ShapeBorder shape,
        required Color color,
        required this.child,
        super.curve,
      }) : decoration = ShapeDecoration(shape: shape, color: color);
    
      final Rect targetRect;
      final EdgeInsets padding;
      final Decoration decoration;
      final Widget child;
    
      @override
      ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() {
        return _AnimatedTutorialState();
      }
    }
    
    class _AnimatedTutorialState extends AnimatedWidgetBaseState<AnimatedTutorial> {
      RectTween? _targetRect;
      EdgeInsetsGeometryTween? _padding;
      DecorationTween? _decoration;
    
      @override
      Widget build(BuildContext context) {
        // timeDilation = 5; // sloooow motion for testing
        return CustomPaint(
          painter: HolePainter(
            targetRect: _targetRect?.evaluate(animation) as Rect,
            decoration: _decoration?.evaluate(animation) as ShapeDecoration,
            direction: Directionality.of(context),
            padding: _padding?.evaluate(animation) as EdgeInsetsGeometry,
          ),
          child: CustomSingleChildLayout(
            delegate: TooltipDelegate(_targetRect?.evaluate(animation) as Rect),
            child: widget.child,
          ),
        );
      }
    
      @override
      void forEachTween(TweenVisitor<dynamic> visitor) {
        _targetRect = visitor(_targetRect, widget.targetRect, (dynamic value) => RectTween(begin: value as Rect)) as RectTween?;
        _padding = visitor(_padding, widget.padding, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
        _decoration = visitor(_decoration, widget.decoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
      }
    }
    
    class TooltipDelegate extends SingleChildLayoutDelegate {
      TooltipDelegate(this.rect);
    
      final Rect rect;
      final padding = const Offset(0, 6);
    
      @override
      Offset getPositionForChild(Size size, Size childSize) {
        assert(size.width - childSize.width >= 0);
        assert(size.height - childSize.height >= 0);
        final position = rect.topLeft - childSize.bottomLeft(padding);
        return _clamp(position.dy >= 0? position : rect.bottomLeft, size, childSize);
      }
    
      Offset _clamp(Offset position, Size size, Size childSize) {
        return Offset(
          position.dx.clamp(0, size.width - childSize.width),
          position.dy.clamp(0, size.height - childSize.height),
        );
      }
    
      @override
      BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();
    
      @override
      bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => true;
    }
    
    /// A painter that covers the area with a shaped hole around a target box
    class HolePainter extends CustomPainter {
      const HolePainter({
        required this.targetRect,
        required this.decoration,
        required this.padding,
        this.direction = TextDirection.ltr,
      });
    
      /// The target rect to paint a hole around
      final Rect targetRect;
    
      /// The padding around the target rect in the hole
      final EdgeInsetsGeometry padding;
    
      /// The shape decoration of the hole to paint around the target rect
      final ShapeDecoration decoration;
    
      /// The direction of the hole. Default is [TextDirection.ltr]
      final TextDirection direction;
    
      @override
      void paint(Canvas canvas, Size size) {
        final Paint paint = Paint()
          ..color = decoration.color ?? Colors.transparent;
    
        final Rect paddedRect = padding.resolve(direction).inflateRect(targetRect);
        Path path = Path()
          ..fillType = PathFillType.evenOdd
          ..addRect(Offset.zero & size)
          ..addPath(decoration.getClipPath(paddedRect, direction), Offset.zero);
        canvas.drawPath(path, paint);
        // debugPrint('paint: ${targetRect.toString()}');
      }
    
      @override
      bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
    }
    

    The above code works across all platforms and exhibits none of the issues highlighted in the question. It is also available in a Gist at the link below:

    Github Gist by pskink