diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0b8da4..7943cc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ Improved Tooltip widget - Feature [#54](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/54) - Added Feasibility to position tooltip left and right to the target widget. +- Feature [#113](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/113) - Added + multiple showcase feature +- Improvement [#514](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/pull/514) - + Improved showcase widget and showcase with widget, Removed inherited widget, keys and setStates, + Added controller to manage showcase +- CHORE [#514](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/pull/514) - + Bumped dart minimum sdk to 2.19.6 ## [4.0.1] - Fixed [#493](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/493) - ShowCase.withWidget not showing issue diff --git a/README.md b/README.md index c6fd669c..24649445 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ Showcase.withWidget( ), ``` -5. Starting the `ShowCase` +5. Starting the `ShowCase`: ```dart someEvent(){ ShowCaseWidget.of(context).startShowCase([_one, _two, _three]); } ``` -If you want to start the `ShowCaseView` as soon as your UI built up then use below code. +If you want to start the `ShowCaseView` as soon as your UI built up then use below code: ```dart WidgetsBinding.instance.addPostFrameCallback((_) => @@ -125,6 +125,45 @@ WidgetsBinding.instance.addPostFrameCallback((_) => ); ``` +If you have some animation or transition in your UI and you want to start the `ShowCaseView` after +that then use below code: + +```dart +WidgetsBinding.instance.addPostFrameCallback((_) => + ShowCaseWidget.of(context).startShowCase([_one, _two, _three], delay: "Animation Duration") +); +``` + +## MultiShowcaseView +To show multiple showcase at the same time provide same key to showcase. +Note: auto scroll to showcase will not work in case of the multi-showcase and we will use property +of first initialized showcase for common things like barrier tap and colors. + +```dart +GlobalKey _one = GlobalKey(); +... + +Showcase( + key: _one, + title: 'Showcase one', + description: 'Click here to see menu options', + child: Icon( + Icons.menu, + color: Colors.black45, + ), +), + +Showcase( + key: _one, + title: 'Showcase two', + description: 'Click here to see menu options', + child: Icon( + Icons.menu, + color: Colors.black45, + ), +), +``` + ## Functions of `ShowCaseWidget.of(context)`: | Function Name | Description | @@ -257,7 +296,7 @@ So, If you want to make a scroll view that contains less number of children widg If using SingleChildScrollView is not an option, then you can assign a ScrollController to that scrollview and manually scroll to the position where showcase widget gets rendered. You can add that code in onStart method of `ShowCaseWidget`. -Example, +Example: ```dart // This controller will be assigned to respected sctollview. diff --git a/example/lib/main.dart b/example/lib/main.dart index b2adb8e4..d1ce61ea 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -468,8 +468,12 @@ class _MailPageState extends State { ), ).then((_) { setState(() { - ShowCaseWidget.of(context) - .startShowCase([_four, _lastShowcaseWidget]); + ShowCaseWidget.of(context).startShowCase( + [_four, _lastShowcaseWidget], + delay: const Duration( + milliseconds: 200, + ), + ); }); }); }, diff --git a/lib/showcaseview.dart b/lib/showcaseview.dart index 3d597dd7..2c94bb01 100644 --- a/lib/showcaseview.dart +++ b/lib/showcaseview.dart @@ -26,7 +26,7 @@ export 'src/enum.dart'; export 'src/models/action_button_icon.dart'; export 'src/models/tooltip_action_button.dart'; export 'src/models/tooltip_action_config.dart'; -export 'src/showcase.dart'; +export 'src/showcase/showcase.dart'; export 'src/showcase_widget.dart'; export 'src/tooltip_action_button_widget.dart'; export 'src/widget/floating_action_widget.dart'; diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 6dda3ebd..a7e43169 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -1,3 +1,7 @@ +import 'package:flutter/material.dart'; + +import 'widget/showcase_circular_progress_indicator.dart'; + class Constants { Constants._(); @@ -25,4 +29,16 @@ class Constants { /// i.e if it is bottom position then centerBottom + [extraAlignmentOffset] /// in bottom static const double extraAlignmentOffset = 5; + + static const Radius defaultTargetRadius = Radius.circular(3.0); + + static const ShapeBorder defaultTargetShapeBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + + static const double cupertinoActivityIndicatorRadius = 12.0; + static const Widget defaultProgressIndicator = + ShowcaseCircularProgressIndicator(); + + static const Duration defaultAnimationDuration = Duration(milliseconds: 2000); } diff --git a/lib/src/enum.dart b/lib/src/enum.dart index 84db9b62..6669e688 100644 --- a/lib/src/enum.dart +++ b/lib/src/enum.dart @@ -94,7 +94,7 @@ enum TooltipDefaultActionType { void onTap(ShowCaseWidgetState showCaseState) { switch (this) { case TooltipDefaultActionType.next: - showCaseState.next(); + showCaseState.next(force: true); break; case TooltipDefaultActionType.previous: showCaseState.previous(); diff --git a/lib/src/get_position.dart b/lib/src/get_position.dart index 91602527..524c528e 100644 --- a/lib/src/get_position.dart +++ b/lib/src/get_position.dart @@ -26,40 +26,34 @@ import 'package:flutter/material.dart'; class GetPosition { GetPosition({ - required this.key, + required this.renderBox, required this.screenWidth, required this.screenHeight, this.padding = EdgeInsets.zero, this.rootRenderObject, }) { - getRenderBox(); + _getRenderBoxOffset(); } - final GlobalKey key; + final RenderBox? renderBox; final EdgeInsets padding; final double screenWidth; final double screenHeight; final RenderObject? rootRenderObject; - late final RenderBox? _box; - late final Offset? _boxOffset; - - RenderBox? get box => _box; - - void getRenderBox() { - var renderBox = key.currentContext?.findRenderObject() as RenderBox?; + Offset? _boxOffset; + void _getRenderBoxOffset() { if (renderBox == null) return; - _box = renderBox; - _boxOffset = _box?.localToGlobal( + _boxOffset = renderBox?.localToGlobal( Offset.zero, ancestor: rootRenderObject, ); } bool _checkBoxOrOffsetIsNull({bool checkDy = false, bool checkDx = false}) { - return _box == null || + return renderBox == null || _boxOffset == null || (checkDx && (_boxOffset?.dx.isNaN ?? true)) || (checkDy && (_boxOffset?.dy.isNaN ?? true)); @@ -69,13 +63,13 @@ class GetPosition { if (_checkBoxOrOffsetIsNull(checkDy: true, checkDx: true)) { return Rect.zero; } - final topLeft = _box!.size.topLeft(_boxOffset!); - final bottomRight = _box!.size.bottomRight(_boxOffset!); + final topLeft = renderBox!.size.topLeft(_boxOffset!); + final bottomRight = renderBox!.size.bottomRight(_boxOffset!); final leftDx = topLeft.dx - padding.left; final leftDy = topLeft.dy - padding.top; final rect = Rect.fromLTRB( - leftDx.clamp(0, leftDx), - leftDy.clamp(0, leftDy), + leftDx.clamp(0, double.maxFinite), + leftDy.clamp(0, double.maxFinite), min(bottomRight.dx + padding.right, screenWidth), min(bottomRight.dy + padding.bottom, screenHeight), ); @@ -87,7 +81,7 @@ class GetPosition { if (_checkBoxOrOffsetIsNull(checkDy: true)) { return padding.bottom; } - final bottomRight = _box!.size.bottomRight(_boxOffset!); + final bottomRight = renderBox!.size.bottomRight(_boxOffset!); return bottomRight.dy + padding.bottom; } @@ -96,7 +90,7 @@ class GetPosition { if (_checkBoxOrOffsetIsNull(checkDy: true)) { return -padding.top; } - final topLeft = _box!.size.topLeft(_boxOffset!); + final topLeft = renderBox!.size.topLeft(_boxOffset!); return topLeft.dy - padding.top; } @@ -105,7 +99,7 @@ class GetPosition { if (_checkBoxOrOffsetIsNull(checkDx: true)) { return -padding.left; } - final topLeft = _box!.size.topLeft(_boxOffset!); + final topLeft = renderBox!.size.topLeft(_boxOffset!); return topLeft.dx - padding.left; } @@ -114,7 +108,7 @@ class GetPosition { if (_checkBoxOrOffsetIsNull(checkDx: true)) { return padding.right; } - final bottomRight = _box!.size.bottomRight(_boxOffset!); + final bottomRight = renderBox!.size.bottomRight(_boxOffset!); return bottomRight.dx + padding.right; } @@ -123,4 +117,18 @@ class GetPosition { double getWidth() => getRight() - getLeft(); double getCenter() => (getLeft() + getRight()) * 0.5; + + Offset topLeft() { + final box = renderBox; + if (box == null) return Offset.zero; + + return box.size.topLeft( + box.localToGlobal( + Offset.zero, + ancestor: rootRenderObject, + ), + ); + } + + Offset getOffset() => renderBox?.size.center(topLeft()) ?? Offset.zero; } diff --git a/lib/src/models/linked_showcase_data.dart b/lib/src/models/linked_showcase_data.dart new file mode 100644 index 00000000..f25b0f1d --- /dev/null +++ b/lib/src/models/linked_showcase_data.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; + +/// This model is used to move linked showcase overlay data to parent +/// showcase to crop linked showcase rect +class LinkedShowcaseDataModel { + const LinkedShowcaseDataModel({ + required this.rect, + required this.radius, + required this.overlayPadding, + required this.isCircle, + }); + + final Rect rect; + final EdgeInsets overlayPadding; + final BorderRadius? radius; + final bool isCircle; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LinkedShowcaseDataModel && + runtimeType == other.runtimeType && + rect == other.rect && + radius == other.radius && + overlayPadding == other.overlayPadding && + isCircle == other.isCircle; + + @override + int get hashCode => Object.hash( + rect, + radius, + overlayPadding, + isCircle, + ); +} diff --git a/lib/src/layout_overlays.dart b/lib/src/overlay_builder.dart similarity index 54% rename from lib/src/layout_overlays.dart rename to lib/src/overlay_builder.dart index e0e57a47..65c38e84 100644 --- a/lib/src/layout_overlays.dart +++ b/lib/src/overlay_builder.dart @@ -24,94 +24,6 @@ import 'package:flutter/material.dart'; import 'showcase_widget.dart'; -typedef OverlayBuilderCallback = Widget Function( - BuildContext context, - Rect anchorBounds, - Offset anchor, -); - -/// Displays an overlay Widget anchored directly above the center of this -/// [AnchoredOverlay]. -/// -/// The overlay Widget is created by invoking the provided [overlayBuilder]. -/// -/// The [anchor] position is provided to the [overlayBuilder], but the builder -/// does not have to respect it. In other words, the [overlayBuilder] can -/// interpret the meaning of "anchor" however it wants - the overlay will not -/// be forced to be centered about the [anchor]. -/// -/// The overlay built by this [AnchoredOverlay] can be conditionally shown -/// and hidden by settings the [showOverlay] property to true or false. -/// -/// The [overlayBuilder] is invoked every time this Widget is rebuilt. -/// -class AnchoredOverlay extends StatelessWidget { - final bool showOverlay; - final OverlayBuilderCallback? overlayBuilder; - final Widget? child; - final RenderObject? rootRenderObject; - - const AnchoredOverlay({ - super.key, - this.showOverlay = false, - this.overlayBuilder, - this.child, - this.rootRenderObject, - }); - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return OverlayBuilder( - showOverlay: showOverlay, - overlayBuilder: (overlayContext) { - // To calculate the "anchor" point we grab the render box of - // our parent Container and then we find the center of that box. - final box = context.findRenderObject() as RenderBox?; - - /// Handle null RenderBox safely. - final topLeft = box?.size.topLeft( - box.localToGlobal( - Offset.zero, - ancestor: rootRenderObject, - ), - ) ?? - Offset.zero; - final bottomRight = box?.size.bottomRight( - box.localToGlobal( - Offset.zero, - ancestor: rootRenderObject, - ), - ) ?? - Offset.zero; - - /// Provide a default anchorBounds if box is null. - final anchorBounds = (topLeft.dx.isNaN || - topLeft.dy.isNaN || - bottomRight.dx.isNaN || - bottomRight.dy.isNaN) - ? const Rect.fromLTRB(0.0, 0.0, 0.0, 0.0) - : Rect.fromLTRB( - topLeft.dx, - topLeft.dy, - bottomRight.dx, - bottomRight.dy, - ); - - /// Calculate the anchor center or default to Offset.zero. - final anchorCenter = box?.size.center(topLeft) ?? Offset.zero; - - /// Pass the anchor details to the overlay builder. - return overlayBuilder!(overlayContext, anchorBounds, anchorCenter); - }, - child: child, - ); - }, - ); - } -} - /// Displays an overlay Widget as constructed by the given [overlayBuilder]. /// /// The overlay built by the [overlayBuilder] can be conditionally shown and @@ -125,17 +37,24 @@ class AnchoredOverlay extends StatelessWidget { /// exist in [OverlayEntry]s which are inaccessible to outside Widgets. But if /// a better approach is found then feel free to use it. class OverlayBuilder extends StatefulWidget { - final bool showOverlay; - final WidgetBuilder? overlayBuilder; - final Widget? child; - const OverlayBuilder({ super.key, - this.showOverlay = false, + required this.child, + required this.updateOverlay, this.overlayBuilder, - this.child, }); + final WidgetBuilder? overlayBuilder; + final Widget child; + + /// A callback that provides a way to control the overlay visibility from + /// showcase widget + /// Basically we pass a reference to [_updateOverlay] function to parent so we + /// can call this from parent class to update the overlay + /// This callback provides a function that can be called with a boolean + /// parameter to show (true) or hide (false) the overlay. + final ValueSetter> updateOverlay; + @override State createState() => _OverlayBuilderState(); } @@ -143,19 +62,28 @@ class OverlayBuilder extends StatefulWidget { class _OverlayBuilderState extends State { OverlayEntry? _overlayEntry; + bool _showOverlay = false; + @override void initState() { super.initState(); - if (widget.showOverlay) { + if (_showOverlay) { WidgetsBinding.instance.addPostFrameCallback((_) => showOverlay()); } + widget.updateOverlay.call(_updateOverlay); + } + + void _updateOverlay(bool showOverlay) { + _showOverlay = showOverlay; + buildOverlay(); + WidgetsBinding.instance.addPostFrameCallback((_) => syncWidgetAndOverlay()); } @override void didUpdateWidget(OverlayBuilder oldWidget) { super.didUpdateWidget(oldWidget); - WidgetsBinding.instance.addPostFrameCallback((_) => syncWidgetAndOverlay()); + WidgetsBinding.instance.addPostFrameCallback((_) => showOverlay()); } @override @@ -207,9 +135,9 @@ class _OverlayBuilderState extends State { } void syncWidgetAndOverlay() { - if (isShowingOverlay() && !widget.showOverlay) { + if (isShowingOverlay() && !_showOverlay) { hideOverlay(); - } else if (!isShowingOverlay() && widget.showOverlay) { + } else if (!isShowingOverlay() && _showOverlay) { showOverlay(); } } @@ -220,9 +148,5 @@ class _OverlayBuilderState extends State { } @override - Widget build(BuildContext context) { - buildOverlay(); - - return widget.child!; - } + Widget build(BuildContext context) => widget.child; } diff --git a/lib/src/shape_clipper.dart b/lib/src/shape_clipper.dart index 2017576d..8bb1b9c8 100644 --- a/lib/src/shape_clipper.dart +++ b/lib/src/shape_clipper.dart @@ -22,25 +22,31 @@ import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class RRectClipper extends CustomClipper { - final bool isCircle; - final BorderRadius? radius; - final EdgeInsets overlayPadding; - final Rect area; +import 'constants.dart'; +import 'models/linked_showcase_data.dart'; - RRectClipper({ +class RRectClipper extends CustomClipper { + const RRectClipper({ this.isCircle = false, - this.radius, this.overlayPadding = EdgeInsets.zero, this.area = Rect.zero, + this.linkedObjectData = const [], + this.radius, }); + final bool isCircle; + final BorderRadius? radius; + final EdgeInsets overlayPadding; + final Rect area; + final List linkedObjectData; + @override ui.Path getClip(ui.Size size) { final customRadius = - isCircle ? Radius.circular(area.height) : const Radius.circular(3.0); + isCircle ? Radius.circular(area.height) : Constants.defaultTargetRadius; final rect = Rect.fromLTRB( area.left - overlayPadding.left, @@ -49,7 +55,7 @@ class RRectClipper extends CustomClipper { area.bottom + overlayPadding.bottom, ); - return Path() + var mainObjectPath = Path() ..fillType = ui.PathFillType.evenOdd ..addRect(Offset.zero & size) ..addRRect( @@ -61,6 +67,40 @@ class RRectClipper extends CustomClipper { bottomRight: (radius?.bottomRight ?? customRadius), ), ); + + final linkedObjectLength = linkedObjectData.length; + for (var i = 0; i < linkedObjectLength; i++) { + final widgetInfo = linkedObjectData[i]; + final customRadius = widgetInfo.isCircle + ? Radius.circular(widgetInfo.rect.height) + : Constants.defaultTargetRadius; + + final rect = Rect.fromLTRB( + widgetInfo.rect.left - widgetInfo.overlayPadding.left, + widgetInfo.rect.top - widgetInfo.overlayPadding.top, + widgetInfo.rect.right + widgetInfo.overlayPadding.right, + widgetInfo.rect.bottom + widgetInfo.overlayPadding.bottom, + ); + + /// We have use this approach so that overlapping cutout will merge with + /// each other + mainObjectPath = Path.combine( + PathOperation.difference, + mainObjectPath, + Path() + ..addRRect( + RRect.fromRectAndCorners( + rect, + topLeft: (widgetInfo.radius?.topLeft ?? customRadius), + topRight: (widgetInfo.radius?.topRight ?? customRadius), + bottomLeft: (widgetInfo.radius?.bottomLeft ?? customRadius), + bottomRight: (widgetInfo.radius?.bottomRight ?? customRadius), + ), + ), + ); + } + + return mainObjectPath; } @override @@ -68,5 +108,6 @@ class RRectClipper extends CustomClipper { isCircle != oldClipper.isCircle || radius != oldClipper.radius || overlayPadding != oldClipper.overlayPadding || - area != oldClipper.area; + area != oldClipper.area || + !listEquals(linkedObjectData, oldClipper.linkedObjectData); } diff --git a/lib/src/showcase.dart b/lib/src/showcase/showcase.dart similarity index 60% rename from lib/src/showcase.dart rename to lib/src/showcase/showcase.dart index e6a19701..bac3d198 100644 --- a/lib/src/showcase.dart +++ b/lib/src/showcase/showcase.dart @@ -20,22 +20,15 @@ * SOFTWARE. */ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'enum.dart'; -import 'get_position.dart'; -import 'layout_overlays.dart'; -import 'models/tooltip_action_button.dart'; -import 'models/tooltip_action_config.dart'; -import 'shape_clipper.dart'; -import 'showcase_widget.dart'; -import 'tooltip/tooltip.dart'; -import 'tooltip_action_button_widget.dart'; -import 'widget/floating_action_widget.dart'; +import '../constants.dart'; +import '../enum.dart'; +import '../models/tooltip_action_button.dart'; +import '../models/tooltip_action_config.dart'; +import '../showcase_widget.dart'; +import '../widget/floating_action_widget.dart'; +import 'showcase_controller.dart'; class Showcase extends StatefulWidget { /// A key that is unique across the entire app. @@ -43,8 +36,7 @@ class Showcase extends StatefulWidget { /// This Key will be used to control state of individual showcase and also /// used in [ShowCaseWidgetState.startShowCase] to define position of current /// target widget while showcasing. - @override - final GlobalKey key; + final GlobalKey showcaseKey; /// Target widget that will be showcased or highlighted final Widget child; @@ -68,6 +60,11 @@ class Showcase extends StatefulWidget { final ShapeBorder targetShapeBorder; /// Radius of rectangle box while target widget is being showcased. + /// + /// Default value is: + /// ```dart + /// const Radius.circular(3.0), + /// ``` final BorderRadius? targetBorderRadius; /// TextStyle for default tooltip title @@ -368,7 +365,7 @@ class Showcase extends StatefulWidget { /// - `overlayOpacity` must be between 0.0 and 1.0. /// - `onTargetClick` and `disposeOnTap` must be used together (one cannot exist without the other). const Showcase({ - required this.key, + required GlobalKey key, required this.description, required this.child, this.title, @@ -385,9 +382,7 @@ class Showcase extends StatefulWidget { this.descTextStyle, this.tooltipBackgroundColor = Colors.white, this.textColor = Colors.black, - this.scrollLoadingWidget = const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), + this.scrollLoadingWidget = Constants.defaultProgressIndicator, this.showArrow = true, this.onTargetClick, this.disposeOnTap, @@ -424,9 +419,10 @@ class Showcase extends StatefulWidget { }) : height = null, width = null, container = null, + showcaseKey = key, assert( overlayOpacity >= 0.0 && overlayOpacity <= 1.0, - "overlay opacity must be between 0 and 1.", + 'overlay opacity must be between 0 and 1.', ), assert( onTargetClick == null || disposeOnTap != null, @@ -494,26 +490,20 @@ class Showcase extends StatefulWidget { /// - `overlayOpacity` must be between 0.0 and 1.0. /// - `onBarrierClick` cannot be used with `disableBarrierInteraction`. const Showcase.withWidget({ - required this.key, + required GlobalKey key, required this.height, required this.width, required this.container, required this.child, this.floatingActionWidget, - this.targetShapeBorder = const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - ), + this.targetShapeBorder = Constants.defaultTargetShapeBorder, this.overlayColor = Colors.black45, this.targetBorderRadius, this.overlayOpacity = 0.75, - this.scrollLoadingWidget = const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), + this.scrollLoadingWidget = Constants.defaultProgressIndicator, this.onTargetClick, this.disposeOnTap, - this.movingAnimationDuration = const Duration(milliseconds: 2000), + this.movingAnimationDuration = Constants.defaultAnimationDuration, this.disableMovingAnimation, this.targetPadding = EdgeInsets.zero, this.blurValue, @@ -551,9 +541,10 @@ class Showcase extends StatefulWidget { titleTextDirection = null, descriptionTextDirection = null, toolTipMargin = 14, + showcaseKey = key, assert( overlayOpacity >= 0.0 && overlayOpacity <= 1.0, - "overlay opacity must be between 0 and 1.", + 'overlay opacity must be between 0 and 1.', ), assert( onBarrierClick == null || disableBarrierInteraction == false, @@ -565,404 +556,56 @@ class Showcase extends StatefulWidget { } class _ShowcaseState extends State { - bool _showShowCase = false; - bool _isScrollRunning = false; - bool _isTooltipDismissed = false; - bool _enableShowcase = true; - Timer? timer; - GetPosition? position; - Size? rootWidgetSize; - RenderBox? rootRenderObject; - - late final showCaseWidgetState = ShowCaseWidget.of(context); - FloatingActionWidget? _globalFloatingActionWidget; + ShowcaseController get _controller => + _showCaseWidgetState.getControllerForShowcase( + key: widget.showcaseKey, + showcaseId: _uniqueId, + ); + + late var _showCaseWidgetState = ShowCaseWidget.of(context); + + final int _uniqueId = UniqueKey().hashCode; @override void initState() { super.initState(); - initRootWidget(); + ShowcaseController( + id: _uniqueId, + key: widget.showcaseKey, + showcaseState: this, + showCaseWidgetState: ShowCaseWidget.of(context), + ); } @override - void didChangeDependencies() { - super.didChangeDependencies(); - _enableShowcase = showCaseWidgetState.enableShowcase; - - recalculateRootWidgetSize(); - - if (_enableShowcase) { - _globalFloatingActionWidget = - showCaseWidgetState.globalFloatingActionWidget?.call(context); - final size = MediaQuery.of(context).size; - position ??= GetPosition( - rootRenderObject: rootRenderObject, - key: widget.key, - padding: widget.targetPadding, - screenWidth: rootWidgetSize?.width ?? size.width, - screenHeight: rootWidgetSize?.height ?? size.height, - ); - showOverlay(); - } + void didUpdateWidget(covariant Showcase oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget == widget) return; + _updateControllerValues(); } - /// show overlay if there is any target widget - void showOverlay() { - final activeStep = ShowCaseWidget.activeTargetWidget(context); - setState(() { - _showShowCase = activeStep == widget.key; - }); - - if (activeStep == widget.key) { - if (widget.enableAutoScroll ?? showCaseWidgetState.enableAutoScroll) { - _scrollIntoView(); - } - - if (showCaseWidgetState.autoPlay) { - timer = Timer( - Duration(seconds: showCaseWidgetState.autoPlayDelay.inSeconds), - _nextIfAny, - ); - } - } else if (timer?.isActive ?? false) { - timer?.cancel(); - timer = null; - } - } - - void _scrollIntoView() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final keyContext = widget.key.currentContext; - if (!mounted) return; - setState(() => _isScrollRunning = true); - await Scrollable.ensureVisible( - keyContext!, - duration: showCaseWidgetState.widget.scrollDuration, - alignment: widget.scrollAlignment, - ); - if (!mounted) return; - setState(() => _isScrollRunning = false); - }); + _updateControllerValues() { + _showCaseWidgetState = ShowCaseWidget.of(context); + _controller + ..showcaseState = this + ..showCaseWidgetState = _showCaseWidgetState; } @override Widget build(BuildContext context) { - if (_enableShowcase) { - return AnchoredOverlay( - key: showCaseWidgetState.anchoredOverlayKey, - rootRenderObject: rootRenderObject, - overlayBuilder: (context, rectBound, offset) { - final size = rootWidgetSize ?? MediaQuery.of(context).size; - position = GetPosition( - rootRenderObject: rootRenderObject, - key: widget.key, - padding: widget.targetPadding, - screenWidth: size.width, - screenHeight: size.height, - ); - return buildOverlayOnTarget(offset, rectBound.size, rectBound, size); - }, - showOverlay: true, - child: widget.child, - ); - } + // This is to support hot reload + _updateControllerValues(); + + _controller.recalculateRootWidgetSize(context); return widget.child; } @override void dispose() { - timer?.cancel(); - timer = null; - super.dispose(); - } - - void initRootWidget() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - rootWidgetSize = showCaseWidgetState.rootWidgetSize; - rootRenderObject = showCaseWidgetState.rootRenderObject; - }); - } - - void recalculateRootWidgetSize() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final rootWidget = - context.findRootAncestorStateOfType>(); - rootRenderObject = rootWidget?.context.findRenderObject() as RenderBox?; - rootWidgetSize = rootWidget == null - ? MediaQuery.of(context).size - : rootRenderObject?.size; - }); - } - - Future _nextIfAny() async { - if (showCaseWidgetState.isShowCaseCompleted) return; - - if (timer != null && timer!.isActive) { - if (showCaseWidgetState.enableAutoPlayLock) { - return; - } - timer!.cancel(); - } else if (timer != null && !timer!.isActive) { - timer = null; - } - await _reverseAnimateTooltip(); - if (showCaseWidgetState.isShowCaseCompleted) return; - showCaseWidgetState.completed(widget.key); - } - - Future _getOnTargetTap() async { - if (widget.disposeOnTap == true) { - await _reverseAnimateTooltip(); - showCaseWidgetState.dismiss(); - widget.onTargetClick!(); - } else { - (widget.onTargetClick ?? _nextIfAny).call(); - } - } - - Future _getOnTooltipTap() async { - if (widget.disposeOnTap == true) { - await _reverseAnimateTooltip(); - showCaseWidgetState.dismiss(); - } - widget.onToolTipClick?.call(); - } - - /// Reverse animates the provided tooltip or - /// the custom container widget. - Future _reverseAnimateTooltip() async { - if (!mounted) return; - setState(() => _isTooltipDismissed = true); - await Future.delayed(widget.scaleAnimationDuration); - _isTooltipDismissed = false; - } - - Widget buildOverlayOnTarget( - Offset offset, - Size size, - Rect rectBound, - Size screenSize, - ) { - final mediaQuerySize = MediaQuery.of(context).size; - var blur = 0.0; - if (_showShowCase) { - blur = widget.blurValue ?? showCaseWidgetState.blurValue; - } - - // Set blur to 0 if application is running on web and - // provided blur is less than 0. - blur = kIsWeb && blur < 0 ? 0 : blur; - - if (!_showShowCase) return const Offstage(); - - return Stack( - children: [ - GestureDetector( - onTap: () { - if (!showCaseWidgetState.disableBarrierInteraction && - !widget.disableBarrierInteraction) { - _nextIfAny(); - } - widget.onBarrierClick?.call(); - }, - child: ClipPath( - clipper: RRectClipper( - area: _isScrollRunning ? Rect.zero : rectBound, - isCircle: widget.targetShapeBorder is CircleBorder, - radius: _isScrollRunning - ? BorderRadius.zero - : widget.targetBorderRadius, - overlayPadding: - _isScrollRunning ? EdgeInsets.zero : widget.targetPadding, - ), - child: blur != 0 - ? BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: Container( - width: mediaQuerySize.width, - height: mediaQuerySize.height, - decoration: BoxDecoration( - color: widget.overlayColor - - //TODO: Update when we remove support for older version - //ignore: deprecated_member_use - .withOpacity(widget.overlayOpacity), - ), - ), - ) - : Container( - width: mediaQuerySize.width, - height: mediaQuerySize.height, - decoration: BoxDecoration( - color: widget.overlayColor - //TODO: Update when we remove support for older version - //ignore: deprecated_member_use - .withOpacity(widget.overlayOpacity), - ), - ), - ), - ), - if (_isScrollRunning) Center(child: widget.scrollLoadingWidget), - if (!_isScrollRunning) ...[ - _TargetWidget( - offset: rectBound.topLeft, - size: size, - onTap: _getOnTargetTap, - radius: widget.targetBorderRadius, - onDoubleTap: widget.onTargetDoubleTap, - onLongPress: widget.onTargetLongPress, - shapeBorder: widget.targetShapeBorder, - disableDefaultChildGestures: widget.disableDefaultTargetGestures, - targetPadding: widget.targetPadding, - ), - ToolTipWidget( - position: position, - title: widget.title, - titleTextAlign: widget.titleTextAlign, - description: widget.description, - descriptionTextAlign: widget.descriptionTextAlign, - titleAlignment: widget.titleAlignment, - descriptionAlignment: widget.descriptionAlignment, - titleTextStyle: widget.titleTextStyle, - descTextStyle: widget.descTextStyle, - container: widget.container, - tooltipBackgroundColor: widget.tooltipBackgroundColor, - textColor: widget.textColor, - showArrow: widget.showArrow, - onTooltipTap: - widget.disposeOnTap == true || widget.onToolTipClick != null - ? _getOnTooltipTap - : null, - tooltipPadding: widget.tooltipPadding, - disableMovingAnimation: widget.disableMovingAnimation ?? - showCaseWidgetState.disableMovingAnimation, - disableScaleAnimation: (widget.disableScaleAnimation ?? - showCaseWidgetState.disableScaleAnimation) || - widget.container != null, - movingAnimationDuration: widget.movingAnimationDuration, - tooltipBorderRadius: widget.tooltipBorderRadius, - scaleAnimationDuration: widget.scaleAnimationDuration, - scaleAnimationCurve: widget.scaleAnimationCurve, - scaleAnimationAlignment: widget.scaleAnimationAlignment, - isTooltipDismissed: _isTooltipDismissed, - tooltipPosition: widget.tooltipPosition, - titlePadding: widget.titlePadding, - descriptionPadding: widget.descriptionPadding, - titleTextDirection: widget.titleTextDirection, - descriptionTextDirection: widget.descriptionTextDirection, - toolTipSlideEndDistance: widget.toolTipSlideEndDistance, - toolTipMargin: widget.toolTipMargin, - tooltipActionConfig: _getTooltipActionConfig(), - tooltipActions: _getTooltipActions(), - targetPadding: widget.targetPadding, - ), - if (_getFloatingActionWidget != null) _getFloatingActionWidget!, - ], - ], - ); - } - - Widget? get _getFloatingActionWidget => - widget.floatingActionWidget ?? _globalFloatingActionWidget; - - List _getTooltipActions() { - final actionData = (widget.tooltipActions?.isNotEmpty ?? false) - ? widget.tooltipActions! - : showCaseWidgetState.globalTooltipActions ?? []; - - final actionWidgets = []; - for (final action in actionData) { - /// This checks that if current widget is being showcased and there is - /// no local action has been provided and global action are needed to hide - /// then it will hide that action for current widget - if (_showShowCase && - action.hideActionWidgetForShowcase.contains(widget.key) && - (widget.tooltipActions?.isEmpty ?? true)) { - continue; - } - actionWidgets.add( - Padding( - padding: EdgeInsetsDirectional.only( - end: action != actionData.last - ? _getTooltipActionConfig().actionGap - : 0, - ), - child: TooltipActionButtonWidget( - config: action, - // We have to pass showcaseState from here because - // [TooltipActionButtonWidget] is not direct child of showcaseWidget - // so it won't be able to get the state by using it's context - showCaseState: showCaseWidgetState, - ), - ), - ); - } - return actionWidgets; - } - - TooltipActionConfig _getTooltipActionConfig() { - return widget.tooltipActionConfig ?? - showCaseWidgetState.globalTooltipActionConfig ?? - const TooltipActionConfig(); - } -} - -class _TargetWidget extends StatelessWidget { - final Offset offset; - final Size size; - final VoidCallback? onTap; - final VoidCallback? onDoubleTap; - final VoidCallback? onLongPress; - final ShapeBorder shapeBorder; - final BorderRadius? radius; - final bool disableDefaultChildGestures; - final EdgeInsets targetPadding; - - const _TargetWidget({ - required this.offset, - required this.size, - required this.shapeBorder, - required this.targetPadding, - this.onTap, - this.radius, - this.onDoubleTap, - this.onLongPress, - this.disableDefaultChildGestures = false, - }); - - @override - Widget build(BuildContext context) { - return Positioned( - top: offset.dy - targetPadding.top, - left: offset.dx - targetPadding.left, - child: disableDefaultChildGestures - ? IgnorePointer( - child: targetWidgetContent(), - ) - : MouseRegion( - cursor: SystemMouseCursors.click, - child: targetWidgetContent(), - ), - ); - } - - Widget targetWidgetContent() { - return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - onDoubleTap: onDoubleTap, - behavior: HitTestBehavior.translucent, - child: Container( - height: size.height, - width: size.width, - margin: targetPadding, - decoration: ShapeDecoration( - shape: radius != null - ? RoundedRectangleBorder(borderRadius: radius!) - : shapeBorder, - ), - ), + _showCaseWidgetState.removeShowcaseController( + key: widget.showcaseKey, + uniqueShowcaseKey: _uniqueId, ); + super.dispose(); } } diff --git a/lib/src/showcase/showcase_controller.dart b/lib/src/showcase/showcase_controller.dart new file mode 100644 index 00000000..d62778ce --- /dev/null +++ b/lib/src/showcase/showcase_controller.dart @@ -0,0 +1,425 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../get_position.dart'; +import '../models/linked_showcase_data.dart'; +import '../models/tooltip_action_config.dart'; +import '../showcase_widget.dart'; +import '../tooltip/tooltip.dart'; +import '../tooltip_action_button_widget.dart'; +import '../widget/floating_action_widget.dart'; +import 'showcase.dart'; +import 'target_widget.dart'; + +/// Controller class for managing showcase functionality +/// +/// This controller handles the lifecycle and presentation of a single showcase element. +/// It manages the position, state, and rendering of showcase elements including +/// tooltips, target highlighting, and floating action widgets. +class ShowcaseController { + /// Creates a [ShowcaseController] with required parameters + /// + /// * [id] - Unique identifier for this showcase instance + /// * [key] - Global key associated with the showcase widget + /// * [showcaseState] - Reference to the showcase state + /// * [showCaseWidgetState] - Reference to the parent showcase widget state + ShowcaseController({ + required this.id, + required this.key, + required this.showcaseState, + required this.showCaseWidgetState, + }) { + showCaseWidgetState.registerShowcaseController( + controller: this, + key: key, + showcaseId: id, + ); + initRootWidget(); + } + + /// Unique identifier for the showcase + final int id; + + /// Global key associated with the showcase widget + final GlobalKey key; + + /// Configuration for the showcase + State showcaseState; + + /// Reference to the parent showcase widget state + ShowCaseWidgetState showCaseWidgetState; + + /// Position information for the showcase target + GetPosition? position; + + /// Data model for linked showcases + LinkedShowcaseDataModel? linkedShowcaseDataModel; + + /// Optional function to reverse the animation + ValueGetter>? reverseAnimationCallback; + + /// Size of the root widget + Size? rootWidgetSize; + + /// Render box for the root widget + RenderBox? rootRenderObject; + + /// List of tooltip widgets to be displayed + List getToolTipWidget = []; + + /// Flag to track if scrolling is in progress + bool isScrollRunning = false; + + /// Blur effect value for the overlay background + double blur = 0.0; + + /// Global floating action widget to be displayed + FloatingActionWidget? globalFloatingActionWidget; + + /// Returns the Showcase widget configuration + /// + /// Provides access to all properties and settings of the current showcase widget. + /// This is used throughout the controller to access showcase configuration options. + Showcase get config => showcaseState.widget; + + /// Returns the BuildContext for this showcase + /// + /// Used for positioning calculations and widget rendering. + /// This context represents the location of the showcase target in the widget tree. + BuildContext get _context => showcaseState.context; + + /// Checks if the showcase context is still valid + /// + /// Returns true if the context is mounted (valid) and false otherwise. + /// Used to prevent operations on widgets that have been removed from the tree. + bool get _mounted => showcaseState.context.mounted; + + /// Initializes the root widget size and render object + /// + /// Must be called after the widget is mounted to ensure proper measurements. + /// Uses a post-frame callback to capture accurate widget dimensions. + void initRootWidget() { + WidgetsBinding.instance.addPostFrameCallback((_) { + rootWidgetSize = showCaseWidgetState.rootWidgetSize; + rootRenderObject = showCaseWidgetState.rootRenderObject; + }); + } + + /// Updates root widget size and render object when the context changes + /// + /// Called during build to ensure showcase positioning is correct. + /// Recalculates sizes, updates controller data, and triggers overlay updates. + /// mounted check ensure the context is still valid before proceeding. + /// * [context] The BuildContext of the showcase widget + void recalculateRootWidgetSize(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + final rootWidget = context.findRootAncestorStateOfType>(); + rootRenderObject = rootWidget?.context.findRenderObject() as RenderBox?; + rootWidgetSize = rootWidget == null + ? MediaQuery.of(context).size + : rootRenderObject?.size; + if (!showCaseWidgetState.enableShowcase) return; + updateControllerData(); + showCaseWidgetState.updateOverlay?.call( + showCaseWidgetState.isShowcaseRunning, + ); + }); + } + + /// Updates the controller's data when the showcase position or size changes + /// + /// Rebuilds the showcase overlay with updated positioning information. + /// Creates positioning data and updates the visual representation. + /// + /// Another use of this is to update the controller data just before overlay is + /// inserted so we can get the correct position. Which is need in + /// page transition case where page transition may take some time to reach + /// to it's original position + void updateControllerData() { + final renderBox = _context.findRenderObject() as RenderBox?; + final screenSize = MediaQuery.of(_context).size; + final size = rootWidgetSize ?? screenSize; + final newPosition = GetPosition( + rootRenderObject: rootRenderObject, + renderBox: renderBox, + padding: config.targetPadding, + screenWidth: size.width, + screenHeight: size.height, + ); + + position = newPosition; + final rect = newPosition.getRect(); + linkedShowcaseDataModel = LinkedShowcaseDataModel( + rect: isScrollRunning ? Rect.zero : rect, + radius: config.targetBorderRadius, + overlayPadding: isScrollRunning ? EdgeInsets.zero : config.targetPadding, + isCircle: config.targetShapeBorder is CircleBorder, + ); + + buildOverlayOnTarget( + offset: newPosition.getOffset(), + size: rect.size, + rectBound: rect, + screenSize: size, + ); + } + + /// Builds the overlay widgets for the target widget + /// + /// Includes target highlight, tooltip, and optional floating action widget. + /// Creates different widget sets based on whether scrolling is in progress. + /// + /// * [offset] The position offset of the target + /// * [size] The size of the target + /// * [rectBound] The target's bounding rectangle + /// * [screenSize] The current screen size + void buildOverlayOnTarget({ + required Offset offset, + required Size size, + required Rect rectBound, + required Size screenSize, + }) { + blur = kIsWeb + ? 0.0 + : max(0.0, config.blurValue ?? showCaseWidgetState.blurValue); + + getToolTipWidget = isScrollRunning + ? [ + Center(child: config.scrollLoadingWidget), + ] + : [ + TargetWidget( + offset: rectBound.topLeft, + size: size, + onTap: _getOnTargetTap, + radius: config.targetBorderRadius, + onDoubleTap: config.onTargetDoubleTap, + onLongPress: config.onTargetLongPress, + shapeBorder: config.targetShapeBorder, + disableDefaultChildGestures: config.disableDefaultTargetGestures, + targetPadding: config.targetPadding, + ), + ToolTipWidget( + key: ValueKey(id), + title: config.title, + titleTextAlign: config.titleTextAlign, + description: config.description, + descriptionTextAlign: config.descriptionTextAlign, + titleAlignment: config.titleAlignment, + descriptionAlignment: config.descriptionAlignment, + titleTextStyle: config.titleTextStyle, + descTextStyle: config.descTextStyle, + container: config.container, + tooltipBackgroundColor: config.tooltipBackgroundColor, + textColor: config.textColor, + showArrow: config.showArrow, + onTooltipTap: + config.disposeOnTap == true || config.onToolTipClick != null + ? _getOnTooltipTap + : null, + tooltipPadding: config.tooltipPadding, + disableMovingAnimation: config.disableMovingAnimation ?? + showCaseWidgetState.disableMovingAnimation, + disableScaleAnimation: (config.disableScaleAnimation ?? + showCaseWidgetState.disableScaleAnimation) || + config.container != null, + movingAnimationDuration: config.movingAnimationDuration, + tooltipBorderRadius: config.tooltipBorderRadius, + scaleAnimationDuration: config.scaleAnimationDuration, + scaleAnimationCurve: config.scaleAnimationCurve, + scaleAnimationAlignment: config.scaleAnimationAlignment, + tooltipPosition: config.tooltipPosition, + titlePadding: config.titlePadding, + descriptionPadding: config.descriptionPadding, + titleTextDirection: config.titleTextDirection, + descriptionTextDirection: config.descriptionTextDirection, + toolTipSlideEndDistance: config.toolTipSlideEndDistance, + toolTipMargin: config.toolTipMargin, + tooltipActionConfig: _getTooltipActionConfig(), + tooltipActions: _getTooltipActions(), + targetPadding: config.targetPadding, + showcaseController: this, + ), + if (_getFloatingActionWidget != null) _getFloatingActionWidget!, + ]; + } + + /// Callback to start the showcase + /// + /// Initializes the showcase by calculating positions and preparing visual elements. + /// This method is called when a showcase is about to be displayed to ensure all + /// positioning data is accurate and up-to-date. + /// + /// The method performs these key actions: + /// - Exits early if showcases are disabled in the parent widget + /// - Recalculates the root widget size to ensure accurate positioning + /// - Sets up any global floating action widgets + /// - Initializes position data if not already set + /// + /// This method is typically called internally by the showcase system but + /// can also be called manually to force a recalculation of showcase elements. + void startShowcase() { + if (!showCaseWidgetState.enableShowcase) return; + + recalculateRootWidgetSize(_context); + globalFloatingActionWidget = showCaseWidgetState + .globalFloatingActionWidget(config.showcaseKey) + ?.call(_context); + final size = rootWidgetSize ?? MediaQuery.of(_context).size; + position ??= GetPosition( + rootRenderObject: rootRenderObject, + renderBox: _context.findRenderObject() as RenderBox?, + padding: config.targetPadding, + screenWidth: size.width, + screenHeight: size.height, + ); + } + + /// Used to scroll the target into view + /// + /// Ensures the showcased widget is visible on screen by scrolling to it. + /// This method handles the complete scrolling process including: + /// + /// - Setting visual indicators while scrolling is in progress + /// - Updating the overlay to show loading state + /// - Performing the actual scrolling operation + /// - Refreshing the showcase display after scrolling completes + /// + /// The method shows a loading indicator during scrolling and updates + /// the showcase position after scrolling completes. It manages the + /// `isScrollRunning` state to coordinate UI updates. + /// + /// Note: Multi Showcase will not be scrolled into view + /// + /// Returns a Future that completes when scrolling is finished. If the widget + /// is unmounted during scrolling, the operation will be canceled safely. + Future scrollIntoView() async { + if (!_mounted) return; + + isScrollRunning = true; + updateControllerData(); + startShowcase(); + showCaseWidgetState.updateOverlay?.call( + showCaseWidgetState.isShowcaseRunning, + ); + await Scrollable.ensureVisible( + _context, + duration: showCaseWidgetState.widget.scrollDuration, + alignment: config.scrollAlignment, + ); + if (!_mounted) return; + + isScrollRunning = false; + updateControllerData(); + startShowcase(); + showCaseWidgetState.updateOverlay?.call( + showCaseWidgetState.isShowcaseRunning, + ); + } + + /// Moves to the next showcase if any are remaining + /// + /// Called when a showcase is completed. + /// Notifies the showcase widget state to advance to the next showcase. + void _nextIfAny() { + if (showCaseWidgetState.isShowCaseCompleted) return; + showCaseWidgetState.completed(config.showcaseKey); + } + + /// Handles target tap behavior based on configuration + /// + /// Either dismisses the showcase or moves to the next step based on configuration. + /// If [disposeOnTap] is true, dismisses the entire showcase, otherwise advances. + void _getOnTargetTap() { + if (config.disposeOnTap == true) { + showCaseWidgetState.dismiss(); + assert( + config.onTargetClick != null, + 'onTargetClick callback should be provided when disposeOnTap is true', + ); + config.onTargetClick!(); + } else { + (config.onTargetClick ?? _nextIfAny).call(); + } + } + + /// Handles tooltip tap behavior based on configuration + /// + /// Dismisses the showcase if configured to do so, and executes any configured callback. + /// If [disposeOnTap] is true, dismisses the entire showcase before executing callback. + void _getOnTooltipTap() { + if (config.disposeOnTap == true) { + showCaseWidgetState.dismiss(); + } + config.onToolTipClick?.call(); + } + + /// Retrieves tooltip action widgets based on configuration + /// + /// Filters actions that should be hidden for the current showcase. + /// Assembles action widgets with appropriate spacing and configuration. + /// + /// @return List of tooltip action widgets + List _getTooltipActions() { + final doesHaveLocalActions = config.tooltipActions?.isNotEmpty ?? false; + final actionData = doesHaveLocalActions + ? config.tooltipActions! + : showCaseWidgetState.globalTooltipActions ?? []; + final actionDataLength = actionData.length; + + return [ + for (var i = 0; i < actionDataLength; i++) + if (doesHaveLocalActions || + !actionData[i] + .hideActionWidgetForShowcase + .contains(config.showcaseKey)) + Padding( + padding: EdgeInsetsDirectional.only( + end: actionData[i] == actionData.last + ? 0 + : _getTooltipActionConfig().actionGap, + ), + child: TooltipActionButtonWidget( + config: actionData[i], + // We have to pass showcaseState from here because + // [TooltipActionButtonWidget] is not direct child of showcaseWidget + // so it won't be able to get the state by using it's context + showCaseState: showCaseWidgetState, + ), + ), + ]; + } + + /// Gets the tooltip action configuration + /// + /// Uses local config if available, falls back to global config or default. + /// Provides a consistent approach to configuration priority. + /// + /// @return The tooltip action configuration to use + TooltipActionConfig _getTooltipActionConfig() { + return config.tooltipActionConfig ?? + showCaseWidgetState.globalTooltipActionConfig ?? + const TooltipActionConfig(); + } + + /// Retrieves the floating action widget if available + /// + /// Combines local widget with global floating action widget. + /// Prefers local configuration over global when available. + /// + /// @return The floating action widget or null if none is configured + Widget? get _getFloatingActionWidget => + config.floatingActionWidget ?? globalFloatingActionWidget; + + @override + int get hashCode => Object.hash(id, key); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is ShowcaseController && other.key == key && other.id == id; + } +} diff --git a/lib/src/showcase/target_widget.dart b/lib/src/showcase/target_widget.dart new file mode 100644 index 00000000..7bddb12c --- /dev/null +++ b/lib/src/showcase/target_widget.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +/// A widget that represents the target of a showcase. +/// +/// This widget creates a transparent overlay that highlights a UI element +/// that needs to be showcased. It defines the position, size, and interaction +/// behavior of the target area. +/// +/// The [TargetWidget] is positioned absolutely within the showcase overlay and +/// can respond to various gestures like tap, double tap, and long press. +class TargetWidget extends StatelessWidget { + /// Creates a target widget for showcasing. + /// + /// * [offset] - The position of the target widget in the overlay + /// * [size] - The size of the target widget + /// * [shapeBorder] - The shape of the target highlight (e.g., circle) + /// * [targetPadding] - Padding applied around the target to increase its + /// highlight area + /// * [onTap] - Callback when the target is tapped + /// * [radius] - Border radius when using a rectangular shape + /// * [onDoubleTap] - Callback when the target is double-tapped + /// * [onLongPress] - Callback when the target is long-pressed + /// * [disableDefaultChildGestures] - Whether to disable gesture detection + /// on the target + const TargetWidget({ + required this.offset, + required this.size, + required this.shapeBorder, + required this.targetPadding, + this.onTap, + this.radius, + this.onDoubleTap, + this.onLongPress, + this.disableDefaultChildGestures = false, + }); + + /// The position of the target widget in the overlay coordinates + final Offset offset; + + /// The size of the target widget to be highlighted + final Size size; + + /// Callback function when the target is tapped + final VoidCallback? onTap; + + /// Callback function when the target is double-tapped + final VoidCallback? onDoubleTap; + + /// Callback function when the target is long-pressed + final VoidCallback? onLongPress; + + /// The shape of the target highlight + /// + /// Common shapes include [CircleBorder] and [RoundedRectangleBorder] + final ShapeBorder shapeBorder; + + /// Border radius when using a rectangular shape + /// + /// This is used only when a rectangular shape is needed with rounded corners + final BorderRadius? radius; + + /// Whether to disable gesture detection on the target area + /// + /// When true, the target area will not respond to gestures and will + /// not show a pointer cursor on hover + final bool disableDefaultChildGestures; + + /// Padding applied around the target to increase its highlight area + /// + /// This creates some space between the actual widget and its highlight border + final EdgeInsets targetPadding; + + @override + Widget build(BuildContext context) { + /// Creates the content of the target widget + /// + /// This includes the gesture detector and the container with the appropriate + /// shape decoration that defines the target's visual appearance. + final targetWidgetContent = GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + onDoubleTap: onDoubleTap, + behavior: HitTestBehavior.translucent, + child: Container( + height: size.height.abs(), + width: size.width.abs(), + margin: targetPadding, + decoration: ShapeDecoration( + shape: radius == null + ? shapeBorder + : RoundedRectangleBorder(borderRadius: radius!), + ), + ), + ); + return Positioned( + top: offset.dy - targetPadding.top, + left: offset.dx - targetPadding.left, + child: disableDefaultChildGestures + ? IgnorePointer( + child: targetWidgetContent, + ) + : MouseRegion( + cursor: SystemMouseCursors.click, + child: targetWidgetContent, + ), + ); + } +} diff --git a/lib/src/showcase_widget.dart b/lib/src/showcase_widget.dart index 34544c0e..3d930b74 100644 --- a/lib/src/showcase_widget.dart +++ b/lib/src/showcase_widget.dart @@ -20,9 +20,16 @@ * SOFTWARE. */ +import 'dart:async'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import '../showcaseview.dart'; +import 'models/linked_showcase_data.dart'; +import 'overlay_builder.dart'; +import 'shape_clipper.dart'; +import 'showcase/showcase_controller.dart'; typedef FloatingActionBuilderCallback = FloatingActionWidget Function( BuildContext context, @@ -163,11 +170,9 @@ class ShowCaseWidget extends StatefulWidget { this.hideFloatingActionWidgetForShowcase = const [], }); - static GlobalKey? activeTargetWidget(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_InheritedShowCaseView>() - ?.activeWidgetIds; - } + static GlobalKey? activeTargetWidget(BuildContext context) => context + .findAncestorStateOfType() + ?.getCurrentActiveShowcaseKey; static ShowCaseWidgetState of(BuildContext context) { final state = context.findAncestorStateOfType(); @@ -187,7 +192,6 @@ class ShowCaseWidgetState extends State { int? activeWidgetId; RenderBox? rootRenderObject; Size? rootWidgetSize; - final anchoredOverlayKey = UniqueKey(); late final TooltipActionConfig? globalTooltipActionConfig; @@ -213,14 +217,22 @@ class ShowCaseWidgetState extends State { bool get isShowCaseCompleted => ids == null && activeWidgetId == null; + Duration get scrollDuration => widget.scrollDuration; + List get hiddenFloatingActionKeys => _hideFloatingWidgetKeys.keys.toList(); - /// This Stores keys of showcase for which we will hide the - /// [globalFloatingActionWidget]. - late final _hideFloatingWidgetKeys = { - for (final item in widget.hideFloatingActionWidgetForShowcase) item: true, - }; + ValueSetter? updateOverlay; + + /// Return a [widget.globalFloatingActionWidget] if not need to hide this for + /// current showcase. + FloatingActionBuilderCallback? globalFloatingActionWidget( + GlobalKey showcaseKey, + ) { + return _hideFloatingWidgetKeys[showcaseKey] ?? false + ? null + : widget.globalFloatingActionWidget; + } /// Returns value of [ShowCaseWidget.blurValue] double get blurValue => widget.blurValue; @@ -236,56 +248,150 @@ class ShowCaseWidgetState extends State { } } - /// Return a [widget.globalFloatingActionWidget] if not need to hide this for - /// current showcase. - FloatingActionBuilderCallback? get globalFloatingActionWidget => - _hideFloatingWidgetKeys[getCurrentActiveShowcaseKey] ?? false - ? null - : widget.globalFloatingActionWidget; + bool get isShowcaseRunning => getCurrentActiveShowcaseKey != null; + + Timer? _timer; + + /// A mapping of showcase keys to their associated controllers. + /// - Key: GlobalKey of a showcase (provided by user) + /// - Value: Map of showcase IDs to their controllers, + /// allowing multiple controllers + /// to be associated with a single showcase key (e.g., for linked showcases) + final Map> _showcaseControllers = {}; + + /// This Stores keys of showcase for which we will hide the + /// [globalFloatingActionWidget]. + late final _hideFloatingWidgetKeys = { + for (final item in widget.hideFloatingActionWidgetForShowcase) item: true, + }; + + List get _getCurrentActiveControllers { + return _showcaseControllers[getCurrentActiveShowcaseKey]?.values.toList() ?? + []; + } @override void initState() { super.initState(); globalTooltipActions = widget.globalTooltipActions; globalTooltipActionConfig = widget.globalTooltipActionConfig; - initRootWidget(); + _initRootWidget(); } - void initRootWidget() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final rootWidget = context.findAncestorStateOfType>(); - rootRenderObject = rootWidget?.context.findRenderObject() as RenderBox?; - rootWidgetSize = rootWidget == null - ? MediaQuery.of(context).size - : rootRenderObject?.size; - }); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _updateRootWidget(); + } + + @override + void didUpdateWidget(covariant ShowCaseWidget oldWidget) { + super.didUpdateWidget(oldWidget); + _updateRootWidget(); + } + + @override + Widget build(BuildContext context) { + return OverlayBuilder( + updateOverlay: (updateOverlays) => updateOverlay = updateOverlays, + overlayBuilder: (_) { + final controller = _getCurrentActiveControllers; + + if (getCurrentActiveShowcaseKey == null || controller.isEmpty) { + return const SizedBox.shrink(); + } + + final controllerLength = controller.length; + for (var i = 0; i < controllerLength; i++) { + controller[i].updateControllerData(); + } + + final firstController = controller.first; + final firstShowcaseConfig = firstController.config; + + final backgroundContainer = ColoredBox( + color: firstShowcaseConfig.overlayColor + + //TODO: Update when we remove support for older version + //ignore: deprecated_member_use + .withOpacity(firstShowcaseConfig.overlayOpacity), + child: const Align(), + ); + + return Stack( + children: [ + GestureDetector( + onTap: () => _barrierOnTap(firstShowcaseConfig), + child: ClipPath( + clipper: RRectClipper( + area: Rect.zero, + isCircle: false, + radius: BorderRadius.zero, + overlayPadding: EdgeInsets.zero, + linkedObjectData: _getLinkedShowcasesData(controller), + ), + child: firstController.blur == 0 + ? backgroundContainer + : BackdropFilter( + filter: ImageFilter.blur( + sigmaX: firstController.blur, + sigmaY: firstController.blur, + ), + child: backgroundContainer, + ), + ), + ), + ...controller.expand((object) => object.getToolTipWidget).toList(), + ], + ); + }, + child: Builder(builder: widget.builder), + ); } /// Starts Showcase view from the beginning of specified list of widget ids. /// If this function is used when showcase has been disabled then it will /// throw an exception. - void startShowCase(List widgetIds) { + /// + /// [delay] is optional and it will be used to delay the start of showcase + /// which is useful when animation may take some time to complete. + /// + /// Refer this issue https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/378 + void startShowCase( + List widgetIds, { + Duration delay = Duration.zero, + }) { + if (!mounted) return; if (!enableShowcase) { throw Exception( "You are trying to start Showcase while it has been disabled with " "`enableShowcase` parameter to false from ShowCaseWidget", ); } - if (!mounted) return; - setState(() { + + if (delay.inMilliseconds == 0) { ids = widgetIds; activeWidgetId = 0; _onStart(); - }); + updateOverlay?.call(isShowcaseRunning); + } else { + Future.delayed( + delay, + () => startShowCase(widgetIds), + ); + } } /// Completes showcase of given key and starts next one /// otherwise will finish the entire showcase view void completed(GlobalKey? key) { - if (ids != null && ids![activeWidgetId!] == key && mounted) { - setState(() { - _onComplete(); + if (activeWidgetId == null || ids?[activeWidgetId!] != key || !mounted) { + return; + } + _onComplete().then( + (_) { + if (!mounted) return; activeWidgetId = activeWidgetId! + 1; _onStart(); @@ -293,41 +399,60 @@ class ShowCaseWidgetState extends State { _cleanupAfterSteps(); widget.onFinish?.call(); } - }); - } + updateOverlay?.call(isShowcaseRunning); + }, + ); } /// Completes current active showcase and starts next one /// otherwise will finish the entire showcase view - void next() { - if (ids != null && mounted) { - setState(() { - _onComplete(); + /// + /// if [force] is true then it will ignore the [enableAutoPlayLock] and + /// move to next showcase. This is default behaviour for + /// [TooltipDefaultActionType.next] + void next({bool force = false}) { + // If this call is from autoPlay timer or action widget we will override the + // enableAutoPlayLock so user can move forward in showcase + if ((!force && widget.enableAutoPlayLock) || ids == null || !mounted) { + return; + } + + /// We are using [.then] to maintain older functionality. + /// here [_onComplete] method waits for animation to complete so we need + /// to wait before moving to next showcase + _onComplete().then( + (_) { + if (!mounted) return; activeWidgetId = activeWidgetId! + 1; _onStart(); - if (activeWidgetId! >= ids!.length) { _cleanupAfterSteps(); widget.onFinish?.call(); } - }); - } + updateOverlay?.call(isShowcaseRunning); + }, + ); } /// Completes current active showcase and starts previous one /// otherwise will finish the entire showcase view void previous() { - if (ids != null && ((activeWidgetId ?? 0) - 1) >= 0 && mounted) { - setState(() { - _onComplete(); + if (ids == null || ((activeWidgetId ?? 0) - 1).isNegative || !mounted) { + return; + } + _onComplete().then( + (_) { + if (!mounted) return; + activeWidgetId = activeWidgetId! - 1; _onStart(); if (activeWidgetId! >= ids!.length) { _cleanupAfterSteps(); widget.onFinish?.call(); } - }); - } + updateOverlay?.call(isShowcaseRunning); + }, + ); } /// Dismiss entire showcase view @@ -339,23 +464,10 @@ class ShowCaseWidgetState extends State { activeWidgetId == null || ids == null || ids!.length < activeWidgetId!; widget.onDismiss?.call(idNotExist ? null : ids?[activeWidgetId!]); + if (!mounted) return; - if (mounted) setState(_cleanupAfterSteps); - } - - void _onStart() { - if (activeWidgetId! < ids!.length) { - widget.onStart?.call(activeWidgetId, ids![activeWidgetId!]); - } - } - - void _onComplete() { - widget.onComplete?.call(activeWidgetId, ids![activeWidgetId!]); - } - - void _cleanupAfterSteps() { - ids = null; - activeWidgetId = null; + _cleanupAfterSteps.call(); + updateOverlay?.call(isShowcaseRunning); } /// Disables the [globalFloatingActionWidget] for the provided keys. @@ -364,29 +476,149 @@ class ShowCaseWidgetState extends State { ) { _hideFloatingWidgetKeys ..clear() - ..addAll({for (final item in updatedList) item: true}); + ..addAll({ + for (final item in updatedList) item: true, + }); } - @override - Widget build(BuildContext context) { - return _InheritedShowCaseView( - activeWidgetIds: ids?.elementAt(activeWidgetId!), - child: Builder( - builder: widget.builder, - ), + void registerShowcaseController({ + required GlobalKey key, + required ShowcaseController controller, + required int showcaseId, + }) { + assert( + StackTrace.current.toString().contains('_ShowcaseState'), + 'This method should only be called from `Showcase` class', ); + _showcaseControllers + .putIfAbsent( + key, + () => {}, + ) + .update( + showcaseId, + (value) => controller, + ifAbsent: () => controller, + ); + } + + void removeShowcaseController({ + required GlobalKey key, + required int uniqueShowcaseKey, + }) { + assert( + StackTrace.current.toString().contains('_ShowcaseState'), + 'This method should only be called from `Showcase` class', + ); + _showcaseControllers[key]?.remove(uniqueShowcaseKey); } -} -class _InheritedShowCaseView extends InheritedWidget { - final GlobalKey? activeWidgetIds; + ShowcaseController getControllerForShowcase({ + required GlobalKey key, + required int showcaseId, + }) { + assert( + StackTrace.current.toString().contains('_ShowcaseState'), + 'This method should only be called from `Showcase` class', + ); + assert( + _showcaseControllers[key]?[showcaseId] != null, + 'Please register showcase controller first by calling ' + 'registerShowcaseController', + ); + return _showcaseControllers[key]![showcaseId]!; + } - const _InheritedShowCaseView({ - required this.activeWidgetIds, - required super.child, - }); + List _getLinkedShowcasesData( + List controllers, + ) { + final controllerLength = controllers.length; + return [ + for (var i = 0; i < controllerLength; i++) + if (controllers[i].linkedShowcaseDataModel != null) + controllers[i].linkedShowcaseDataModel!, + ]; + } - @override - bool updateShouldNotify(_InheritedShowCaseView oldWidget) => - oldWidget.activeWidgetIds != activeWidgetIds; + void _updateRootWidget() { + if (!mounted) return; + final rootWidget = context.findRootAncestorStateOfType>(); + rootRenderObject = rootWidget?.context.findRenderObject() as RenderBox?; + rootWidgetSize = rootWidget == null + ? MediaQuery.of(context).size + : rootRenderObject?.size; + } + + void _barrierOnTap(Showcase firstShowcaseConfig) { + firstShowcaseConfig.onBarrierClick?.call(); + if (disableBarrierInteraction || + firstShowcaseConfig.disableBarrierInteraction) { + return; + } + next(); + } + + void _initRootWidget() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final rootWidget = context.findRootAncestorStateOfType>(); + rootRenderObject = rootWidget?.context.findRenderObject() as RenderBox?; + rootWidgetSize = rootWidget == null + ? MediaQuery.of(context).size + : rootRenderObject?.size; + }); + } + + Future _onStart() async { + if (activeWidgetId! < ids!.length) { + widget.onStart?.call(activeWidgetId, ids![activeWidgetId!]); + final controllers = _getCurrentActiveControllers; + final controllerLength = controllers.length; + for (var i = 0; i < controllerLength; i++) { + final controller = controllers[i]; + final isAutoScroll = + controller.config.enableAutoScroll ?? widget.enableAutoScroll; + if (controllerLength == 1 && isAutoScroll) { + await controller.scrollIntoView(); + } else { + controller.startShowcase(); + } + } + } + + if (widget.autoPlay) { + _cancelTimer(); + _timer = Timer( + Duration(seconds: widget.autoPlayDelay.inSeconds), + () => next(force: true), + ); + } + } + + Future _onComplete() async { + final currentControllers = _getCurrentActiveControllers; + final controllerLength = currentControllers.length; + + await Future.wait([ + for (var i = 0; i < controllerLength; i++) + if (!(currentControllers[i].config.disableScaleAnimation ?? + widget.disableScaleAnimation) && + currentControllers[i].reverseAnimationCallback != null) + currentControllers[i].reverseAnimationCallback!.call(), + ]); + widget.onComplete?.call(activeWidgetId, ids![activeWidgetId!]); + if (widget.autoPlay) _cancelTimer(); + } + + void _cancelTimer() { + if (!(_timer?.isActive ?? false)) return; + _timer?.cancel(); + _timer = null; + } + + void _cleanupAfterSteps() { + ids = null; + activeWidgetId = null; + _cancelTimer(); + } } diff --git a/lib/src/tooltip/animated_tooltip_layout.dart b/lib/src/tooltip/animated_tooltip_layout.dart index 271f3930..ca0ba63f 100644 --- a/lib/src/tooltip/animated_tooltip_layout.dart +++ b/lib/src/tooltip/animated_tooltip_layout.dart @@ -1,7 +1,10 @@ part of 'tooltip.dart'; class _AnimatedTooltipMultiLayout extends MultiChildRenderObjectWidget { - const _AnimatedTooltipMultiLayout({ + // TODO: make this const when update to new flutter version + // ignore: prefer_const_constructors_in_immutables + _AnimatedTooltipMultiLayout({ + // If we remove this parameter it will cause error in v3.29.0 so ignore // ignore: unused_element_parameter super.key, required this.scaleController, @@ -20,6 +23,7 @@ class _AnimatedTooltipMultiLayout extends MultiChildRenderObjectWidget { required this.scaleAlignment, required this.screenEdgePadding, required this.targetPadding, + required this.showcaseOffset, }); final AnimationController scaleController; @@ -37,6 +41,7 @@ class _AnimatedTooltipMultiLayout extends MultiChildRenderObjectWidget { final Alignment? scaleAlignment; final double screenEdgePadding; final EdgeInsets targetPadding; + final Offset showcaseOffset; @override RenderObject createRenderObject(BuildContext context) { @@ -56,6 +61,7 @@ class _AnimatedTooltipMultiLayout extends MultiChildRenderObjectWidget { toolTipSlideEndDistance: toolTipSlideEndDistance, screenEdgePadding: screenEdgePadding, targetPadding: targetPadding, + showcaseOffset: showcaseOffset, ); } @@ -78,6 +84,7 @@ class _AnimatedTooltipMultiLayout extends MultiChildRenderObjectWidget { ..screenEdgePadding = screenEdgePadding ..toolTipSlideEndDistance = toolTipSlideEndDistance ..gapBetweenContentAndAction = gapBetweenContentAndAction - ..targetPadding = targetPadding; + ..targetPadding = targetPadding + ..showcaseOffset = showcaseOffset; } } diff --git a/lib/src/tooltip/render_animation_delegate.dart b/lib/src/tooltip/render_animation_delegate.dart index 349d6db3..b8e1d08c 100644 --- a/lib/src/tooltip/render_animation_delegate.dart +++ b/lib/src/tooltip/render_animation_delegate.dart @@ -29,6 +29,7 @@ class _RenderAnimationDelegate extends _RenderPositionDelegate { required super.toolTipSlideEndDistance, required super.screenEdgePadding, required super.targetPadding, + required super.showcaseOffset, }) : _scaleController = scaleController, _moveController = moveController, _scaleAnimation = scaleAnimation, diff --git a/lib/src/tooltip/render_position_delegate.dart b/lib/src/tooltip/render_position_delegate.dart index 2642e938..15ea6e48 100644 --- a/lib/src/tooltip/render_position_delegate.dart +++ b/lib/src/tooltip/render_position_delegate.dart @@ -30,6 +30,7 @@ class _RenderPositionDelegate extends RenderBox required this.gapBetweenContentAndAction, required this.screenEdgePadding, required this.targetPadding, + required this.showcaseOffset, }); // Core positioning parameters @@ -44,6 +45,12 @@ class _RenderPositionDelegate extends RenderBox double screenEdgePadding; EdgeInsets targetPadding; + /// This is used when there is some space around showcaseview as this widget + /// implementation works in global coordinate system so because of that we + /// need to manage local position by our self + /// To check this usecase wrap material app in padding widget + Offset showcaseOffset; + /// Calculated tooltip position after layout late TooltipPosition tooltipPosition; @@ -58,7 +65,7 @@ class _RenderPositionDelegate extends RenderBox @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { // Standard hit testing implementation for children - return defaultHitTestChildren(result, position: position); + return defaultHitTestChildren(result, position: position + showcaseOffset); } @override @@ -198,16 +205,23 @@ class _RenderPositionDelegate extends RenderBox double maxHeight = tooltipHeight; // Horizontal boundary handling - if (xOffset < screenEdgePadding) { + if (xOffset < screenEdgePadding + showcaseOffset.dx) { // Tooltip extends beyond left edge if (tooltipPosition.isLeft) { // When positioned left, we have a few options: - var minWidth = maxWidth - screenEdgePadding + xOffset.abs(); + var minWidth = targetPosition.dx - + showcaseOffset.dx - + screenEdgePadding - + Constants.tooltipOffset - + targetPadding.left; + minWidth -= hasArrow + ? Constants.withArrowToolTipPadding + : Constants.withOutArrowToolTipPadding; if (minWidth > Constants.minimumToolTipWidth && minWidth > minimumActionBoxSize.width) { // Option 1: Resize tooltip to fit - maxWidth -= screenEdgePadding + xOffset.abs(); - xOffset = screenEdgePadding; + maxWidth = minWidth; + xOffset = screenEdgePadding + showcaseOffset.dx; needToResize = true; } else if (_fitsInPosition( TooltipPosition.right, @@ -241,7 +255,7 @@ class _RenderPositionDelegate extends RenderBox } else { // Option 5: Last resort - resize and keep at left maxWidth -= screenEdgePadding - xOffset; - xOffset = screenEdgePadding; + xOffset = screenEdgePadding + showcaseOffset.dx; needToResize = true; } } else if (tooltipPosition.isVertical) { @@ -250,14 +264,17 @@ class _RenderPositionDelegate extends RenderBox maxWidth = availableScreenWidth; needToResize = true; } - xOffset = screenEdgePadding; + xOffset = screenEdgePadding + showcaseOffset.dx; } - } else if (xOffset + toolTipBoxSize.width > + } else if (xOffset + toolTipBoxSize.width - showcaseOffset.dx > screenSize.width - screenEdgePadding) { // Tooltip extends beyond right edge if (tooltipPosition.isRight) { // When positioned right, similar options as with left position - var minWidth = screenSize.width - screenEdgePadding - xOffset; + var minWidth = screenSize.width - + screenEdgePadding - + xOffset - + targetPadding.right; if (minWidth > Constants.minimumToolTipWidth && minWidth > minimumActionBoxSize.width) { @@ -299,10 +316,13 @@ class _RenderPositionDelegate extends RenderBox if (maxWidth > availableScreenWidth) { maxWidth = availableScreenWidth; needToResize = true; - xOffset = screenEdgePadding; + xOffset = screenEdgePadding + showcaseOffset.dx; } else { // Align to right edge - xOffset = screenSize.width - screenEdgePadding - toolTipBoxSize.width; + xOffset = screenSize.width - + screenEdgePadding - + toolTipBoxSize.width + + showcaseOffset.dx; } } } @@ -320,9 +340,16 @@ class _RenderPositionDelegate extends RenderBox if (hasSecondBox) { maxHeight += (actionBoxSize.height + gapBetweenContentAndAction); } + var extraVerticalComponentHeight = 0.0; + if (tooltipPosition.isVertical) { + extraVerticalComponentHeight += Constants.tooltipOffset; + extraVerticalComponentHeight += hasArrow + ? Constants.withArrowToolTipPadding + : Constants.withOutArrowToolTipPadding; + } // Vertical boundary handling - if (yOffset < screenEdgePadding) { + if (yOffset < screenEdgePadding + showcaseOffset.dy) { // Tooltip extends beyond top edge if (tooltipPosition.isTop) { // When positioned at top, check options @@ -358,7 +385,7 @@ class _RenderPositionDelegate extends RenderBox } else { // Option 4: Last resort - resize and keep at top maxHeight -= screenEdgePadding - xOffset; - yOffset = screenEdgePadding; + yOffset = screenEdgePadding + showcaseOffset.dy; needToResize = true; } } else if (tooltipPosition.isHorizontal) { @@ -367,9 +394,12 @@ class _RenderPositionDelegate extends RenderBox maxHeight = availableScreenHeight; needToResize = true; } - yOffset = screenEdgePadding; + yOffset = screenEdgePadding + showcaseOffset.dy; } - } else if (yOffset + tooltipHeight > + } else if (yOffset + + maxHeight + + extraVerticalComponentHeight - + showcaseOffset.dy > screenSize.height - screenEdgePadding) { // Tooltip extends beyond bottom edge if (tooltipPosition.isBottom) { @@ -405,18 +435,25 @@ class _RenderPositionDelegate extends RenderBox needToResize = true; } else { // Option 4: Last resort - resize and keep at bottom - maxHeight = screenSize.height - yOffset - screenEdgePadding; + maxHeight += extraVerticalComponentHeight; needToResize = true; + yOffset = screenSize.height - + showcaseOffset.dy - + screenEdgePadding - + maxHeight; } } else { // For left/right positions, ensure height fits and adjust alignment if (maxHeight > availableScreenHeight) { maxHeight = availableScreenHeight; needToResize = true; - yOffset = screenEdgePadding; + yOffset = screenEdgePadding + showcaseOffset.dy; } else { // Align to bottom edge - yOffset = screenSize.height - screenEdgePadding - tooltipHeight; + yOffset = screenSize.height - + screenEdgePadding - + tooltipHeight + + showcaseOffset.dy; } } } @@ -524,14 +561,17 @@ class _RenderPositionDelegate extends RenderBox // Ensure tooltip stays within horizontal screen bounds xOffset = xOffset.clamp( - screenEdgePadding, - screenSize.width - toolTipBoxSize.width - screenEdgePadding, + screenEdgePadding + showcaseOffset.dx, + screenSize.width - + toolTipBoxSize.width - + screenEdgePadding + + showcaseOffset.dx, ); // Ensure tooltip stays within vertical screen bounds yOffset = yOffset.clamp( - screenEdgePadding, - screenSize.height - tooltipHeight - screenEdgePadding, + screenEdgePadding + showcaseOffset.dy, + screenSize.height - tooltipHeight - screenEdgePadding + showcaseOffset.dy, ); switch (tooltipPosition) { @@ -768,6 +808,10 @@ class _RenderPositionDelegate extends RenderBox Size tooltipSize, double totalHeight, ) { + final arrowPadding = hasArrow + ? Constants.withArrowToolTipPadding + : Constants.withOutArrowToolTipPadding; + switch (pos) { case TooltipPosition.bottom: // Check if tooltip fits below target @@ -775,9 +819,8 @@ class _RenderPositionDelegate extends RenderBox targetSize.height + totalHeight + Constants.tooltipOffset + - (hasArrow - ? Constants.withArrowToolTipPadding - : Constants.withOutArrowToolTipPadding) <= + arrowPadding - + showcaseOffset.dy <= screenSize.height - screenEdgePadding; case TooltipPosition.top: @@ -785,9 +828,8 @@ class _RenderPositionDelegate extends RenderBox return targetPosition.dy - totalHeight - Constants.tooltipOffset - - (hasArrow - ? Constants.withArrowToolTipPadding - : Constants.withOutArrowToolTipPadding) >= + arrowPadding - + showcaseOffset.dy >= screenEdgePadding; case TooltipPosition.left: @@ -795,9 +837,8 @@ class _RenderPositionDelegate extends RenderBox return targetPosition.dx - tooltipSize.width - Constants.tooltipOffset - - (hasArrow - ? Constants.withArrowToolTipPadding - : Constants.withOutArrowToolTipPadding) >= + arrowPadding - + showcaseOffset.dx >= screenEdgePadding; case TooltipPosition.right: @@ -806,9 +847,8 @@ class _RenderPositionDelegate extends RenderBox targetSize.width + tooltipSize.width + Constants.tooltipOffset + - (hasArrow - ? Constants.withArrowToolTipPadding - : Constants.withOutArrowToolTipPadding) <= + arrowPadding - + showcaseOffset.dx <= screenSize.width - screenEdgePadding; } } diff --git a/lib/src/tooltip/tooltip.dart b/lib/src/tooltip/tooltip.dart index f63bbcbc..3a02fcd9 100644 --- a/lib/src/tooltip/tooltip.dart +++ b/lib/src/tooltip/tooltip.dart @@ -3,8 +3,8 @@ import 'package:flutter/rendering.dart'; import '../constants.dart'; import '../enum.dart'; -import '../get_position.dart'; import '../models/tooltip_action_config.dart'; +import '../showcase/showcase_controller.dart'; import '../widget/action_widget.dart'; import '../widget/default_tooltip_text_widget.dart'; import 'render_object_manager.dart'; diff --git a/lib/src/tooltip/tooltip_widget.dart b/lib/src/tooltip/tooltip_widget.dart index e27c01ec..0e4c5068 100644 --- a/lib/src/tooltip/tooltip_widget.dart +++ b/lib/src/tooltip/tooltip_widget.dart @@ -1,43 +1,8 @@ part of "tooltip.dart"; class ToolTipWidget extends StatefulWidget { - final GetPosition? position; - final String? title; - final TextAlign? titleTextAlign; - final String? description; - final TextAlign? descriptionTextAlign; - final AlignmentGeometry titleAlignment; - final AlignmentGeometry descriptionAlignment; - final TextStyle? titleTextStyle; - final TextStyle? descTextStyle; - final Widget? container; - final Color? tooltipBackgroundColor; - final Color? textColor; - final bool showArrow; - final VoidCallback? onTooltipTap; - final EdgeInsets? tooltipPadding; - final Duration movingAnimationDuration; - final bool disableMovingAnimation; - final bool disableScaleAnimation; - final BorderRadius? tooltipBorderRadius; - final Duration scaleAnimationDuration; - final Curve scaleAnimationCurve; - final Alignment? scaleAnimationAlignment; - final bool isTooltipDismissed; - final TooltipPosition? tooltipPosition; - final EdgeInsets? titlePadding; - final EdgeInsets? descriptionPadding; - final TextDirection? titleTextDirection; - final TextDirection? descriptionTextDirection; - final double toolTipSlideEndDistance; - final double toolTipMargin; - final TooltipActionConfig tooltipActionConfig; - final List tooltipActions; - final EdgeInsets targetPadding; - const ToolTipWidget({ super.key, - required this.position, required this.title, required this.description, required this.titleTextStyle, @@ -61,17 +26,50 @@ class ToolTipWidget extends StatefulWidget { required this.scaleAnimationDuration, required this.scaleAnimationCurve, required this.toolTipMargin, + required this.showcaseController, + required this.tooltipPadding, + required this.toolTipSlideEndDistance, this.scaleAnimationAlignment, - this.isTooltipDismissed = false, this.tooltipPosition, this.titlePadding, this.descriptionPadding, this.titleTextDirection, this.descriptionTextDirection, - this.toolTipSlideEndDistance = 7, - this.tooltipPadding = const EdgeInsets.symmetric(vertical: 8), }); + final String? title; + final TextAlign? titleTextAlign; + final String? description; + final TextAlign? descriptionTextAlign; + final AlignmentGeometry titleAlignment; + final AlignmentGeometry descriptionAlignment; + final TextStyle? titleTextStyle; + final TextStyle? descTextStyle; + final Widget? container; + final Color? tooltipBackgroundColor; + final Color? textColor; + final bool showArrow; + final VoidCallback? onTooltipTap; + final EdgeInsets? tooltipPadding; + final Duration movingAnimationDuration; + final bool disableMovingAnimation; + final bool disableScaleAnimation; + final BorderRadius? tooltipBorderRadius; + final Duration scaleAnimationDuration; + final Curve scaleAnimationCurve; + final Alignment? scaleAnimationAlignment; + final TooltipPosition? tooltipPosition; + final EdgeInsets? titlePadding; + final EdgeInsets? descriptionPadding; + final TextDirection? titleTextDirection; + final TextDirection? descriptionTextDirection; + final double toolTipSlideEndDistance; + final double toolTipMargin; + final TooltipActionConfig tooltipActionConfig; + final List tooltipActions; + final EdgeInsets targetPadding; + final ShowcaseController showcaseController; + @override State createState() => _ToolTipWidgetState(); } @@ -118,22 +116,22 @@ class _ToolTipWidgetState extends State if (!widget.disableMovingAnimation) { _movingAnimationController.forward(); } + widget.showcaseController.reverseAnimationCallback = + widget.disableScaleAnimation ? null : _scaleAnimationController.reverse; } @override - void didUpdateWidget(covariant ToolTipWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (!widget.disableScaleAnimation && widget.isTooltipDismissed) { - WidgetsBinding.instance.addPostFrameCallback( - (timeStamp) { - _scaleAnimationController.reverse(); - }, - ); + Widget build(BuildContext context) { + // Calculate the target position and size + final box = widget.showcaseController.position?.renderBox; + // This is a workaround to avoid the error when the widget is not mounted + // but won't happen in general cases + if (box == null) { + return const SizedBox.shrink(); } - } + final targetPosition = box.localToGlobal(Offset.zero); + final targetSize = box.size; - @override - Widget build(BuildContext context) { final defaultToolTipWidget = widget.container != null ? MouseRegion( cursor: widget.onTooltipTap == null @@ -228,10 +226,6 @@ class _ToolTipWidgetState extends State ), ); - // Calculate the target position and size - final targetPosition = widget.position!.box!.localToGlobal(Offset.zero); - final targetSize = widget.position!.box!.size; - return Material( type: MaterialType.transparency, child: _AnimatedTooltipMultiLayout( @@ -242,7 +236,8 @@ class _ToolTipWidgetState extends State targetPosition: targetPosition, targetSize: targetSize, position: widget.tooltipPosition, - screenSize: MediaQuery.of(context).size, + screenSize: widget.showcaseController.rootWidgetSize ?? + MediaQuery.of(context).size, hasArrow: widget.showArrow, targetPadding: widget.targetPadding, scaleAlignment: widget.scaleAnimationAlignment, @@ -253,6 +248,9 @@ class _ToolTipWidgetState extends State gapBetweenContentAndAction: widget.tooltipActionConfig.gapBetweenContentAndAction, screenEdgePadding: widget.toolTipMargin, + showcaseOffset: widget.showcaseController.rootRenderObject + ?.localToGlobal(Offset.zero) ?? + Offset.zero, children: [ _TooltipLayoutId( id: TooltipLayoutSlot.tooltipBox, diff --git a/lib/src/widget/showcase_circular_progress_indicator.dart b/lib/src/widget/showcase_circular_progress_indicator.dart new file mode 100644 index 00000000..3bc40e6f --- /dev/null +++ b/lib/src/widget/showcase_circular_progress_indicator.dart @@ -0,0 +1,28 @@ +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart'; + +import '../constants.dart'; + +class ShowcaseCircularProgressIndicator extends StatelessWidget { + const ShowcaseCircularProgressIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return const CupertinoActivityIndicator( + radius: Constants.cupertinoActivityIndicatorRadius, + color: Colors.white, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 962e5874..8af14d99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ issue_tracker: https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/is repository: https://github.com/simformsolutions/flutter_showcaseview environment: - sdk: '>=2.18.0 <4.0.0' + sdk: '>=2.19.6 <4.0.0' dependencies: flutter: