Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active May 14, 2024 08:59
Show Gist options
  • Save pskink/ce140525b3c35cd351569d238a73a543 to your computer and use it in GitHub Desktop.
Save pskink/ce140525b3c35cd351569d238a73a543 to your computer and use it in GitHub Desktop.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() {
runApp(const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(body: 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();
int index = 0;
void _onTutorialFinished(int index) {
print('exit tutorial, index: $index');
}
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
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 {
final overlayKeys = [
TutorialTooltip(
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),
alignment: Alignment.topRight,
),
TutorialTooltip(
overlayKey: _overlayKey2,
overlayTooltip: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: const Text('Dolore aute mollit non sit pariatur id cupidatat.'),
),
padding: const EdgeInsets.all(16.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
color: Colors.orange,
),
TutorialTooltip(
overlayKey: _overlayKey3,
overlayTooltip: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
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),
alignment: Alignment.bottomLeft,
),
];
final tutorialRoute = PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => Tutorial(
overlayKeys: overlayKeys,
onTutorialFinished: _onTutorialFinished,
index: index,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(begin: const Offset(0, -0.66), end: const Offset(0, 0))
.chain(CurveTween(curve: Curves.ease))
.animate(animation),
child: child,
),
),
opaque: false,
transitionDuration: Durations.long4,
reverseTransitionDuration: Durations.long4,
);
// final tutorialRoute = DialogRoute(
// context: context,
// barrierColor: Colors.transparent,
// builder: (context) {
// return Tutorial(
// overlayKeys: overlayKeys,
// onTutorialFinished: _onTutorialFinished,
// index: index,
// );
// });
index = await Navigator.of(context).push(tutorialRoute);
},
child: const Text('Show Tutorial'),
),
],
),
);
}
class Tutorial 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<TutorialTooltip> 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 Function(int)? onTutorialFinished;
final int index;
const Tutorial({
super.key,
required this.overlayKeys,
this.ancestorKey,
this.onTutorialFinished,
required this.index,
});
@override
State<Tutorial> createState() => _TutorialState();
}
class _TutorialState extends State<Tutorial> {
late int _currentIndex = widget.index;
TutorialTooltip? get _currentTooltip =>
_currentIndex < widget.overlayKeys.length ? widget.overlayKeys[_currentIndex] : null;
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);
final iconColor = tooltipColor.withAlpha(1).withLightness(0.35).toColor();
return AnimatedTutorial(
duration: Durations.long2,
targetRect: nextRenderBox,
shape: _currentTooltip!.shape,
color: _currentTooltip!.color,
alignment: _currentTooltip!.alignment,
padding: _currentTooltip!.padding,
curve: Curves.ease,
child: Material(
color: tooltipColor.withAlpha(1).withLightness(0.75).toColor(),
borderRadius: BorderRadius.circular(6),
elevation: 3,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
InkWell(
onTap: () => _changeIndex(1),
child: Padding(
padding: const EdgeInsets.all(6),
child: AnimatedSize(
duration: Durations.medium2,
curve: Curves.ease,
child: AnimatedSwitcher(
duration: Durations.medium2,
child: KeyedSubtree(
key: UniqueKey(),
child: _currentTooltip!.overlayTooltip,
),
),
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _changeIndex(-1),
visualDensity: VisualDensity.compact,
tooltip: 'previous field',
color: iconColor,
icon: const Icon(Icons.arrow_left),
iconSize: 20,
),
IconButton(
onPressed: () {
Navigator.of(context).pop(_currentIndex);
widget.onTutorialFinished?.call(_currentIndex);
},
visualDensity: VisualDensity.compact,
tooltip: 'exit tutorial',
color: iconColor,
icon: const Icon(Icons.exit_to_app),
iconSize: 20,
),
],
),
],
),
),
);
}
void _changeIndex(int delta) {
setState(() => _currentIndex = (_currentIndex + delta) % widget.overlayKeys.length);
}
}
/// Contains information for drawing a tutorial overlay over a given widget
/// based on the provided global key.
class TutorialTooltip {
/// 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;
final Alignment alignment;
const TutorialTooltip({
required this.overlayKey,
required this.overlayTooltip,
this.padding = EdgeInsets.zero,
this.shape = const RoundedRectangleBorder(),
this.color = const Color(0x90000000), // Black with 50% opacity
this.alignment = Alignment.topCenter,
});
}
class AnimatedTutorial extends ImplicitlyAnimatedWidget {
AnimatedTutorial({
super.key,
required super.duration,
required this.targetRect,
required this.padding,
required ShapeBorder shape,
required Color color,
required this.alignment,
required this.child,
super.curve,
super.onEnd,
}) : decoration = ShapeDecoration(shape: shape, color: color);
final Rect targetRect;
final EdgeInsets padding;
final Decoration decoration;
final Alignment alignment;
final Widget child;
@override
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() {
return _AnimatedTutorialState();
}
}
class _AnimatedTutorialState extends AnimatedWidgetBaseState<AnimatedTutorial> {
RectTween? _targetRect;
EdgeInsetsGeometryTween? _padding;
DecorationTween? _decoration;
AlignmentTween? _alignment;
@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: _targetRect?.evaluate(animation) as Rect,
alignment: _alignment?.evaluate(animation) as Alignment,
),
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?;
_alignment = visitor(_alignment, widget.alignment, (dynamic value) => AlignmentTween(begin: value as Alignment)) as AlignmentTween?;
}
}
class TooltipDelegate extends SingleChildLayoutDelegate {
TooltipDelegate({
required this.targetRect,
required this.alignment,
});
final Rect targetRect;
final Alignment alignment;
final padding = const EdgeInsets.only(top: 6, bottom: 3);
@override
Offset getPositionForChild(Size size, Size childSize) {
assert(size.width - childSize.width >= 0);
assert(size.height - childSize.height >= 0);
final vOffset = Offset(0, childSize.height * alignment.y);
final childRect = alignment.inscribe(childSize, padding.inflateRect(targetRect)).shift(vOffset);
return _clamp(childRect, size, childSize);
}
Offset _clamp(Rect childRect, Size size, Size childSize) {
final position = childRect.topLeft;
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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment