Last active
June 25, 2024 18:44
-
-
Save pskink/ce140525b3c35cd351569d238a73a543 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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