From facd8ed4d62c38e76393c93e72d200900515e9e2 Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 18 Sep 2021 02:23:52 -0600 Subject: [PATCH 1/3] feat: use clipper to passthrough pointer events --- lib/src/shape.dart | 28 +++++++- lib/src/showcase.dart | 38 +++++++--- lib/src/slide.dart | 159 +++++++++++++++++++++++++++++++++--------- lib/src/utils.dart | 46 ++++++++++-- 4 files changed, 226 insertions(+), 45 deletions(-) diff --git a/lib/src/shape.dart b/lib/src/shape.dart index 0fe3607..4a3570e 100644 --- a/lib/src/shape.dart +++ b/lib/src/shape.dart @@ -7,6 +7,8 @@ abstract class Shape { /// Draw the shape on the specified canvas. void drawOnCanvas(Canvas canvas, Rect rectangle, Paint paint); + + void makePath(Path path, Rect rectangle); } /// A rectangle shape. @@ -23,6 +25,11 @@ class Rectangle extends Shape { void drawOnCanvas(Canvas canvas, Rect rectangle, Paint paint) { canvas.drawRect(rectangle.inflate(spreadRadius), paint); } + + @override + void makePath(Path path, Rect rectangle) { + path.addRect(rectangle); + } } /// A rounded rectangle shape. @@ -41,7 +48,14 @@ class RoundedRectangle extends Shape { @override void drawOnCanvas(Canvas canvas, Rect rectangle, Paint paint) { - canvas.drawRRect(RRect.fromRectAndRadius(rectangle.inflate(spreadRadius), radius), paint); + canvas.drawRRect( + RRect.fromRectAndRadius(rectangle.inflate(spreadRadius), radius), + paint); + } + + @override + void makePath(Path path, Rect rectangle) { + path.addRRect(RRect.fromRectAndRadius(rectangle, radius)); } } @@ -59,6 +73,11 @@ class Oval extends Shape { void drawOnCanvas(Canvas canvas, Rect rectangle, Paint paint) { canvas.drawOval(rectangle.inflate(spreadRadius), paint); } + + @override + void makePath(Path path, Rect rectangle) { + path.addOval(rectangle.inflate(spreadRadius)); + } } /// A circle shape. @@ -76,4 +95,11 @@ class Circle extends Shape { Rect circle = rectangle.inflate(spreadRadius); canvas.drawCircle(circle.center, circle.longestSide / 2, paint); } + + @override + void makePath(Path path, Rect rectangle) { + path.addOval( + Rect.fromCircle(center: rectangle.center, radius: rectangle.width / 2), + ); + } } diff --git a/lib/src/showcase.dart b/lib/src/showcase.dart index 4ecb999..eab7a1f 100644 --- a/lib/src/showcase.dart +++ b/lib/src/showcase.dart @@ -29,7 +29,7 @@ class BubbleShowcase extends StatefulWidget { final bool showCloseButton; // Duration by which delay showcase initialization. - final Duration initialDelay; + final Duration initialDelay; /// Creates a new bubble showcase instance. BubbleShowcase({ @@ -52,13 +52,15 @@ class BubbleShowcase extends StatefulWidget { return true; } SharedPreferences preferences = await SharedPreferences.getInstance(); - bool? result = preferences.getBool('$bubbleShowcaseId.$bubbleShowcaseVersion'); + bool? result = + preferences.getBool('$bubbleShowcaseId.$bubbleShowcaseVersion'); return result == null || result; } } /// The BubbleShowcase state. -class _BubbleShowcaseState extends State with WidgetsBindingObserver { +class _BubbleShowcaseState extends State + with WidgetsBindingObserver { /// The current slide index. int currentSlideIndex = -1; @@ -79,7 +81,11 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse } @override - Widget build(BuildContext context) => widget.child; + Widget build(BuildContext context) => + NotificationListener( + onNotification: processNotification, + child: widget.child, + ); @override void dispose() { @@ -98,8 +104,15 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse }); } + bool processNotification(BubbleShowcaseNotification notif) { + goToNextEntryOrClose(currentSlideIndex + 1); + return true; + } + /// Returns whether the showcasing is finished. - bool get isFinished => currentSlideIndex == -1 || currentSlideIndex == widget.bubbleSlides.length; + bool get isFinished => + currentSlideIndex == -1 || + currentSlideIndex == widget.bubbleSlides.length; /// Allows to go to the next entry (or to close the showcase if needed). void goToNextEntryOrClose(int position) { @@ -111,7 +124,9 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse currentSlideEntry = null; if (widget.doNotReopenOnClose) { SharedPreferences.getInstance().then((preferences) { - preferences.setBool('${widget.bubbleShowcaseId}.${widget.bubbleShowcaseVersion}', false); + preferences.setBool( + '${widget.bubbleShowcaseId}.${widget.bubbleShowcaseVersion}', + false); }); } } else { @@ -135,7 +150,8 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse /// Allows to trigger enter callbacks. void triggerOnEnter() { - if (currentSlideIndex >= 0 && currentSlideIndex < widget.bubbleSlides.length) { + if (currentSlideIndex >= 0 && + currentSlideIndex < widget.bubbleSlides.length) { VoidCallback? callback = widget.bubbleSlides[currentSlideIndex].onEnter; if (callback != null) { callback(); @@ -145,7 +161,8 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse /// Allows to trigger exit callbacks. void triggerOnExit() { - if (currentSlideIndex >= 0 && currentSlideIndex < widget.bubbleSlides.length) { + if (currentSlideIndex >= 0 && + currentSlideIndex < widget.bubbleSlides.length) { VoidCallback? callback = widget.bubbleSlides[currentSlideIndex].onExit; if (callback != null) { callback(); @@ -153,3 +170,8 @@ class _BubbleShowcaseState extends State with WidgetsBindingObse } } } + +/// Notification Used to tell the [BubbleShowcase] to continue the showcase +class BubbleShowcaseNotification extends Notification { + const BubbleShowcaseNotification(); +} diff --git a/lib/src/slide.dart b/lib/src/slide.dart index 3d29808..3ac7e40 100644 --- a/lib/src/slide.dart +++ b/lib/src/slide.dart @@ -7,6 +7,18 @@ import 'package:flutter/material.dart'; /// A function that allows to calculate a position according to a provided size. typedef PositionCalculator = Position Function(Size size); +/// Pointer passthrough mode for the BubbleSlide +enum PassthroughMode { + /// Passes through pointer events inside the highlighted area, interaction events will pass down to children. + /// + /// You will need to dispatch a [BubbleShowcaseNotification] from the children to continue + /// the showcase. Interaction events will NOT continue the showcase. + INSIDE_WITH_NOTIFICATION, + + /// Does not pass through any pointer events (default) + NONE, +} + /// A simple bubble slide that allows to highlight a specific screen zone. abstract class BubbleSlide { /// The slide shape. @@ -21,6 +33,8 @@ abstract class BubbleSlide { /// Triggered when this slide has been exited. final VoidCallback? onExit; + final PassthroughMode passthroughMode; + /// The slide child. final BubbleSlideChild? child; @@ -35,25 +49,63 @@ abstract class BubbleSlide { this.onEnter, this.onExit, this.child, + this.passthroughMode = PassthroughMode.NONE, }); /// Builds the whole slide widget. - Widget build(BuildContext context, BubbleShowcase bubbleShowcase, int currentSlideIndex, void Function(int) goToSlide) { - Position highlightPosition = getHighlightPosition(context, bubbleShowcase, currentSlideIndex); - List children = [ - Positioned.fill( - child: CustomPaint( - painter: OverlayPainter(this, highlightPosition), - ), - ), - ]; + Widget build( + BuildContext context, + BubbleShowcase bubbleShowcase, + int currentSlideIndex, + void Function(int) goToSlide, + ) { + Position highlightPosition = getHighlightPosition( + context, + bubbleShowcase, + currentSlideIndex, + ); + List children; + + switch (passthroughMode) { + case PassthroughMode.NONE: + children = [ + Positioned.fill( + child: CustomPaint( + painter: OverlayPainter(this, highlightPosition), + ), + ), + ]; + break; + case PassthroughMode.INSIDE_WITH_NOTIFICATION: + children = [ + Positioned.fill( + child: ClipPath( + clipper: OverlayClipper(this, highlightPosition), + child: Container( + color: Colors.black54, + ), + ), + ), + ]; + break; + } + + // Add BubbleSlide if (child?.widget != null) { - children.add(child!.build(context, highlightPosition, MediaQuery.of(context).size)); + children.add( + child!.build( + context, + highlightPosition, + MediaQuery.of(context).size, + ), + ); } + // Add counter text int slidesCount = bubbleShowcase.bubbleSlides.length; - Color writeColor = Utils.isColorDark(boxShadow.color) ? Colors.white : Colors.black; + Color writeColor = + Utils.isColorDark(boxShadow.color) ? Colors.white : Colors.black; if (bubbleShowcase.counterText != null) { children.add( Positioned( @@ -61,14 +113,20 @@ abstract class BubbleSlide { left: 0, right: 0, child: Text( - bubbleShowcase.counterText!.replaceAll(':i', (currentSlideIndex + 1).toString()).replaceAll(':n', slidesCount.toString()), - style: Theme.of(context).textTheme.bodyText2!.copyWith(color: writeColor), + bubbleShowcase.counterText! + .replaceAll(':i', (currentSlideIndex + 1).toString()) + .replaceAll(':n', slidesCount.toString()), + style: Theme.of(context) + .textTheme + .bodyText2! + .copyWith(color: writeColor), textAlign: TextAlign.center, ), ), ); } + // Add Close button if (bubbleShowcase.showCloseButton) { children.add(Positioned( top: MediaQuery.of(context).padding.top, @@ -83,16 +141,26 @@ abstract class BubbleSlide { )); } - return GestureDetector( - onTap: () => goToSlide(currentSlideIndex + 1), - child: Stack( + if (passthroughMode == PassthroughMode.INSIDE_WITH_NOTIFICATION) { + return Stack( children: children, - ), - ); + ); + } else { + return GestureDetector( + onTap: () => goToSlide(currentSlideIndex + 1), + child: Stack( + children: children, + ), + ); + } } /// Returns the position to highlight. - Position getHighlightPosition(BuildContext context, BubbleShowcase bubbleShowcase, int currentSlideIndex); + Position getHighlightPosition( + BuildContext context, + BubbleShowcase bubbleShowcase, + int currentSlideIndex, + ); } /// A bubble slide with a position that depends on another widget. @@ -100,6 +168,9 @@ class RelativeBubbleSlide extends BubbleSlide { /// The widget key. final GlobalKey widgetKey; + /// Padding for the highlight area + final int highlightPadding; + /// Creates a new relative bubble slide instance. const RelativeBubbleSlide({ Shape shape = const Rectangle(), @@ -108,24 +179,32 @@ class RelativeBubbleSlide extends BubbleSlide { blurRadius: 0, spreadRadius: 0, ), + passThroughMode = PassthroughMode.NONE, required BubbleSlideChild child, required this.widgetKey, + this.highlightPadding = 0, }) : super( shape: shape, boxShadow: boxShadow, child: child, + passthroughMode: passThroughMode, ); @override - Position getHighlightPosition(BuildContext context, BubbleShowcase bubbleShowcase, int currentSlideIndex) { - RenderBox renderBox = widgetKey.currentContext!.findRenderObject() as RenderBox; + Position getHighlightPosition( + BuildContext context, + BubbleShowcase bubbleShowcase, + int currentSlideIndex, + ) { + RenderBox renderBox = + widgetKey.currentContext!.findRenderObject() as RenderBox; Offset offset = renderBox.localToGlobal(Offset.zero); return Position( - top: offset.dy, - right: offset.dx + renderBox.size.width, - bottom: offset.dy + renderBox.size.height, - left: offset.dx, + top: offset.dy - highlightPadding, + right: offset.dx + renderBox.size.width + highlightPadding, + bottom: offset.dy + renderBox.size.height + highlightPadding, + left: offset.dx - highlightPadding, ); } } @@ -152,7 +231,12 @@ class AbsoluteBubbleSlide extends BubbleSlide { ); @override - Position getHighlightPosition(BuildContext context, BubbleShowcase bubbleShowcase, int currentSlideIndex) => positionCalculator(MediaQuery.of(context).size); + Position getHighlightPosition( + BuildContext context, + BubbleShowcase bubbleShowcase, + int currentSlideIndex, + ) => + positionCalculator(MediaQuery.of(context).size); } /// A bubble slide child, holding a widget. @@ -178,7 +262,11 @@ abstract class BubbleSlideChild { } /// Returns child position according to the highlight position and parent size. - Position getPosition(BuildContext context, Position highlightPosition, Size parentSize); + Position getPosition( + BuildContext context, + Position highlightPosition, + Size parentSize, + ); } /// A bubble slide with a position that depends on the highlight zone. @@ -195,7 +283,11 @@ class RelativeBubbleSlideChild extends BubbleSlideChild { ); @override - Position getPosition(BuildContext context, Position highlightPosition, Size parentSize) { + Position getPosition( + BuildContext context, + Position highlightPosition, + Size parentSize, + ) { switch (direction) { case AxisDirection.up: return Position( @@ -234,10 +326,13 @@ class AbsoluteBubbleSlideChild extends BubbleSlideChild { const AbsoluteBubbleSlideChild({ required Widget widget, required this.positionCalculator, - }) : super( - widget: widget, - ); + }) : super(widget: widget); @override - Position getPosition(BuildContext context, Position highlightPosition, Size parentSize) => positionCalculator(parentSize); + Position getPosition( + BuildContext context, + Position highlightPosition, + Size parentSize, + ) => + positionCalculator(parentSize); } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index adb00de..10ee608 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -30,10 +30,16 @@ class Position { }); @override - String toString() => 'Position(top: $top, right: $right, bottom: $bottom, left: $left)'; + String toString() => + 'Position(top: $top, right: $right, bottom: $bottom, left: $left)'; @override - bool operator ==(Object other) => other is Position && top == other.top && right == other.right && bottom == other.bottom && left == other.left; + bool operator ==(Object other) => + other is Position && + top == other.top && + right == other.right && + bottom == other.bottom && + left == other.left; @override int get hashCode { @@ -59,7 +65,10 @@ class OverlayPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - canvas.saveLayer(Offset.zero & size, Paint()); // Thanks to https://stackoverflow.com/a/51548959. + canvas.saveLayer( + Offset.zero & size, + Paint(), + ); // Thanks to https://stackoverflow.com/a/51548959. canvas.drawColor(_slide.boxShadow.color, BlendMode.dstATop); _slide.shape.drawOnCanvas( canvas, @@ -75,5 +84,34 @@ class OverlayPainter extends CustomPainter { } @override - bool shouldRepaint(OverlayPainter oldOverlay) => oldOverlay._position != _position; + bool shouldRepaint(OverlayPainter oldOverlay) => + oldOverlay._position != _position; +} + +class OverlayClipper extends CustomClipper { + final BubbleSlide _slide; + final Position _position; + + const OverlayClipper(this._slide, this._position); + + @override + Path getClip(Size size) { + final path = Path(); + path.addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + _slide.shape.makePath( + path, + Rect.fromLTRB( + _position.left, + _position.top, + _position.right, + _position.bottom, + ), + ); + path.fillType = PathFillType.evenOdd; + + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; } From 0b79c60e3a015ff840cfca4282226eb1b80595af Mon Sep 17 00:00:00 2001 From: Mario Date: Sat, 18 Sep 2021 02:24:01 -0600 Subject: [PATCH 2/3] chore: update example with passthrough slide --- example/lib/main.dart | 63 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index d4db1a7..3fe2eee 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -28,6 +28,9 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { /// The first button global key. final GlobalKey _firstButtonKey = GlobalKey(); + /// The second button global key. + final GlobalKey _secondButtonKey = GlobalKey(); + @override Widget build(BuildContext context) { TextStyle textStyle = Theme.of(context).textTheme.bodyText2!.copyWith( @@ -40,8 +43,13 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { _firstSlide(textStyle), _secondSlide(textStyle), _thirdSlide(textStyle), + _fourthSlide(textStyle), ], - child: _BubbleShowcaseDemoChild(_titleKey, _firstButtonKey), + child: _BubbleShowcaseDemoChild( + _titleKey, + _firstButtonKey, + _secondButtonKey, + ), ); } @@ -143,6 +151,46 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), ), ); + + /// Creates the fourth slide. + BubbleSlide _fourthSlide(TextStyle textStyle) => RelativeBubbleSlide( + highlightPadding: 4, + passThroughMode: PassthroughMode.INSIDE_WITH_NOTIFICATION, + widgetKey: _secondButtonKey, + shape: const Oval( + spreadRadius: 15, + ), + child: RelativeBubbleSlideChild( + widget: Padding( + padding: const EdgeInsets.only(top: 8), + child: SpeechBubble( + nipLocation: NipLocation.TOP, + color: Colors.blue, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Going through!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + 'Passthrough is on!\nTo finish the tutorial, you need to click this button', + style: textStyle, + ), + ], + ), + ), + ), + ), + ), + ); } /// The main demo widget child. @@ -152,9 +200,11 @@ class _BubbleShowcaseDemoChild extends StatelessWidget { /// The first button global key. final GlobalKey _firstButtonKey; + final GlobalKey _secondButtonKey; /// Creates a new bubble showcase demo child instance. - _BubbleShowcaseDemoChild(this._titleKey, this._firstButtonKey); + _BubbleShowcaseDemoChild( + this._titleKey, this._firstButtonKey, this._secondButtonKey); @override Widget build(BuildContext context) => Padding( @@ -183,8 +233,13 @@ class _BubbleShowcaseDemoChild extends StatelessWidget { ), ), ElevatedButton( - onPressed: () {}, - child: const Text('This button is old, please don\'t pay attention.'), + key: _secondButtonKey, + onPressed: () { + const BubbleShowcaseNotification()..dispatch(context); + }, + child: const Text( + 'This button is old, please don\'t pay attention.', + ), ) ], ), From e6c161587072e72ac609efd719edfc6e51a31c54 Mon Sep 17 00:00:00 2001 From: Mario Mejia <49447170+Termtime@users.noreply.github.com> Date: Wed, 22 Sep 2021 10:32:12 -0600 Subject: [PATCH 3/3] fix: ignore notification if the BubbleShowcase has finished --- lib/src/showcase.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/showcase.dart b/lib/src/showcase.dart index eab7a1f..a3b81a6 100644 --- a/lib/src/showcase.dart +++ b/lib/src/showcase.dart @@ -105,6 +105,7 @@ class _BubbleShowcaseState extends State } bool processNotification(BubbleShowcaseNotification notif) { + if (isFinished) return true; goToNextEntryOrClose(currentSlideIndex + 1); return true; }