I am trying to get familiar with CustomPainter
s 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).
]
*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.
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;
}
}
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: