diff --git a/example/lib/draggableShowcase.dart b/example/lib/draggableShowcase.dart new file mode 100644 index 0000000..99b30df --- /dev/null +++ b/example/lib/draggableShowcase.dart @@ -0,0 +1,288 @@ +import 'package:bubble_showcase/bubble_showcase.dart'; +import 'package:flutter/material.dart'; + +import 'speech_bubble.dart'; + +final middlePointHeight = 0.20; +final middlePointWidth = 0.20; + +final buttonHeight = 80.00; +final buttonWidth = 220.00; + +/// The draggable demo widget +class BubbleShowcaseDraggableWidget extends StatefulWidget { + @override + BubbleShowcaseDraggableWidgetState createState() => + BubbleShowcaseDraggableWidgetState(); +} + +class BubbleShowcaseDraggableWidgetState + extends State { + int tapAmount = 0; + bool enabled = false; + final GlobalKey _draggableSlideKey = GlobalKey(); + + callback() { + setState(() { + enabled = !enabled; + }); + } + + @override + Widget build(BuildContext context) { + TextStyle textStyle = Theme.of(context).textTheme.bodyText2!.copyWith( + color: Colors.white, + ); + return BubbleShowcase( + enabled: enabled, + initialDelay: const Duration(milliseconds: 500), + onDismiss: () { + print('I got dismissed!'); + }, + onEnd: () { + callback(); + }, + bubbleShowcaseId: 'my_bubble_showcase_2', + bubbleShowcaseVersion: 1, + bubbleSlides: [ + _draggableSlide(textStyle), + ], + child: _BubbleShowcaseDraggableChild( + _draggableSlideKey, + callback, + enabled, + ), + ); + } + + BubbleSlide _draggableSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _draggableSlideKey, + child: RelativeBubbleSlideChildBuilder( + middlePointHeight: middlePointHeight, + middlePointWidth: middlePointWidth, + direction: AxisDirection.down, + builder: ( + ctx, + highlightPosition, + slidePosition, + parentSize, + slideAlignment, + slideDirection, + ) { + NipLocation getNipLocation( + Alignment alignment, + AxisDirection direction, + ) { + if (alignment == Alignment.topLeft) { + return NipLocation.TOP_LEFT; + } else if (alignment == Alignment.topRight) { + return NipLocation.TOP_RIGHT; + } else if (alignment == Alignment.bottomLeft) { + return NipLocation.BOTTOM_LEFT; + } else if (alignment == Alignment.bottomRight) { + return NipLocation.BOTTOM_RIGHT; + } else if (alignment == Alignment.centerLeft) { + return NipLocation.LEFT; + } else if (alignment == Alignment.centerRight) { + return NipLocation.RIGHT; + } else { + switch (direction) { + case AxisDirection.up: + return NipLocation.BOTTOM; + case AxisDirection.right: + return NipLocation.RIGHT; + case AxisDirection.down: + return NipLocation.TOP; + case AxisDirection.left: + return NipLocation.LEFT; + } + } + } + + return Padding( + padding: const EdgeInsets.all(8.0), + child: SpeechBubble( + color: Colors.blue, + nipLocation: getNipLocation(slideAlignment, slideDirection), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Example Slide', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon( + Icons.info_outline, + color: Colors.white, + ), + ), + Flexible( + child: Text( + 'Example of advanced positioning system.', + style: textStyle, + ), + ) + ], + ), + ], + ), + ), + ), + ); + }, + ), + ); +} + +class _BubbleShowcaseDraggableChild extends StatefulWidget { + /// The first button global key. + final GlobalKey _draggableSlideKey; + final VoidCallback callback; + final bool enabled; + + /// Creates a new bubble showcase demo child instance. + _BubbleShowcaseDraggableChild( + this._draggableSlideKey, + this.callback, + this.enabled, + ); + + @override + _BubbleShowcaseDraggableChildState createState() => + _BubbleShowcaseDraggableChildState(); +} + +class _BubbleShowcaseDraggableChildState + extends State<_BubbleShowcaseDraggableChild> { + Offset? _position; + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final debugLines = [ + // Extreme zone Left (Vertical) + Positioned( + left: MediaQuery.of(context).size.width * 0.05, + top: 0, + bottom: MediaQuery.of(context).size.height * 0.055, + child: const VerticalDivider( + thickness: 5, + color: Colors.red, + ), + ), + // Extreme zone Right (Vertical) + Positioned( + right: MediaQuery.of(context).size.width * 0.05, + top: 0, + bottom: MediaQuery.of(context).size.height * 0.055, + child: const VerticalDivider( + thickness: 5, + color: Colors.red, + ), + ), + // Extreme zone Bottom (Horizontal) + Positioned( + bottom: MediaQuery.of(context).size.height * 0.05, + left: MediaQuery.of(context).size.width * 0.055, + right: MediaQuery.of(context).size.width * 0.055, + child: const Divider( + thickness: 5, + color: Colors.red, + ), + ), + // Center zone left (Vertical) + Positioned( + top: 0, + bottom: MediaQuery.of(context).size.height * 0.055, + right: MediaQuery.of(context).size.width * (middlePointWidth + 0.5), + child: const VerticalDivider( + color: Colors.blue, + thickness: 5, + ), + ), + // Center zone right (Vertical) + Positioned( + top: 0, + bottom: MediaQuery.of(context).size.height * 0.055, + left: MediaQuery.of(context).size.width * (middlePointWidth + 0.5), + child: const VerticalDivider( + color: Colors.blue, + thickness: 5, + ), + ), + // Center zone bottom (Horizontal) + Positioned( + bottom: MediaQuery.of(context).size.height * (middlePointHeight), + left: MediaQuery.of(context).size.width * 0.055, + right: MediaQuery.of(context).size.width * 0.055, + child: const Divider( + color: Colors.blue, + thickness: 5, + ), + ), + // Center zone top (Horizontal) + Positioned( + bottom: + MediaQuery.of(context).size.height * (middlePointHeight + 0.5), + left: MediaQuery.of(context).size.width * 0.055, + right: MediaQuery.of(context).size.width * 0.055, + child: const Divider( + color: Colors.blue, + thickness: 5, + ), + ) + ]; + + final button = Container( + width: buttonWidth, + height: buttonHeight, + child: ElevatedButton( + onPressed: () { + widget.callback(); + }, + child: const Text( + 'Drag this button to position it\nClick to see the showcase slide', + ), + ), + ); + return Stack( + children: [ + ...debugLines, + Positioned( + left: MediaQuery.of(context).size.width / 2 - 80, + child: Text('Tutorial enabled? ${widget.enabled}'), + ), + Positioned( + left: _position != null + ? _position!.dx + : constraints.maxWidth / 2 - buttonWidth / 2, + top: _position != null + ? _position!.dy + : constraints.maxHeight / 2 - buttonHeight / 2, + child: Draggable( + key: widget._draggableSlideKey, + feedback: button, + onDraggableCanceled: (velocity, offset) => { + setState(() { + _position = Offset(offset.dx, offset.dy - 104); + }) + }, + child: button, + ), + ), + ], + ); + }, + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 3fe2eee..fb6b50f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:bubble_showcase/bubble_showcase.dart'; +import 'package:bubble_showcase_example/draggableShowcase.dart'; import 'package:flutter/material.dart'; import 'speech_bubble.dart'; @@ -11,11 +12,25 @@ class _BubbleShowcaseDemoApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( title: 'Bubble Showcase Demo', - home: Scaffold( - appBar: AppBar( - title: const Text('Bubble Showcase Demo'), + home: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Bubble Showcase Demo'), + bottom: const TabBar( + tabs: [ + const Tab(text: 'Advanced positioning demo'), + const Tab(text: 'Demo'), + ], + ), + ), + body: TabBarView( + children: [ + BubbleShowcaseDraggableWidget(), + _BubbleShowcaseDemoWidget(), + ], + ), ), - body: _BubbleShowcaseDemoWidget(), ), ); } @@ -23,13 +38,17 @@ class _BubbleShowcaseDemoApp extends StatelessWidget { /// The main demo widget. class _BubbleShowcaseDemoWidget extends StatelessWidget { /// The title text global key. - final GlobalKey _titleKey = GlobalKey(); + final GlobalKey _firstSlideKey = GlobalKey(); /// The first button global key. - final GlobalKey _firstButtonKey = GlobalKey(); + final GlobalKey _secondSlideKey = GlobalKey(); /// The second button global key. - final GlobalKey _secondButtonKey = GlobalKey(); + final GlobalKey _thirdSlideKey = GlobalKey(); + final GlobalKey _fourthSlideKey = GlobalKey(); + final GlobalKey _fifthSlideKey = GlobalKey(); + final GlobalKey _sixthSlideKey = GlobalKey(); + final GlobalKey _seventhSlideKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -37,6 +56,13 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { color: Colors.white, ); return BubbleShowcase( + initialDelay: const Duration(milliseconds: 500), + onDismiss: () { + print('I got dismissed!'); + }, + onEnd: () { + print('Tutorial has ended!'); + }, bubbleShowcaseId: 'my_bubble_showcase', bubbleShowcaseVersion: 1, bubbleSlides: [ @@ -44,18 +70,32 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { _secondSlide(textStyle), _thirdSlide(textStyle), _fourthSlide(textStyle), + _fifthSlide(textStyle), + _sixthSlide(textStyle), + _seventhSlide(textStyle), + _absoluteSlide(textStyle), ], child: _BubbleShowcaseDemoChild( - _titleKey, - _firstButtonKey, - _secondButtonKey, + _firstSlideKey, + _secondSlideKey, + _thirdSlideKey, + _fourthSlideKey, + _fifthSlideKey, + _sixthSlideKey, + _seventhSlideKey, ), ); } /// Creates the first slide. BubbleSlide _firstSlide(TextStyle textStyle) => RelativeBubbleSlide( - widgetKey: _titleKey, + onEnter: () { + print('OnEnter function!'); + }, + onExit: () { + print('OnExit function!'); + }, + widgetKey: _firstSlideKey, child: RelativeBubbleSlideChild( widget: Padding( padding: const EdgeInsets.only(top: 8), @@ -69,14 +109,14 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - 'That\'s cool !', + 'Hello World!', style: textStyle.copyWith( fontSize: 18, fontWeight: FontWeight.bold, ), ), Text( - 'This is my brand new title !', + 'BubbleShowcase lets you create step by step showcase of your features', style: textStyle, ), ], @@ -87,8 +127,14 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), ); - /// Creates the second slide. - BubbleSlide _secondSlide(TextStyle textStyle) => AbsoluteBubbleSlide( + /// Creates the absolute slide. + BubbleSlide _absoluteSlide(TextStyle textStyle) => AbsoluteBubbleSlide( + onEnter: () { + print('OnEnter function!'); + }, + onExit: () { + print('OnExit function!'); + }, positionCalculator: (size) => Position( top: 0, right: 0, @@ -102,9 +148,9 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { nipLocation: NipLocation.LEFT, color: Colors.teal, child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(20), child: Text( - 'Look at me pointing absolutely nothing.\n(Or maybe that\'s an hidden navigation bar !)', + 'Look at me pointing absolutely nothing.\n(Or maybe that\'s a hidden navigation bar!)', style: textStyle, ), ), @@ -114,35 +160,32 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), ); - /// Creates the third slide. - BubbleSlide _thirdSlide(TextStyle textStyle) => RelativeBubbleSlide( - widgetKey: _firstButtonKey, - shape: const Oval( - spreadRadius: 15, - ), + /// Creates the second slide. + BubbleSlide _secondSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _secondSlideKey, child: RelativeBubbleSlideChild( + direction: AxisDirection.down, widget: Padding( - padding: const EdgeInsets.only(top: 23), + padding: const EdgeInsets.only(top: 8.0), child: SpeechBubble( nipLocation: NipLocation.TOP, - color: Colors.purple, + color: Colors.blue, child: Padding( padding: const EdgeInsets.all(10), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Padding( - padding: EdgeInsets.only(right: 5), - child: Icon( - Icons.info_outline, - color: Colors.white, + Text( + 'Second slide!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, ), ), - Expanded( - child: Text( - 'As said, this button is new.\nOh, and this one is oval by the way.', - style: textStyle, - ), + const SizedBox(height: 10), + Text( + 'This slide uses the default positioning which will center the container\'s content within the dimensions of the highlighted box.', + style: textStyle, ), ], ), @@ -152,19 +195,17 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), ); - /// Creates the fourth slide. - BubbleSlide _fourthSlide(TextStyle textStyle) => RelativeBubbleSlide( + /// Creates the third slide. + BubbleSlide _thirdSlide(TextStyle textStyle) => RelativeBubbleSlide( highlightPadding: 4, passThroughMode: PassthroughMode.INSIDE_WITH_NOTIFICATION, - widgetKey: _secondButtonKey, - shape: const Oval( - spreadRadius: 15, - ), + widgetKey: _thirdSlideKey, child: RelativeBubbleSlideChild( + direction: AxisDirection.down, widget: Padding( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(8.0), child: SpeechBubble( - nipLocation: NipLocation.TOP, + nipLocation: NipLocation.TOP_LEFT, color: Colors.blue, child: Padding( padding: const EdgeInsets.all(10), @@ -173,7 +214,7 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Going through!', + 'Click me to continue!', style: textStyle.copyWith( fontSize: 18, fontWeight: FontWeight.bold, @@ -181,7 +222,7 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), const SizedBox(height: 10), Text( - 'Passthrough is on!\nTo finish the tutorial, you need to click this button', + 'This slide is on the top left with `enableExtraSpace = true`\nWhen this is enabled it will automatically expand to the side with the most space, to expand further than the highlighted area\'s dimentions.\nThere is also some highlight padding on this one.\n\nAlso passthrough mode is on so you can now interact with the button.\nTo continue the tutorial, you need to click this button.', style: textStyle, ), ], @@ -191,20 +232,216 @@ class _BubbleShowcaseDemoWidget extends StatelessWidget { ), ), ); + + BubbleSlide _fourthSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _fourthSlideKey, + child: RelativeBubbleSlideChild( + direction: AxisDirection.down, + widget: Padding( + padding: const EdgeInsets.all(8.0), + child: SpeechBubble( + color: Colors.blue, + nipLocation: NipLocation.TOP_RIGHT, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Fourth Slide!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon( + Icons.info_outline, + color: Colors.white, + ), + ), + Text( + 'Another example of the automatic resizing.\n\nThis one is on the top right and it will expand to the bottom left if needed!\n\nNote that the positioning is assisted by an `Alignment.topRight`', + style: textStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + + BubbleSlide _fifthSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _fifthSlideKey, + child: RelativeBubbleSlideChild( + direction: AxisDirection.up, + widget: Padding( + padding: const EdgeInsets.all(8.0), + child: SpeechBubble( + nipLocation: NipLocation.BOTTOM_RIGHT, + color: Colors.purple, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Fifth Slide!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon( + Icons.info_outline, + color: Colors.white, + ), + ), + Text( + 'Another example of the automatic resizing.\n\nThis is one is on bottom right and it will expand to the top left if needed!\n Note the MainAxisSize.min on both the column and row to shrinkwrap the content', + style: textStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + + BubbleSlide _sixthSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _sixthSlideKey, + shape: const Oval( + spreadRadius: 15, + ), + child: RelativeBubbleSlideChild( + direction: AxisDirection.up, + widget: Padding( + padding: const EdgeInsets.all(16.0), + child: SpeechBubble( + nipLocation: NipLocation.BOTTOM_LEFT, + color: Colors.purple, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Sixth slide!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon( + Icons.info_outline, + color: Colors.white, + ), + ), + Text( + 'Another example of the automatic resizing.\n\nOh, and this one is oval by the way.', + style: textStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + + BubbleSlide _seventhSlide(TextStyle textStyle) => RelativeBubbleSlide( + widgetKey: _seventhSlideKey, + child: RelativeBubbleSlideChild( + direction: AxisDirection.left, + widget: Padding( + padding: const EdgeInsets.all(8.0), + child: SpeechBubble( + color: Colors.blue, + nipLocation: NipLocation.RIGHT, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Center positioned!', + style: textStyle.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 5), + child: Icon( + Icons.info_outline, + color: Colors.white, + ), + ), + Flexible( + child: Text( + 'Another example of the automatic resizing. This one will try to expand to the left, top and bottom, it still is limited vertically, but it is bigger than its highlighted area', + style: textStyle, + ), + ) + ], + ), + ], + ), + ), + ), + ), + ), + ); } /// The main demo widget child. class _BubbleShowcaseDemoChild extends StatelessWidget { /// The title text global key. - final GlobalKey _titleKey; + final GlobalKey _firstSlideKey; /// The first button global key. - final GlobalKey _firstButtonKey; - final GlobalKey _secondButtonKey; + final GlobalKey _secondSlideKey; + final GlobalKey _thirdSlideKey; + final GlobalKey _fourthSlideKey; + final GlobalKey _fifthSlideKey; + final GlobalKey _sixthSlideKey; + final GlobalKey _seventhSlideKey; /// Creates a new bubble showcase demo child instance. _BubbleShowcaseDemoChild( - this._titleKey, this._firstButtonKey, this._secondButtonKey); + this._firstSlideKey, + this._secondSlideKey, + this._thirdSlideKey, + this._fourthSlideKey, + this._fifthSlideKey, + this._sixthSlideKey, + this._seventhSlideKey, + ); @override Widget build(BuildContext context) => Padding( @@ -219,7 +456,7 @@ class _BubbleShowcaseDemoChild extends StatelessWidget { width: MediaQuery.of(context).size.width, child: Text( 'Bubble Showcase', - key: _titleKey, + key: _firstSlideKey, style: Theme.of(context).textTheme.headline4, textAlign: TextAlign.center, ), @@ -227,19 +464,59 @@ class _BubbleShowcaseDemoChild extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 30, bottom: 5), child: ElevatedButton( - key: _firstButtonKey, + key: _secondSlideKey, onPressed: () {}, child: const Text('This button is NEW !'), ), ), - ElevatedButton( - key: _secondButtonKey, - onPressed: () { - const BubbleShowcaseNotification()..dispatch(context); - }, - child: const Text( - 'This button is old, please don\'t pay attention.', - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + key: _thirdSlideKey, + onPressed: () { + const BubbleShowcaseNotification()..dispatch(context); + }, + child: const Text( + 'This button is to the left', + ), + ), + ElevatedButton( + key: _fourthSlideKey, + onPressed: () {}, + child: const Text( + 'This button is to the right', + ), + ), + ], + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + key: _seventhSlideKey, + onPressed: () {}, + child: const Text( + 'This button is on the center', + ), + ), + ], + ), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + key: _sixthSlideKey, + child: const Text('This text is to the left'), + ), + Container( + key: _fifthSlideKey, + child: const Text('This text is to the right'), + ) + ], ) ], ), diff --git a/lib/src/showcase.dart b/lib/src/showcase.dart index 401452f..bb10c23 100644 --- a/lib/src/showcase.dart +++ b/lib/src/showcase.dart @@ -34,6 +34,14 @@ class BubbleShowcase extends StatefulWidget { /// Wether this showcase should be presented. final bool enabled; + /// Handler that executes when the showcase is dismissed by the close icon + final VoidCallback? onDismiss; + + /// Handler that will execute when the showcase finishes + /// + /// Note that this handler will also execute when `onDismiss` is triggered + final VoidCallback? onEnd; + /// Creates a new bubble showcase instance. BubbleShowcase({ required this.bubbleShowcaseId, @@ -45,6 +53,8 @@ class BubbleShowcase extends StatefulWidget { this.showCloseButton = true, this.initialDelay = Duration.zero, this.enabled = true, + this.onDismiss, + this.onEnd, }) : assert(bubbleSlides.isNotEmpty); @override @@ -90,11 +100,28 @@ class _BubbleShowcaseState extends State } @override - Widget build(BuildContext context) => - NotificationListener( - onNotification: processNotification, - child: widget.child, - ); + void didUpdateWidget(BubbleShowcase oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.enabled != widget.enabled) { + WidgetsBinding.instance?.addPostFrameCallback((_) async { + if (await widget.shouldOpenShowcase) { + await Future.delayed(widget.initialDelay); + if (mounted) { + goToNextEntryOrClose(0); + } + } + }); + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: processNotification, + child: widget.child, + ); + } @override void dispose() { @@ -131,6 +158,9 @@ class _BubbleShowcaseState extends State triggerOnExit(); if (isFinished) { + if (widget.onEnd != null) { + widget.onEnd!(); + } currentSlideEntry = null; if (widget.doNotReopenOnClose) { SharedPreferences.getInstance().then((preferences) { @@ -146,6 +176,19 @@ class _BubbleShowcaseState extends State } } + void close() { + currentSlideEntry?.remove(); + triggerOnExit(); + currentSlideEntry = null; + if (widget.doNotReopenOnClose) { + SharedPreferences.getInstance().then((preferences) { + preferences.setBool( + '${widget.bubbleShowcaseId}.${widget.bubbleShowcaseVersion}', + false); + }); + } + } + /// Creates the current slide entry. OverlayEntry createCurrentSlideEntry() => OverlayEntry( builder: (context) => widget.bubbleSlides[currentSlideIndex].build( @@ -155,6 +198,7 @@ class _BubbleShowcaseState extends State (position) { setState(() => goToNextEntryOrClose(position)); }, + close, ), ); @@ -171,9 +215,10 @@ class _BubbleShowcaseState extends State /// Allows to trigger exit callbacks. void triggerOnExit() { - if (currentSlideIndex >= 0 && - currentSlideIndex < widget.bubbleSlides.length) { - VoidCallback? callback = widget.bubbleSlides[currentSlideIndex].onExit; + if (currentSlideIndex > 0 && + currentSlideIndex <= widget.bubbleSlides.length) { + VoidCallback? callback = + widget.bubbleSlides[currentSlideIndex - 1].onExit; if (callback != null) { callback(); } diff --git a/lib/src/slide.dart b/lib/src/slide.dart index 3ac7e40..4a3306c 100644 --- a/lib/src/slide.dart +++ b/lib/src/slide.dart @@ -2,6 +2,7 @@ import 'package:bubble_showcase/src/shape.dart'; import 'package:bubble_showcase/src/showcase.dart'; import 'package:bubble_showcase/src/utils.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// A function that allows to calculate a position according to a provided size. @@ -19,6 +20,14 @@ enum PassthroughMode { NONE, } +enum Quadrant { + TOP_LEFT, + TOP_RIGHT, + CENTER, + BOTTOM_LEFT, + BOTTOM_RIGHT, +} + /// A simple bubble slide that allows to highlight a specific screen zone. abstract class BubbleSlide { /// The slide shape. @@ -31,9 +40,11 @@ abstract class BubbleSlide { final VoidCallback? onEnter; /// Triggered when this slide has been exited. + /// + /// Also triggered when onDismissed is called on the BubbleShowcase final VoidCallback? onExit; - final PassthroughMode passthroughMode; + final PassthroughMode passThroughMode; /// The slide child. final BubbleSlideChild? child; @@ -49,7 +60,7 @@ abstract class BubbleSlide { this.onEnter, this.onExit, this.child, - this.passthroughMode = PassthroughMode.NONE, + this.passThroughMode = PassthroughMode.NONE, }); /// Builds the whole slide widget. @@ -58,6 +69,7 @@ abstract class BubbleSlide { BubbleShowcase bubbleShowcase, int currentSlideIndex, void Function(int) goToSlide, + VoidCallback close, ) { Position highlightPosition = getHighlightPosition( context, @@ -67,7 +79,7 @@ abstract class BubbleSlide { List children; - switch (passthroughMode) { + switch (passThroughMode) { case PassthroughMode.NONE: children = [ Positioned.fill( @@ -92,7 +104,7 @@ abstract class BubbleSlide { } // Add BubbleSlide - if (child?.widget != null) { + if (child?.widget != null || child?.builder != null) { children.add( child!.build( context, @@ -109,7 +121,7 @@ abstract class BubbleSlide { if (bubbleShowcase.counterText != null) { children.add( Positioned( - bottom: 5, + bottom: MediaQuery.of(context).padding.bottom + 5, left: 0, right: 0, child: Text( @@ -129,10 +141,15 @@ abstract class BubbleSlide { // Add Close button if (bubbleShowcase.showCloseButton) { children.add(Positioned( - top: MediaQuery.of(context).padding.top, + top: MediaQuery.of(context).padding.top + 5, left: 0, child: GestureDetector( - onTap: () => goToSlide(slidesCount), + onTap: () { + if (bubbleShowcase.onDismiss != null) { + bubbleShowcase.onDismiss!(); + } + close(); + }, child: Icon( Icons.close, color: writeColor, @@ -141,7 +158,7 @@ abstract class BubbleSlide { )); } - if (passthroughMode == PassthroughMode.INSIDE_WITH_NOTIFICATION) { + if (passThroughMode == PassthroughMode.INSIDE_WITH_NOTIFICATION) { return Stack( children: children, ); @@ -171,6 +188,11 @@ class RelativeBubbleSlide extends BubbleSlide { /// Padding for the highlight area final int highlightPadding; + final PassthroughMode passThroughMode; + + final VoidCallback? onEnter; + final VoidCallback? onExit; + /// Creates a new relative bubble slide instance. const RelativeBubbleSlide({ Shape shape = const Rectangle(), @@ -179,15 +201,19 @@ class RelativeBubbleSlide extends BubbleSlide { blurRadius: 0, spreadRadius: 0, ), - passThroughMode = PassthroughMode.NONE, required BubbleSlideChild child, required this.widgetKey, + this.passThroughMode = PassthroughMode.NONE, this.highlightPadding = 0, + this.onEnter, + this.onExit, }) : super( shape: shape, boxShadow: boxShadow, child: child, - passthroughMode: passThroughMode, + passThroughMode: passThroughMode, + onEnter: onEnter, + onExit: onExit, ); @override @@ -214,6 +240,9 @@ class AbsoluteBubbleSlide extends BubbleSlide { /// The function that allows to compute the highlight position according to the parent size. final PositionCalculator positionCalculator; + final VoidCallback? onEnter; + final VoidCallback? onExit; + /// Creates a new absolute bubble slide instance. const AbsoluteBubbleSlide({ Shape shape = const Rectangle(), @@ -224,10 +253,14 @@ class AbsoluteBubbleSlide extends BubbleSlide { ), required BubbleSlideChild child, required this.positionCalculator, + this.onEnter, + this.onExit, }) : super( shape: shape, boxShadow: boxShadow, child: child, + onEnter: onEnter, + onExit: onExit, ); @override @@ -241,23 +274,68 @@ class AbsoluteBubbleSlide extends BubbleSlide { /// A bubble slide child, holding a widget. abstract class BubbleSlideChild { + /// The direction of the slide + final AxisDirection direction; + /// The held widget. - final Widget widget; + final Widget? widget; + + /// Builder function + final Widget Function( + BuildContext ctx, + Position highlightPosition, + Position slidePosition, + Size parentSize, + Alignment slideAlignment, + AxisDirection slideDirection, + )? builder; /// Creates a new bubble slide child instance. const BubbleSlideChild({ required this.widget, + required this.builder, + required this.direction, }); /// Builds the bubble slide child widget. Widget build(BuildContext context, Position targetPosition, Size parentSize) { - Position position = getPosition(context, targetPosition, parentSize); + print("DEBUG => Hello world"); + Widget childWidget; + Position slidePosition = getPosition(context, targetPosition, parentSize); + Alignment alignment = + getAlignment(context, targetPosition, parentSize, direction); + + print( + "DEBUG => alignment: $alignment, direction: $direction, slidePosition: $slidePosition", + ); + + if (builder != null) { + print("DEBUG => Building off the builder"); + childWidget = builder!( + context, + targetPosition, + slidePosition, + parentSize, + alignment, + direction, + ); + } else { + print("DEBUG => Using the widget passed in props"); + childWidget = widget!; + } + return Positioned( - top: position.top, - right: position.right, - bottom: position.bottom, - left: position.left, - child: widget, + top: slidePosition.top, + right: slidePosition.right, + bottom: slidePosition.bottom, + left: slidePosition.left, + child: Container( + color: Colors.black, + child: Align( + alignment: alignment, + child: childWidget, + ), + ), ); } @@ -267,6 +345,13 @@ abstract class BubbleSlideChild { Position highlightPosition, Size parentSize, ); + + Alignment getAlignment( + BuildContext context, + Position highlightPosition, + Size parentSize, + AxisDirection direction, + ); } /// A bubble slide with a position that depends on the highlight zone. @@ -276,10 +361,12 @@ class RelativeBubbleSlideChild extends BubbleSlideChild { /// Creates a new relative bubble slide child instance. const RelativeBubbleSlideChild({ - required Widget widget, + required Widget? widget, this.direction = AxisDirection.down, }) : super( + direction: direction, widget: widget, + builder: null, ); @override @@ -315,6 +402,174 @@ class RelativeBubbleSlideChild extends BubbleSlideChild { ); } } + + @override + Alignment getAlignment( + BuildContext context, + Position highlightPosition, + Size parentSize, + AxisDirection direction, + ) { + return Alignment.center; + } +} + +class RelativeBubbleSlideChildBuilder extends BubbleSlideChild { + /// The child direction. + final AxisDirection direction; + + /// Determines, in size a percentage from 0.15 to 0.45, the height of the parent container that will be + /// recognized as "Middle" space, starting from the center. + /// + /// Used by the automatic positioning system to determine + /// which positioning strategy to use. Defaults to 15% of the area from the middle to be counted as "center space". + final double middlePointHeight; + + /// Determines, in size a percentage from 0.15 to 0.45, the width of the parent container that will be + /// recognized as "Middle" space, starting from the center. + /// + /// Used by the automatic positioning system to determine + /// which positioning strategy to use. Defaults to 15% of the area from the middle to be counted as "center space". + final double middlePointWidth; + + final Widget Function( + BuildContext ctx, + Position highlightPosition, + Position slidePosition, + Size parentSize, + Alignment slideAlignment, + AxisDirection slideDirection, + ) builder; + + RelativeBubbleSlideChildBuilder({ + required this.builder, + this.direction = AxisDirection.down, + this.middlePointWidth = 0.15, + this.middlePointHeight = 0.15, + }) : assert(middlePointHeight >= 0 && middlePointHeight < 0.45), + assert(middlePointWidth >= 0 && middlePointWidth < 0.45), + super( + direction: direction, + widget: null, + builder: builder, + ); + + @override + Alignment getAlignment( + BuildContext context, + Position highlightPosition, + Size parentSize, + AxisDirection direction, + ) { + Quadrant quadrant = + AdvancedPositioningUtils.getQuadrantFromRelativePosition( + highlightPosition: highlightPosition, + parentSize: parentSize, + direction: direction, + middlePointHeight: middlePointHeight, + middlePointWidth: middlePointWidth, + ); + + switch (quadrant) { + case Quadrant.TOP_RIGHT: + switch (direction) { + case AxisDirection.down: + return Alignment.topRight; + case AxisDirection.up: + return Alignment.bottomRight; + case AxisDirection.right: + return Alignment.topLeft; + case AxisDirection.left: + return Alignment.topRight; + } + case Quadrant.TOP_LEFT: + switch (direction) { + case AxisDirection.down: + return Alignment.topLeft; + case AxisDirection.up: + return Alignment.bottomLeft; + case AxisDirection.right: + return Alignment.topLeft; + case AxisDirection.left: + return Alignment.topRight; + } + case Quadrant.BOTTOM_LEFT: + switch (direction) { + case AxisDirection.down: + return Alignment.topLeft; + case AxisDirection.up: + return Alignment.bottomLeft; + case AxisDirection.right: + return Alignment.bottomLeft; + case AxisDirection.left: + return Alignment.bottomRight; + } + case Quadrant.BOTTOM_RIGHT: + switch (direction) { + case AxisDirection.down: + return Alignment.topRight; + case AxisDirection.up: + return Alignment.bottomRight; + case AxisDirection.right: + return Alignment.bottomLeft; + case AxisDirection.left: + return Alignment.bottomRight; + } + case Quadrant.CENTER: + switch (direction) { + case AxisDirection.down: + return Alignment.topCenter; + case AxisDirection.up: + return Alignment.bottomCenter; + case AxisDirection.left: + return Alignment.centerRight; + case AxisDirection.right: + return Alignment.centerLeft; + } + } + } + + @override + Position getPosition( + BuildContext context, + Position highlightPosition, + Size parentSize, + ) { + Quadrant quadrant = + AdvancedPositioningUtils.getQuadrantFromRelativePosition( + highlightPosition: highlightPosition, + parentSize: parentSize, + direction: direction, + middlePointHeight: middlePointHeight, + middlePointWidth: middlePointWidth, + ); + + print('DEBUG => quadrant $quadrant'); + + switch (direction) { + case AxisDirection.up: + return AdvancedPositioningUtils.getUpPositionFromQuadrant( + quadrant, + parentSize, + highlightPosition, + ); + case AxisDirection.right: + return AdvancedPositioningUtils.getRightPositionFromQuadrant( + quadrant, + parentSize, + highlightPosition, + ); + case AxisDirection.left: + return AdvancedPositioningUtils.getLeftPositionFromQuadrant( + quadrant, + parentSize, + highlightPosition, + ); + default: + return AdvancedPositioningUtils.getDownPositionFromQuadrant( + quadrant, parentSize, highlightPosition); + } + } } /// A bubble slide child with an absolute position on the screen. @@ -326,7 +581,11 @@ class AbsoluteBubbleSlideChild extends BubbleSlideChild { const AbsoluteBubbleSlideChild({ required Widget widget, required this.positionCalculator, - }) : super(widget: widget); + }) : super( + widget: widget, + builder: null, + direction: AxisDirection.down, + ); @override Position getPosition( @@ -335,4 +594,14 @@ class AbsoluteBubbleSlideChild extends BubbleSlideChild { Size parentSize, ) => positionCalculator(parentSize); + + @override + Alignment getAlignment( + BuildContext context, + Position highlightPosition, + Size parentSize, + AxisDirection direction, + ) { + return Alignment.center; + } } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 10ee608..a4e0875 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -115,3 +115,336 @@ class OverlayClipper extends CustomClipper { @override bool shouldReclip(covariant CustomClipper oldClipper) => false; } + +class AdvancedPositioningUtils { + static Quadrant getQuadrantFromRelativePosition({ + required Position highlightPosition, + required Size parentSize, + required AxisDirection direction, + required double middlePointWidth, + required double middlePointHeight, + }) { + // Distance of the right point from the left edge + final r = highlightPosition.right; + // Distance of the left point from the left edge + final l = highlightPosition.left; + // Ditsance from the top point from the top + final t = highlightPosition.top; + // Distance from the bottom point from the top + final b = highlightPosition.bottom; + + // Distance of the right point from the right edge + final dr = parentSize.width - r; + // Distance of the left point from the left edge + final dl = l; + // Distance of the top point from the top + final dt = t; + // Distance of the bottom point from the bottom; + final db = parentSize.height - b; + + final w = parentSize.width; + final h = parentSize.height; + // Represents the boundaries x1 & y1 represent the positive axes from the middle of the parent + // While x2 & y2 represent the negative axes from the middle of the parent + + final middlePointWidthConverted = 0.5 + middlePointWidth; + final middlePointHeightConverted = 0.5 + middlePointHeight; + + final x1 = w * middlePointWidthConverted; + final x2 = w - (w * middlePointWidthConverted); + + final y1 = h - (h * middlePointHeightConverted); + final y2 = h * middlePointHeightConverted; + + // Mx & My represent the middle points of the axes of the highlighted item + final highlightAreaSize = Size(r - l, b - t); + final mx = l + highlightAreaSize.width / 2; + final my = t + highlightAreaSize.height / 2; + + // It leans to the right side if the distance from the right edge is less than from the right edge + final leansToRightSide = dr < dl; + + // It leans to the bottom side if the distance from the bottom edge is less than from the top edge + final leansToBottomSide = db < dt; + print('==================================='); + print('leansToBottomSide: $leansToBottomSide'); + print( + 'highlightPosition.bottom: ${highlightPosition.bottom}, highlightPosition.top: ${highlightPosition.top}', + ); + print('==================================='); + print('leansToRightSide: $leansToRightSide'); + print( + 'highlightPosition.right: ${highlightPosition.right}, highlightPosition.left: ${highlightPosition.left}', + ); + + final isHorizontal = + direction == AxisDirection.left || direction == AxisDirection.right; + + final isVertical = + direction == AxisDirection.up || direction == AxisDirection.down; + + // Calculate extremes first, cases where + // it might be in quadrant 5, but we cannot center due to possible + // collitions with the screen edges. + if (dr <= w * 0.05 && isVertical) { + // Extreme right (slide on top or bottom of highlighted area) + print('1st'); + if (leansToBottomSide) { + return Quadrant.BOTTOM_RIGHT; + } else { + return Quadrant.TOP_RIGHT; + } + } else if (dl <= w * 0.05 && isVertical) { + // Extreme left (slide on top or bottom of highlighted area) + print('2nd'); + if (leansToBottomSide) { + return Quadrant.BOTTOM_LEFT; + } else { + return Quadrant.TOP_LEFT; + } + } else if (db <= h * 0.05 && isHorizontal) { + // Extreme bottom (slide on left or right of highlighted area) + print('3rd'); + if (leansToRightSide) { + return Quadrant.BOTTOM_RIGHT; + } else { + return Quadrant.BOTTOM_LEFT; + } + } else if (dt <= h * 0.05 && isHorizontal) { + // Extreme top (slide on left or right of highlighted area) + print('4th'); + if (leansToRightSide) { + return Quadrant.TOP_RIGHT; + } else { + return Quadrant.TOP_LEFT; + } + } + + print("Quadrant calculated normally"); + + // Calculate quadrants normally + if (mx >= x1 && my <= y1) { + print("DEBUG => Top_right"); + return Quadrant.TOP_RIGHT; + } else if (mx <= x2 && my <= y1) { + print("DEBUG => Top_left"); + return Quadrant.TOP_LEFT; + } else if (mx <= x2 && my >= y2) { + print("DEBUG => Bottom_left"); + return Quadrant.BOTTOM_LEFT; + } else if (mx >= x1 && my >= y2) { + print("DEBUG => Bottom_right"); + return Quadrant.BOTTOM_RIGHT; + } else { + return Quadrant.CENTER; // center (Not totally within any other quadrant) + } + } + + static Position getDownPositionFromQuadrant( + Quadrant quadrant, + Size parentSize, + Position highlightPosition, + ) { + switch (quadrant) { + case Quadrant.TOP_RIGHT: + case Quadrant.BOTTOM_RIGHT: + // It will expand to the left + final spacingFromTheRightEdge = + parentSize.width - highlightPosition.right; + return Position( + right: spacingFromTheRightEdge, + top: highlightPosition.bottom, + ); + case Quadrant.TOP_LEFT: + case Quadrant.BOTTOM_LEFT: + // It will expand to the right + return Position( + left: highlightPosition.left, + top: highlightPosition.bottom, + ); + case Quadrant.CENTER: + final widthFromRightEdge = parentSize.width - highlightPosition.right; + final widthFromLeftEdge = highlightPosition.left; + final availableWidth = widthFromRightEdge > widthFromLeftEdge + ? highlightPosition.right + : highlightPosition.left; + + return Position( + top: highlightPosition.bottom, + right: widthFromRightEdge > widthFromLeftEdge + ? (widthFromRightEdge) - (availableWidth / 2) + : availableWidth / 2, + left: widthFromLeftEdge > widthFromRightEdge + ? (widthFromLeftEdge) + (availableWidth / 2) + : availableWidth / 2, + ); + default: + throw ('Slide is outside the view area'); + } + } + + static Position getLeftPositionFromQuadrant( + Quadrant quadrant, + Size parentSize, + Position highlightPosition, + ) { + switch (quadrant) { + case Quadrant.TOP_RIGHT: + case Quadrant.TOP_LEFT: + // It will expand to the bottom + return Position( + top: highlightPosition.top, + right: parentSize.width - highlightPosition.left, + ); + case Quadrant.BOTTOM_RIGHT: + case Quadrant.BOTTOM_LEFT: + // It will expand to the top + return Position( + bottom: parentSize.height - highlightPosition.bottom, + right: parentSize.width - highlightPosition.left, + ); + case Quadrant.CENTER: + // It will be centered + final topHeightFromEdge = highlightPosition.top; + final bottomHeightFromEdge = + parentSize.height - highlightPosition.bottom; + final availableHeight = topHeightFromEdge > bottomHeightFromEdge + ? (parentSize.height - highlightPosition.bottom) - + (parentSize.height - highlightPosition.top) + : (parentSize.height - highlightPosition.top) - + (parentSize.height - highlightPosition.bottom); + final highlightedItemSize = Size( + highlightPosition.right - highlightPosition.left, + highlightPosition.bottom - highlightPosition.top, + ); + double top; + double bottom; + if (topHeightFromEdge > bottomHeightFromEdge) { + top = (topHeightFromEdge) - + ((availableHeight / 2) + + (bottomHeightFromEdge / 2) + + highlightedItemSize.height); + bottom = (bottomHeightFromEdge / 2) - highlightedItemSize.height / 2; + } else { + top = (topHeightFromEdge / 2) - highlightedItemSize.height; + bottom = (bottomHeightFromEdge) - + ((availableHeight / 2) + + (topHeightFromEdge / 2) + + highlightedItemSize.height / 2); + } + + return Position( + top: top, + bottom: bottom, + right: parentSize.width - highlightPosition.left, + ); + + default: + throw ('Slide is outside the view area'); + } + } + + static Position getRightPositionFromQuadrant( + Quadrant quadrant, + Size parentSize, + Position highlightPosition, + ) { + switch (quadrant) { + case Quadrant.TOP_RIGHT: + case Quadrant.TOP_LEFT: + // It will expand to the bottom + return Position( + top: highlightPosition.top, + left: highlightPosition.right, + ); + case Quadrant.BOTTOM_RIGHT: + case Quadrant.BOTTOM_LEFT: + // It will expand to the top + return Position( + bottom: parentSize.height - highlightPosition.bottom, + left: highlightPosition.right, + ); + case Quadrant.CENTER: + // It will be centered + final topHeightFromEdge = highlightPosition.top; + final bottomHeightFromEdge = + parentSize.height - highlightPosition.bottom; + + final availableHeight = topHeightFromEdge > bottomHeightFromEdge + ? (parentSize.height - highlightPosition.bottom) - + (parentSize.height - highlightPosition.top) + : (parentSize.height - highlightPosition.top) - + (parentSize.height - highlightPosition.bottom); + final highlightedItemSize = Size( + highlightPosition.right - highlightPosition.left, + highlightPosition.bottom - highlightPosition.top, + ); + double top; + double bottom; + if (topHeightFromEdge > bottomHeightFromEdge) { + top = (topHeightFromEdge) - + ((availableHeight / 2) + + (bottomHeightFromEdge / 2) + + highlightedItemSize.height); + bottom = (bottomHeightFromEdge / 2) - highlightedItemSize.height / 2; + } else { + top = (topHeightFromEdge / 2) - highlightedItemSize.height; + bottom = (bottomHeightFromEdge) - + ((availableHeight / 2) + + (topHeightFromEdge / 2) + + highlightedItemSize.height / 2); + } + + return Position( + top: top, + bottom: bottom, + left: highlightPosition.right, + ); + default: + throw ('Slide is outside the view area'); + } + } + + static Position getUpPositionFromQuadrant( + Quadrant quadrant, + Size parentSize, + Position highlightPosition, + ) { + switch (quadrant) { + case Quadrant.TOP_RIGHT: + case Quadrant.BOTTOM_RIGHT: + // It will expand to the left + final spacingFromTheRightEdge = + parentSize.width - highlightPosition.right; + return Position( + right: spacingFromTheRightEdge, + bottom: parentSize.height - highlightPosition.top, + ); + case Quadrant.TOP_LEFT: + case Quadrant.BOTTOM_LEFT: + // It will expand to the right + return Position( + left: highlightPosition.left, + bottom: parentSize.height - highlightPosition.top, + ); + case Quadrant.CENTER: + final widthFromRightEdge = parentSize.width - highlightPosition.right; + final widthFromLeftEdge = highlightPosition.left; + final availableWidth = widthFromRightEdge > widthFromLeftEdge + ? highlightPosition.right + : highlightPosition.left; + + return Position( + bottom: parentSize.height - highlightPosition.top, + right: widthFromRightEdge > widthFromLeftEdge + ? (widthFromRightEdge) - (availableWidth / 2) + : availableWidth / 2, + left: widthFromLeftEdge > widthFromRightEdge + ? (widthFromLeftEdge) + (availableWidth / 2) + : availableWidth / 2, + ); + default: + throw ('Slide is outside the view area'); + } + } +}