From 78ca737160b242687d6d5931231f45dd65be6775 Mon Sep 17 00:00:00 2001 From: Aditya Chavda Date: Mon, 26 May 2025 21:10:36 +0530 Subject: [PATCH 1/2] refactor: :racehorse: Overlay update optimization --- lib/src/models/action_button_icon.dart | 11 ++ ...a.dart => linked_showcase_data_model.dart} | 0 lib/src/models/showcase_scope.dart | 13 ++ lib/src/models/tooltip_action_button.dart | 41 ++++++ lib/src/models/tooltip_action_config.dart | 22 +++ lib/src/showcase/showcase.dart | 12 +- lib/src/showcase/showcase_controller.dart | 73 ++++++---- lib/src/showcase/showcase_view.dart | 73 ++++++++-- ...art => animated_tooltip_multi_layout.dart} | 0 lib/src/tooltip/arrow_painter.dart | 32 ++--- .../tooltip/render_animation_delegate.dart | 54 +++++-- lib/src/tooltip/render_position_delegate.dart | 42 ++++++ lib/src/tooltip/tooltip.dart | 4 +- ...out_widget.dart => tooltip_layout_id.dart} | 0 lib/src/tooltip/tooltip_widget.dart | 133 +++++++++--------- lib/src/utils/overlay_manager.dart | 45 +++--- lib/src/utils/shape_clipper.dart | 42 +++--- lib/src/utils/target_position_service.dart | 28 +++- 18 files changed, 443 insertions(+), 182 deletions(-) rename lib/src/models/{linked_showcase_data.dart => linked_showcase_data_model.dart} (100%) rename lib/src/tooltip/{animated_tooltip_layout.dart => animated_tooltip_multi_layout.dart} (100%) rename lib/src/tooltip/{tooltip_layout_widget.dart => tooltip_layout_id.dart} (100%) diff --git a/lib/src/models/action_button_icon.dart b/lib/src/models/action_button_icon.dart index 6de1b80f..0cbd2ebb 100644 --- a/lib/src/models/action_button_icon.dart +++ b/lib/src/models/action_button_icon.dart @@ -51,4 +51,15 @@ class ActionButtonIcon { /// Optional padding to apply around the icon. final EdgeInsets? padding; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ActionButtonIcon && + icon == other.icon && + padding == other.padding; + } + + @override + int get hashCode => Object.hash(icon, padding); } diff --git a/lib/src/models/linked_showcase_data.dart b/lib/src/models/linked_showcase_data_model.dart similarity index 100% rename from lib/src/models/linked_showcase_data.dart rename to lib/src/models/linked_showcase_data_model.dart diff --git a/lib/src/models/showcase_scope.dart b/lib/src/models/showcase_scope.dart index 4d1abacf..f7348e35 100644 --- a/lib/src/models/showcase_scope.dart +++ b/lib/src/models/showcase_scope.dart @@ -19,6 +19,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../showcase/showcase_controller.dart'; @@ -51,4 +52,16 @@ class ShowcaseScope { /// - Key: GlobalKey of a showcase (provided by user) /// - Value: Map of showcase IDs to their controllers final Map> controllers = {}; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ShowcaseScope && + name == other.name && + showcaseView == other.showcaseView && + mapEquals(controllers, other.controllers); + } + + @override + int get hashCode => Object.hash(name, showcaseView, controllers); } diff --git a/lib/src/models/tooltip_action_button.dart b/lib/src/models/tooltip_action_button.dart index bb39a87e..81a6b8a8 100644 --- a/lib/src/models/tooltip_action_button.dart +++ b/lib/src/models/tooltip_action_button.dart @@ -19,6 +19,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../showcaseview.dart'; @@ -126,4 +127,44 @@ class TooltipActionButton { /// This only works for the global action widgets /// Defaults to [] final List hideActionWidgetForShowcase; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TooltipActionButton && + other.type == type && + other.borderRadius == borderRadius && + other.padding == padding && + other.backgroundColor == backgroundColor && + other.textStyle == textStyle && + other.leadIcon == leadIcon && + other.tailIcon == tailIcon && + other.name == name && + other.button == button && + other.border == border && + listEquals( + other.hideActionWidgetForShowcase, + hideActionWidgetForShowcase, + ); + } + + @override + int get hashCode { + return Object.hashAllUnordered( + [ + type, + borderRadius, + padding, + hideActionWidgetForShowcase, + backgroundColor, + textStyle, + leadIcon, + tailIcon, + name, + onTap, + button, + border, + ], + ); + } } diff --git a/lib/src/models/tooltip_action_config.dart b/lib/src/models/tooltip_action_config.dart index faf854c0..3d78beea 100644 --- a/lib/src/models/tooltip_action_config.dart +++ b/lib/src/models/tooltip_action_config.dart @@ -59,4 +59,26 @@ class TooltipActionConfig { /// If aligning items according to their baseline, which baseline to use. /// This must be set if using baseline alignment. final TextBaseline? textBaseline; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TooltipActionConfig && + alignment == other.alignment && + actionGap == other.actionGap && + position == other.position && + gapBetweenContentAndAction == other.gapBetweenContentAndAction && + crossAxisAlignment == other.crossAxisAlignment && + textBaseline == other.textBaseline; + } + + @override + int get hashCode => Object.hashAllUnordered([ + alignment, + actionGap, + position, + gapBetweenContentAndAction, + crossAxisAlignment, + textBaseline, + ]); } diff --git a/lib/src/showcase/showcase.dart b/lib/src/showcase/showcase.dart index cb655475..33fa90f2 100644 --- a/lib/src/showcase/showcase.dart +++ b/lib/src/showcase/showcase.dart @@ -573,7 +573,12 @@ class _ShowcaseState extends State { // This is to support hot reload _updateControllerValues(); - _controller.recalculateRootWidgetSize(context); + _controller.recalculateRootWidgetSize( + context, + shouldUpdateOverlay: + _showCaseWidgetManager.showcaseView.getActiveShowcaseKey == + widget.showcaseKey, + ); return widget.child; } @@ -584,14 +589,15 @@ class _ShowcaseState extends State { id: _uniqueId, scope: _showCaseWidgetManager.name, ); - super.dispose(); } void _updateControllerValues() { - _showCaseWidgetManager = ShowcaseService.instance.getScope( + final manager = ShowcaseService.instance.getScope( scope: _showCaseWidgetManager.name, ); + if (manager == _showCaseWidgetManager) return; + _showCaseWidgetManager = manager; ShowcaseService.instance.addController( controller: _controller ..showcaseView = _showCaseWidgetManager.showcaseView, diff --git a/lib/src/showcase/showcase_controller.dart b/lib/src/showcase/showcase_controller.dart index 28b92783..f15a57fc 100644 --- a/lib/src/showcase/showcase_controller.dart +++ b/lib/src/showcase/showcase_controller.dart @@ -24,7 +24,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import '../models/linked_showcase_data.dart'; +import '../models/linked_showcase_data_model.dart'; import '../models/tooltip_action_config.dart'; import '../tooltip/tooltip.dart'; import '../utils/overlay_manager.dart'; @@ -134,20 +134,23 @@ class ShowcaseController { /// /// 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() { + void startShowcase({bool shouldUpdateOverlay = true}) { if (!showcaseView.enableShowcase || !_mounted) return; - recalculateRootWidgetSize(_context); + recalculateRootWidgetSize( + _context, + shouldUpdateOverlay: shouldUpdateOverlay, + ); globalFloatingActionWidget = showcaseView .getFloatingActionWidget(config.showcaseKey) ?.call(_context); - final size = rootWidgetSize ?? MediaQuery.sizeOf(_context); - position ??= TargetPositionService( - rootRenderObject: rootRenderObject, - screenSize: size, - renderBox: _context.findRenderObject() as RenderBox?, - padding: config.targetPadding, - ); + // final size = rootWidgetSize ?? MediaQuery.sizeOf(_context); + // position ??= TargetPositionService( + // rootRenderObject: rootRenderObject, + // screenSize: size, + // renderBox: _context.findRenderObject() as RenderBox?, + // padding: config.targetPadding, + // ); } /// Used to scroll the target into view. @@ -165,19 +168,19 @@ class ShowcaseController { /// /// 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 { + Future scrollIntoView({bool shouldUpdateOverlay = true}) async { if (!_mounted) { assert(_mounted, 'Widget has been unmounted'); return; } isScrollRunning = true; - updateControllerData(); - startShowcase(); - OverlayManager.instance.update( - show: showcaseView.isShowcaseRunning, - scope: showcaseView.scope, - ); + // updateControllerData(); + startShowcase(shouldUpdateOverlay: shouldUpdateOverlay); + // OverlayManager.instance.update( + // show: showcaseView.isShowcaseRunning, + // scope: showcaseView.scope, + // ); await Scrollable.ensureVisible( _context, duration: showcaseView.scrollDuration, @@ -185,12 +188,12 @@ class ShowcaseController { ); isScrollRunning = false; - updateControllerData(); - startShowcase(); - OverlayManager.instance.update( - show: showcaseView.isShowcaseRunning, - scope: showcaseView.scope, - ); + // updateControllerData(); + startShowcase(shouldUpdateOverlay: shouldUpdateOverlay); + // OverlayManager.instance.update( + // show: showcaseView.isShowcaseRunning, + // scope: showcaseView.scope, + // ); } /// Handles tap on barrier area. @@ -214,20 +217,28 @@ class ShowcaseController { /// /// Parameter: /// * [context] The BuildContext of the [Showcase] widget. - void recalculateRootWidgetSize(BuildContext context) { + void recalculateRootWidgetSize( + BuildContext context, { + bool shouldUpdateOverlay = true, + }) { + if (!showcaseView.enableShowcase || !showcaseView.isShowcaseRunning) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; + if (!context.mounted || + !showcaseView.enableShowcase || + !showcaseView.isShowcaseRunning) { + return; + } _initRootWidget(); - if (!showcaseView.enableShowcase) return; updateControllerData(); - if (!showcaseView.isShowcaseRunning) return; - OverlayManager.instance.update( - show: showcaseView.isShowcaseRunning, - scope: showcaseView.scope, - ); + if (shouldUpdateOverlay) { + OverlayManager.instance.update( + show: showcaseView.isShowcaseRunning, + scope: showcaseView.scope, + ); + } }); } diff --git a/lib/src/showcase/showcase_view.dart b/lib/src/showcase/showcase_view.dart index 198be02e..6cd9fc83 100644 --- a/lib/src/showcase/showcase_view.dart +++ b/lib/src/showcase/showcase_view.dart @@ -21,6 +21,7 @@ */ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../models/tooltip_action_button.dart'; @@ -320,10 +321,7 @@ class ShowcaseView { _ids = widgetIds; _activeWidgetId = 0; _onStart(); - OverlayManager.instance.update( - show: isShowcaseRunning, - scope: scope, - ); + // OverlayManager.instance.update(show: isShowcaseRunning, scope: scope); } else { Future.delayed(delay, () => _startShowcase(Duration.zero, widgetIds)); } @@ -410,14 +408,17 @@ class ShowcaseView { 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 ?? enableAutoScroll; - if (controllerLength == 1 && isAutoScroll) { - await controller.scrollIntoView(); - } else { - controller.startShowcase(); + final firstController = controllers.firstOrNull; + + final isAutoScroll = + firstController?.config.enableAutoScroll ?? enableAutoScroll; + + // Auto scroll is not supported for multi-showcase feature. + if (controllerLength == 1 && isAutoScroll) { + await firstController?.scrollIntoView(); + } else { + for (var i = 0; i < controllerLength; i++) { + controllers[i].startShowcase(shouldUpdateOverlay: i == 0); } } } @@ -468,4 +469,52 @@ class ShowcaseView { _ids = _activeWidgetId = null; _cancelTimer(); } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ShowcaseView && + scope == other.scope && + autoPlay == other.autoPlay && + autoPlayDelay == other.autoPlayDelay && + enableAutoPlayLock == other.enableAutoPlayLock && + blurValue == other.blurValue && + scrollDuration == other.scrollDuration && + disableMovingAnimation == other.disableMovingAnimation && + disableScaleAnimation == other.disableScaleAnimation && + enableAutoScroll == other.enableAutoScroll && + disableBarrierInteraction == other.disableBarrierInteraction && + enableShowcase == other.enableShowcase && + globalTooltipActionConfig == other.globalTooltipActionConfig && + listEquals(globalTooltipActions, other.globalTooltipActions) && + listEquals( + hideFloatingActionWidgetForShowcase, + other.hideFloatingActionWidgetForShowcase, + ); + } + + @override + int get hashCode { + return Object.hashAllUnordered([ + scope, + onFinish, + onDismiss, + onStart, + onComplete, + autoPlay, + autoPlayDelay, + enableAutoPlayLock, + blurValue, + scrollDuration, + disableMovingAnimation, + disableScaleAnimation, + enableAutoScroll, + disableBarrierInteraction, + enableShowcase, + globalTooltipActionConfig, + globalTooltipActions, + globalFloatingActionWidget, + hideFloatingActionWidgetForShowcase, + ]); + } } diff --git a/lib/src/tooltip/animated_tooltip_layout.dart b/lib/src/tooltip/animated_tooltip_multi_layout.dart similarity index 100% rename from lib/src/tooltip/animated_tooltip_layout.dart rename to lib/src/tooltip/animated_tooltip_multi_layout.dart diff --git a/lib/src/tooltip/arrow_painter.dart b/lib/src/tooltip/arrow_painter.dart index e87447d6..f38e3e78 100644 --- a/lib/src/tooltip/arrow_painter.dart +++ b/lib/src/tooltip/arrow_painter.dart @@ -21,38 +21,38 @@ */ part of 'tooltip.dart'; -class _Arrow extends CustomPainter { - _Arrow({ +class _ArrowPainter extends CustomPainter { + _ArrowPainter({ this.strokeColor = Colors.black, this.strokeWidth = Constants.arrowStrokeWidth, this.paintingStyle = PaintingStyle.fill, - }) : _paint = Paint() + }) : _paint = Paint() ..color = strokeColor ..strokeWidth = strokeWidth - ..style = paintingStyle; + ..style = paintingStyle, + // Cache the triangle path since it never changes + _path = Path() + ..moveTo(0, Constants.arrowHeight) + ..lineTo(Constants.arrowWidth * 0.5, 0) + ..lineTo(Constants.arrowWidth, Constants.arrowHeight) + ..lineTo(0, Constants.arrowHeight); final Color strokeColor; final PaintingStyle paintingStyle; final double strokeWidth; final Paint _paint; + final Path _path; @override - void paint(Canvas canvas, Size size) { - canvas.drawPath(_getTrianglePath(), _paint); - } + void paint(Canvas canvas, Size size) => canvas.drawPath( + _path, + _paint, + ); @override - bool shouldRepaint(covariant _Arrow oldDelegate) { + bool shouldRepaint(covariant _ArrowPainter oldDelegate) { return oldDelegate.strokeColor != strokeColor || oldDelegate.paintingStyle != paintingStyle || oldDelegate.strokeWidth != strokeWidth; } - - Path _getTrianglePath() { - return Path() - ..moveTo(0, Constants.arrowHeight) - ..lineTo(Constants.arrowWidth * 0.5, 0) - ..lineTo(Constants.arrowWidth, Constants.arrowHeight) - ..lineTo(0, Constants.arrowHeight); - } } diff --git a/lib/src/tooltip/render_animation_delegate.dart b/lib/src/tooltip/render_animation_delegate.dart index 18171207..31ce67e5 100644 --- a/lib/src/tooltip/render_animation_delegate.dart +++ b/lib/src/tooltip/render_animation_delegate.dart @@ -54,8 +54,8 @@ class _RenderAnimationDelegate extends _RenderPositionDelegate { _scaleAnimation = scaleAnimation, _moveAnimation = moveAnimation { // Add listeners to trigger repaint when animations change. - _scaleAnimation.addListener(_effectivelyMarkNeedsPaint); - _moveAnimation.addListener(_effectivelyMarkNeedsPaint); + _scaleAnimation.addListener(_throttledMarkNeedsPaint); + _moveAnimation.addListener(_throttledMarkNeedsPaint); } AnimationController _scaleController; @@ -67,6 +67,16 @@ class _RenderAnimationDelegate extends _RenderPositionDelegate { /// This will stop extra repaint when paint function is already in progress bool _isPreviousRepaintInProgress = false; + /// Cache for animation values to prevent unnecessary repaints + double? _lastScaleValue; + double? _lastMoveValue; + + /// Last time a repaint was requested (used for throttling) + int _lastRepaintTime = 0; + + /// Minimum time between repaints in milliseconds (throttle to reduce load) + static const _repaintThrottleMs = 8; // ~120fps + /// Updates the scale animation controller. set scaleController(AnimationController value) { if (_scaleController == value) return; @@ -82,37 +92,57 @@ class _RenderAnimationDelegate extends _RenderPositionDelegate { /// Updates the scale animation and refreshes listeners. set scaleAnimation(Animation value) { if (_scaleAnimation == value) return; - _scaleAnimation.removeListener(_effectivelyMarkNeedsPaint); + _scaleAnimation.removeListener(_throttledMarkNeedsPaint); _scaleAnimation = value; - _scaleAnimation.addListener(_effectivelyMarkNeedsPaint); + _scaleAnimation.addListener(_throttledMarkNeedsPaint); markNeedsPaint(); } /// Updates the move animation and refreshes listeners. set moveAnimation(Animation value) { if (_moveAnimation == value) return; - _moveAnimation.removeListener(_effectivelyMarkNeedsPaint); + _moveAnimation.removeListener(_throttledMarkNeedsPaint); _moveAnimation = value; - _moveAnimation.addListener(_effectivelyMarkNeedsPaint); - _effectivelyMarkNeedsPaint(); + _moveAnimation.addListener(_throttledMarkNeedsPaint); + _throttledMarkNeedsPaint(); } - void _effectivelyMarkNeedsPaint() { + /// Throttled version of markNeedsPaint to avoid excessive repaints + void _throttledMarkNeedsPaint() { + // Skip if a repaint is already in progress if (_isPreviousRepaintInProgress) return; - _isPreviousRepaintInProgress = true; - markNeedsPaint(); - _isPreviousRepaintInProgress = false; + + final currentTime = DateTime.now().millisecondsSinceEpoch; + final timeSinceLastRepaint = currentTime - _lastRepaintTime; + + // Check if animation values have actually changed + final currentScaleValue = _scaleAnimation.value; + final currentMoveValue = _moveAnimation.value; + + // Only repaint if values changed and sufficient time has passed + if ((currentScaleValue != _lastScaleValue || + currentMoveValue != _lastMoveValue) && + timeSinceLastRepaint >= _repaintThrottleMs) { + _lastScaleValue = currentScaleValue; + _lastMoveValue = currentMoveValue; + _lastRepaintTime = currentTime; + markNeedsPaint(); + } } /// Sets the scale alignment and marks the widget for repaint. void setScaleAlignment(Alignment alignment) { if (scaleAlignment == alignment) return; scaleAlignment = alignment; - _effectivelyMarkNeedsPaint(); + _throttledMarkNeedsPaint(); } + @override + bool get isRepaintBoundary => true; + @override void paint(PaintingContext context, Offset offset) { + _isPreviousRepaintInProgress = true; var child = firstChild; while (child != null) { diff --git a/lib/src/tooltip/render_position_delegate.dart b/lib/src/tooltip/render_position_delegate.dart index 492a59be..a8b7dce9 100644 --- a/lib/src/tooltip/render_position_delegate.dart +++ b/lib/src/tooltip/render_position_delegate.dart @@ -129,6 +129,10 @@ class _RenderPositionDelegate extends RenderBox ? Constants.withArrowToolTipPadding : Constants.withOutArrowToolTipPadding; + // Override to make this object a repaint boundary for better raster thread performance + @override + bool get isRepaintBoundary => true; + @override void performLayout() { // Initialize @@ -158,10 +162,48 @@ class _RenderPositionDelegate extends RenderBox // Final layout and positioning _performFinalChildLayout(); + // _calculateAndSetFinalSize(); + // Cleanup RenderObjectManager.clear(); } + /// Calculate and set the final size of the render object based on the bounds of its children + void _calculateAndSetFinalSize() { + // Start with an empty Rect to track the bounds + Rect bounds = Rect.zero; + bool firstChild = true; + + // Iterate through all children to calculate the bounding box + var child = this.firstChild; + while (child != null) { + final childParentData = child.parentData! as MultiChildLayoutParentData; + final childRect = Rect.fromPoints( + childParentData.offset + Offset(child.size.width, child.size.height), + childParentData.offset + Offset(child.size.width, child.size.height), + ); + + // For the first child, initialize bounds; for others, expand it + if (firstChild) { + bounds = childRect; + firstChild = false; + } else { + bounds = bounds.expandToInclude(childRect); + } + + child = childParentData.nextSibling; + } + + // Add a small padding to ensure we don't clip any content + bounds = bounds.inflate(2.0); + + // Constrain the size to the available constraints + final constrainedSize = constraints.constrain(bounds.size); + + // Set the size of this render object to the calculated bounds + size = constrainedSize; + } + /// Initialize layout variables and set size void _initializeLayout() { // Set size for this render object diff --git a/lib/src/tooltip/tooltip.dart b/lib/src/tooltip/tooltip.dart index 0bb396ed..6f45212f 100644 --- a/lib/src/tooltip/tooltip.dart +++ b/lib/src/tooltip/tooltip.dart @@ -9,9 +9,9 @@ import '../widget/action_widget.dart'; import '../widget/default_tooltip_text_widget.dart'; import 'render_object_manager.dart'; -part 'animated_tooltip_layout.dart'; +part 'animated_tooltip_multi_layout.dart'; part 'arrow_painter.dart'; part 'render_animation_delegate.dart'; part 'render_position_delegate.dart'; -part 'tooltip_layout_widget.dart'; +part 'tooltip_layout_id.dart'; part 'tooltip_widget.dart'; diff --git a/lib/src/tooltip/tooltip_layout_widget.dart b/lib/src/tooltip/tooltip_layout_id.dart similarity index 100% rename from lib/src/tooltip/tooltip_layout_widget.dart rename to lib/src/tooltip/tooltip_layout_id.dart diff --git a/lib/src/tooltip/tooltip_widget.dart b/lib/src/tooltip/tooltip_widget.dart index 10bb74e8..b9cee6dc 100644 --- a/lib/src/tooltip/tooltip_widget.dart +++ b/lib/src/tooltip/tooltip_widget.dart @@ -76,19 +76,19 @@ class ToolTipWidget extends StatefulWidget { }); final String? title; - final TextAlign? titleTextAlign; + final TextAlign titleTextAlign; final String? description; - final TextAlign? descriptionTextAlign; + 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 Color tooltipBackgroundColor; + final Color textColor; final bool showArrow; final VoidCallback? onTooltipTap; - final EdgeInsets? tooltipPadding; + final EdgeInsets tooltipPadding; final Duration movingAnimationDuration; final bool disableMovingAnimation; final bool disableScaleAnimation; @@ -176,74 +176,72 @@ class _ToolTipWidgetState extends State child: Center(child: widget.container ?? const SizedBox.shrink()), ), ) - : ClipRRect( - borderRadius: widget.tooltipBorderRadius ?? - const BorderRadius.all(Radius.circular(8)), - child: MouseRegion( - cursor: widget.onTooltipTap == null - ? MouseCursor.defer - : SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTooltipTap, - child: Container( - padding: widget.tooltipPadding?.copyWith(left: 0, right: 0), + : MouseRegion( + cursor: widget.onTooltipTap == null + ? MouseCursor.defer + : SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTooltipTap, + child: Container( + padding: widget.tooltipPadding?.copyWith(left: 0, right: 0), + decoration: BoxDecoration( color: widget.tooltipBackgroundColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.title case final title?) - DefaultTooltipTextWidget( - padding: (widget.titlePadding ?? EdgeInsets.zero).add( - EdgeInsets.only( - left: widget.tooltipPadding?.left ?? 0, - right: widget.tooltipPadding?.right ?? 0, - ), - ), - text: title, - textAlign: widget.titleTextAlign, - alignment: widget.titleAlignment, - textColor: widget.textColor, - textDirection: widget.titleTextDirection, - textStyle: widget.titleTextStyle ?? - Theme.of(context).textTheme.titleLarge?.merge( - TextStyle(color: widget.textColor), - ), - ), - if (widget.description case final desc?) - DefaultTooltipTextWidget( - padding: - (widget.descriptionPadding ?? EdgeInsets.zero) - .add( - EdgeInsets.only( - left: widget.tooltipPadding?.left ?? 0, - right: widget.tooltipPadding?.right ?? 0, - ), + borderRadius: widget.tooltipBorderRadius ?? + const BorderRadius.all(Radius.circular(8)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.title case final title?) + DefaultTooltipTextWidget( + padding: (widget.titlePadding ?? EdgeInsets.zero).add( + EdgeInsets.symmetric( + horizontal: widget.tooltipPadding?.horizontal ?? 0, ), - text: desc, - textAlign: widget.descriptionTextAlign, - alignment: widget.descriptionAlignment, - textColor: widget.textColor, - textDirection: widget.descriptionTextDirection, - textStyle: widget.descTextStyle ?? - Theme.of(context).textTheme.titleSmall?.merge( - TextStyle(color: widget.textColor), - ), ), - if (widget.tooltipActions.isNotEmpty && - widget.tooltipActionConfig.position.isInside) - ActionWidget( - tooltipActionConfig: widget.tooltipActionConfig, - outsidePadding: EdgeInsets.only( + text: title, + textAlign: widget.titleTextAlign, + alignment: widget.titleAlignment, + textColor: widget.textColor, + textDirection: widget.titleTextDirection, + textStyle: widget.titleTextStyle ?? + Theme.of(context).textTheme.titleLarge?.merge( + TextStyle(color: widget.textColor), + ), + ), + if (widget.description case final desc?) + DefaultTooltipTextWidget( + padding: + (widget.descriptionPadding ?? EdgeInsets.zero).add( + EdgeInsets.only( left: widget.tooltipPadding?.left ?? 0, right: widget.tooltipPadding?.right ?? 0, ), - alignment: widget.tooltipActionConfig.alignment, - crossAxisAlignment: - widget.tooltipActionConfig.crossAxisAlignment, - children: widget.tooltipActions, ), - ], - ), + text: desc, + textAlign: widget.descriptionTextAlign, + alignment: widget.descriptionAlignment, + textColor: widget.textColor, + textDirection: widget.descriptionTextDirection, + textStyle: widget.descTextStyle ?? + Theme.of(context).textTheme.titleSmall?.merge( + TextStyle(color: widget.textColor), + ), + ), + if (widget.tooltipActions.isNotEmpty && + widget.tooltipActionConfig.position.isInside) + ActionWidget( + tooltipActionConfig: widget.tooltipActionConfig, + outsidePadding: EdgeInsets.only( + left: widget.tooltipPadding?.left ?? 0, + right: widget.tooltipPadding?.right ?? 0, + ), + alignment: widget.tooltipActionConfig.alignment, + crossAxisAlignment: + widget.tooltipActionConfig.crossAxisAlignment, + children: widget.tooltipActions, + ), + ], ), ), ), @@ -297,7 +295,8 @@ class _ToolTipWidgetState extends State _TooltipLayoutId( id: TooltipLayoutSlot.arrow, child: CustomPaint( - painter: _Arrow(strokeColor: widget.tooltipBackgroundColor!), + painter: + _ArrowPainter(strokeColor: widget.tooltipBackgroundColor!), size: const Size(Constants.arrowWidth, Constants.arrowHeight), ), ), diff --git a/lib/src/utils/overlay_manager.dart b/lib/src/utils/overlay_manager.dart index 2b4de69b..7247f995 100644 --- a/lib/src/utils/overlay_manager.dart +++ b/lib/src/utils/overlay_manager.dart @@ -24,10 +24,10 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import '../models/linked_showcase_data.dart'; +import '../../showcaseview.dart'; +import '../models/linked_showcase_data_model.dart'; import '../showcase/showcase_controller.dart'; import '../showcase/showcase_service.dart'; -import '../showcase/showcase_view.dart'; import 'extensions.dart'; import 'shape_clipper.dart'; @@ -129,7 +129,7 @@ class OverlayManager { if (_isShowing && !_shouldShow) { _hide(); } else if (!_isShowing && _shouldShow) { - _show((_) => _getBuilder()); + _show(_getBuilder); } } @@ -137,7 +137,11 @@ class OverlayManager { /// /// Builds a stack with background and tooltip widgets based on active /// controllers. - Widget _getBuilder() { + Widget _getBuilder(BuildContext context) { + if (!context.mounted || !(_overlayEntry?.mounted ?? true)) { + return const SizedBox.shrink(); + } + final showcaseView = ShowcaseView.getNamed(_currentScope); final controllers = ShowcaseService.instance .getControllers( @@ -149,14 +153,22 @@ class OverlayManager { if (controllers.isEmpty) return const SizedBox.shrink(); + final currentShowcaseKey = showcaseView.getActiveShowcaseKey; + + late final ShowcaseController firstController; + late final Showcase firstShowcaseConfig; final controllerLength = controllers.length; for (var i = 0; i < controllerLength; i++) { - controllers[i].updateControllerData(); + final controller = controllers[i]; + if (i == 0) { + firstController = controller; + firstShowcaseConfig = firstController.config; + } + if (controller.key == currentShowcaseKey) { + controller.updateControllerData(); + } } - final firstController = controllers.first; - final firstShowcaseConfig = firstController.config; - final backgroundContainer = ColoredBox( color: firstShowcaseConfig.overlayColor .reduceOpacity(firstShowcaseConfig.overlayOpacity), @@ -171,15 +183,14 @@ class OverlayManager { clipper: ShapeClipper( linkedObjectData: _getLinkedShowcasesData(controllers), ), - child: firstController.blur == 0 - ? backgroundContainer - : BackdropFilter( - filter: ImageFilter.blur( - sigmaX: firstController.blur, - sigmaY: firstController.blur, - ), - child: backgroundContainer, - ), + child: ImageFiltered( + enabled: firstController.blur > 0.2, + imageFilter: ImageFilter.blur( + sigmaX: firstController.blur, + sigmaY: firstController.blur, + ), + child: backgroundContainer, + ), ), ), ...controllers.expand((object) => object.tooltipWidgets), diff --git a/lib/src/utils/shape_clipper.dart b/lib/src/utils/shape_clipper.dart index 427b8015..1a9f706c 100644 --- a/lib/src/utils/shape_clipper.dart +++ b/lib/src/utils/shape_clipper.dart @@ -25,7 +25,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../models/linked_showcase_data.dart'; +import '../models/linked_showcase_data_model.dart'; import 'constants.dart'; class ShapeClipper extends CustomClipper { @@ -50,10 +50,15 @@ class ShapeClipper extends CustomClipper { @override ui.Path getClip(ui.Size size) { - var mainObjectPath = Path() + // Create a path for the full screen + final mainObjectPath = Path() ..fillType = ui.PathFillType.evenOdd - ..addRect(Offset.zero & size) - ..addRRect(RRect.fromRectAndCorners(ui.Rect.zero)); + ..addRect(Offset.zero & size); + + // Optimization: If we have multiple objects, we'll create a combined + // path for all cutouts rather than using Path.combine in a loop which is + // more expensive. + final cutoutPath = Path()..fillType = ui.PathFillType.evenOdd; final linkedObjectLength = linkedObjectData.length; for (var i = 0; i < linkedObjectLength; i++) { @@ -71,24 +76,23 @@ class ShapeClipper extends CustomClipper { 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), - ), - ), + // Add each cutout to our combined path + cutoutPath.addRRect( + RRect.fromRectAndCorners( + rect, + topLeft: (widgetInfo.radius?.topLeft ?? customRadius), + topRight: (widgetInfo.radius?.topRight ?? customRadius), + bottomLeft: (widgetInfo.radius?.bottomLeft ?? customRadius), + bottomRight: (widgetInfo.radius?.bottomRight ?? customRadius), + ), ); } + // Do a single Path.combine operation instead of multiple + if (!cutoutPath.getBounds().isEmpty) { + mainObjectPath.addPath(cutoutPath, Offset.zero); + } + return mainObjectPath; } diff --git a/lib/src/utils/target_position_service.dart b/lib/src/utils/target_position_service.dart index 41a0ba86..6e710d16 100644 --- a/lib/src/utils/target_position_service.dart +++ b/lib/src/utils/target_position_service.dart @@ -53,6 +53,13 @@ class TargetPositionService { Offset? _boxOffset; + // Caching fields to avoid redundant calculations + Rect? _cachedRect; + Rect? _cachedRectForOverlay; + + // Flag to track if dimensions have changed and cache needs to be invalidated + bool _dimensionsChanged = true; + /// Calculates the rectangle representing the target widget with padding /// /// This method returns a rectangle that represents the target widget's bounds @@ -62,17 +69,24 @@ class TargetPositionService { if (_checkBoxOrOffsetIsNull(checkDy: true, checkDx: true)) { return Rect.zero; } + + // Use cached value if available and dimensions haven't changed + if (_cachedRect != null && !_dimensionsChanged) { + return _cachedRect!; + } + 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( + + _dimensionsChanged = false; + return _cachedRect = Rect.fromLTRB( leftDx.clamp(0, double.maxFinite), leftDy.clamp(0, double.maxFinite), min(bottomRight.dx + padding.right, screenSize.width), min(bottomRight.dy + padding.bottom, screenSize.height), ); - return rect; } /// Gets the raw rectangle bounds of the target widget without clamping @@ -85,9 +99,17 @@ class TargetPositionService { if (_checkBoxOrOffsetIsNull(checkDy: true, checkDx: true)) { return Rect.zero; } + + // Use cached value if available and dimensions haven't changed + if (_cachedRectForOverlay != null && !_dimensionsChanged) { + return _cachedRectForOverlay!; + } + final topLeft = renderBox!.size.topLeft(_boxOffset!); final bottomRight = renderBox!.size.bottomRight(_boxOffset!); - return Rect.fromLTRB( + + _dimensionsChanged = false; + return _cachedRectForOverlay = Rect.fromLTRB( topLeft.dx, topLeft.dy, bottomRight.dx, From f736586ba96fd20a14796194c2ea240a4acb4529 Mon Sep 17 00:00:00 2001 From: Sahil-Simform Date: Thu, 3 Jul 2025 18:55:52 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=F0=9F=90=9B=20Fix=20clipper=20and?= =?UTF-8?q?=20documentation=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 5 +- lib/src/models/tooltip_action_config.dart | 2 +- lib/src/showcase/showcase.dart | 3 +- lib/src/showcase/showcase_controller.dart | 27 +----- lib/src/showcase/showcase_service.dart | 2 +- lib/src/showcase/showcase_view.dart | 26 +++--- lib/src/tooltip/render_position_delegate.dart | 38 -------- lib/src/tooltip/tooltip_widget.dart | 20 +++-- lib/src/utils/overlay_manager.dart | 3 +- lib/src/utils/shape_clipper.dart | 90 +++++++++++++++---- lib/src/widget/action_widget.dart | 2 +- .../widget/tooltip_action_button_widget.dart | 8 +- 12 files changed, 116 insertions(+), 110 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 0163511b..0df39d41 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -120,7 +120,8 @@ class _MailPageState extends State { //Start showcase view after current widget frames are drawn. WidgetsBinding.instance.addPostFrameCallback( (_) => ShowcaseView.get().startShowCase( - [_firstShowcaseWidget, _two, _three, _four, _lastShowcaseWidget]), + [_firstShowcaseWidget, _two, _three, _four, _lastShowcaseWidget], + ), ); mails = [ Mail( @@ -192,6 +193,8 @@ class _MailPageState extends State { @override void dispose() { scrollController.dispose(); + // Unregister the showcase view when the widget is disposed + ShowcaseView.get().unregister(); super.dispose(); } diff --git a/lib/src/models/tooltip_action_config.dart b/lib/src/models/tooltip_action_config.dart index 3d78beea..c5743d72 100644 --- a/lib/src/models/tooltip_action_config.dart +++ b/lib/src/models/tooltip_action_config.dart @@ -21,7 +21,7 @@ */ import 'package:flutter/material.dart'; -import '../../showcaseview.dart'; +import '../utils/enum.dart'; class TooltipActionConfig { /// Configuration options for tooltip action buttons. diff --git a/lib/src/showcase/showcase.dart b/lib/src/showcase/showcase.dart index 33fa90f2..273dbc66 100644 --- a/lib/src/showcase/showcase.dart +++ b/lib/src/showcase/showcase.dart @@ -25,7 +25,6 @@ import 'package:flutter/material.dart'; import '../models/showcase_scope.dart'; import '../models/tooltip_action_button.dart'; import '../models/tooltip_action_config.dart'; -import '../showcase_widget.dart'; import '../utils/constants.dart'; import '../utils/enum.dart'; import '../utils/overlay_manager.dart'; @@ -251,7 +250,7 @@ class Showcase extends StatefulWidget { /// A key that is unique across the entire app. /// /// This Key will be used to control state of individual showcase and also - /// used in [ShowcaseView.startShowcase] to define position of current + /// used in [ShowcaseView.setupShowcase] to define position of current /// target widget while showcasing. final GlobalKey showcaseKey; diff --git a/lib/src/showcase/showcase_controller.dart b/lib/src/showcase/showcase_controller.dart index f15a57fc..7696c61a 100644 --- a/lib/src/showcase/showcase_controller.dart +++ b/lib/src/showcase/showcase_controller.dart @@ -119,7 +119,7 @@ class ShowcaseController { /// tree. bool get _mounted => getState().mounted; - /// Callback to start the showcase. + /// Callback to setup the showcase. /// /// Initializes the showcase by calculating positions and preparing visual /// elements. @@ -134,7 +134,7 @@ class ShowcaseController { /// /// 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({bool shouldUpdateOverlay = true}) { + void setupShowcase({bool shouldUpdateOverlay = true}) { if (!showcaseView.enableShowcase || !_mounted) return; recalculateRootWidgetSize( @@ -144,13 +144,6 @@ class ShowcaseController { globalFloatingActionWidget = showcaseView .getFloatingActionWidget(config.showcaseKey) ?.call(_context); - // final size = rootWidgetSize ?? MediaQuery.sizeOf(_context); - // position ??= TargetPositionService( - // rootRenderObject: rootRenderObject, - // screenSize: size, - // renderBox: _context.findRenderObject() as RenderBox?, - // padding: config.targetPadding, - // ); } /// Used to scroll the target into view. @@ -175,12 +168,7 @@ class ShowcaseController { } isScrollRunning = true; - // updateControllerData(); - startShowcase(shouldUpdateOverlay: shouldUpdateOverlay); - // OverlayManager.instance.update( - // show: showcaseView.isShowcaseRunning, - // scope: showcaseView.scope, - // ); + setupShowcase(shouldUpdateOverlay: shouldUpdateOverlay); await Scrollable.ensureVisible( _context, duration: showcaseView.scrollDuration, @@ -188,12 +176,7 @@ class ShowcaseController { ); isScrollRunning = false; - // updateControllerData(); - startShowcase(shouldUpdateOverlay: shouldUpdateOverlay); - // OverlayManager.instance.update( - // show: showcaseView.isShowcaseRunning, - // scope: showcaseView.scope, - // ); + setupShowcase(shouldUpdateOverlay: shouldUpdateOverlay); } /// Handles tap on barrier area. @@ -231,8 +214,6 @@ class ShowcaseController { _initRootWidget(); - updateControllerData(); - if (shouldUpdateOverlay) { OverlayManager.instance.update( show: showcaseView.isShowcaseRunning, diff --git a/lib/src/showcase/showcase_service.dart b/lib/src/showcase/showcase_service.dart index d73fd321..38d30f09 100644 --- a/lib/src/showcase/showcase_service.dart +++ b/lib/src/showcase/showcase_service.dart @@ -21,11 +21,11 @@ */ import 'package:flutter/widgets.dart'; -import '../../showcaseview.dart'; import '../models/showcase_scope.dart'; import '../utils/constants.dart'; import '../utils/extensions.dart'; import 'showcase_controller.dart'; +import 'showcase_view.dart'; /// A scoped service locator for showcase functionality. /// diff --git a/lib/src/showcase/showcase_view.dart b/lib/src/showcase/showcase_view.dart index 6cd9fc83..c2f5b188 100644 --- a/lib/src/showcase/showcase_view.dart +++ b/lib/src/showcase/showcase_view.dart @@ -62,20 +62,20 @@ class ShowcaseView { /// options like auto-play, animation, and many more. ShowcaseView.register({ this.scope = Constants.defaultScope, - this.onFinish, this.onStart, + this.onFinish, this.onComplete, this.onDismiss, + this.enableShowcase = true, this.autoPlay = false, this.autoPlayDelay = Constants.defaultAutoPlayDelay, this.enableAutoPlayLock = false, - this.blurValue = 0, - this.scrollDuration = Constants.defaultScrollDuration, - this.disableMovingAnimation = false, - this.disableScaleAnimation = false, this.enableAutoScroll = false, + this.scrollDuration = Constants.defaultScrollDuration, this.disableBarrierInteraction = false, - this.enableShowcase = true, + this.disableScaleAnimation = false, + this.disableMovingAnimation = false, + this.blurValue = 0, this.globalTooltipActionConfig, this.globalTooltipActions, this.globalFloatingActionWidget, @@ -263,10 +263,6 @@ class ShowcaseView { if (!_mounted) return; _cleanupAfterSteps(); - OverlayManager.instance.update( - show: isShowcaseRunning, - scope: scope, - ); } /// Cleans up resources when unregistering the showcase view. @@ -321,7 +317,7 @@ class ShowcaseView { _ids = widgetIds; _activeWidgetId = 0; _onStart(); - // OverlayManager.instance.update(show: isShowcaseRunning, scope: scope); + OverlayManager.instance.update(show: isShowcaseRunning, scope: scope); } else { Future.delayed(delay, () => _startShowcase(Duration.zero, widgetIds)); } @@ -344,12 +340,13 @@ class ShowcaseView { (_) { if (!_mounted) return; _activeWidgetId = id; - _onStart(); + if (_activeWidgetId! >= _ids!.length) { _cleanupAfterSteps(); onFinish?.call(); + } else { + _onStart(); } - OverlayManager.instance.update(show: isShowcaseRunning, scope: scope); }, ); } @@ -418,7 +415,7 @@ class ShowcaseView { await firstController?.scrollIntoView(); } else { for (var i = 0; i < controllerLength; i++) { - controllers[i].startShowcase(shouldUpdateOverlay: i == 0); + controllers[i].setupShowcase(shouldUpdateOverlay: i == 0); } } } @@ -468,6 +465,7 @@ class ShowcaseView { void _cleanupAfterSteps() { _ids = _activeWidgetId = null; _cancelTimer(); + OverlayManager.instance.update(show: isShowcaseRunning, scope: scope); } @override diff --git a/lib/src/tooltip/render_position_delegate.dart b/lib/src/tooltip/render_position_delegate.dart index a8b7dce9..265332de 100644 --- a/lib/src/tooltip/render_position_delegate.dart +++ b/lib/src/tooltip/render_position_delegate.dart @@ -162,48 +162,10 @@ class _RenderPositionDelegate extends RenderBox // Final layout and positioning _performFinalChildLayout(); - // _calculateAndSetFinalSize(); - // Cleanup RenderObjectManager.clear(); } - /// Calculate and set the final size of the render object based on the bounds of its children - void _calculateAndSetFinalSize() { - // Start with an empty Rect to track the bounds - Rect bounds = Rect.zero; - bool firstChild = true; - - // Iterate through all children to calculate the bounding box - var child = this.firstChild; - while (child != null) { - final childParentData = child.parentData! as MultiChildLayoutParentData; - final childRect = Rect.fromPoints( - childParentData.offset + Offset(child.size.width, child.size.height), - childParentData.offset + Offset(child.size.width, child.size.height), - ); - - // For the first child, initialize bounds; for others, expand it - if (firstChild) { - bounds = childRect; - firstChild = false; - } else { - bounds = bounds.expandToInclude(childRect); - } - - child = childParentData.nextSibling; - } - - // Add a small padding to ensure we don't clip any content - bounds = bounds.inflate(2.0); - - // Constrain the size to the available constraints - final constrainedSize = constraints.constrain(bounds.size); - - // Set the size of this render object to the calculated bounds - size = constrainedSize; - } - /// Initialize layout variables and set size void _initializeLayout() { // Set size for this render object diff --git a/lib/src/tooltip/tooltip_widget.dart b/lib/src/tooltip/tooltip_widget.dart index b9cee6dc..b4dd5c4b 100644 --- a/lib/src/tooltip/tooltip_widget.dart +++ b/lib/src/tooltip/tooltip_widget.dart @@ -183,7 +183,7 @@ class _ToolTipWidgetState extends State child: GestureDetector( onTap: widget.onTooltipTap, child: Container( - padding: widget.tooltipPadding?.copyWith(left: 0, right: 0), + padding: widget.tooltipPadding.copyWith(left: 0, right: 0), decoration: BoxDecoration( color: widget.tooltipBackgroundColor, borderRadius: widget.tooltipBorderRadius ?? @@ -195,8 +195,9 @@ class _ToolTipWidgetState extends State if (widget.title case final title?) DefaultTooltipTextWidget( padding: (widget.titlePadding ?? EdgeInsets.zero).add( - EdgeInsets.symmetric( - horizontal: widget.tooltipPadding?.horizontal ?? 0, + EdgeInsets.only( + left: widget.tooltipPadding.left, + right: widget.tooltipPadding.right, ), ), text: title, @@ -214,8 +215,8 @@ class _ToolTipWidgetState extends State padding: (widget.descriptionPadding ?? EdgeInsets.zero).add( EdgeInsets.only( - left: widget.tooltipPadding?.left ?? 0, - right: widget.tooltipPadding?.right ?? 0, + left: widget.tooltipPadding.left, + right: widget.tooltipPadding.right, ), ), text: desc, @@ -233,8 +234,8 @@ class _ToolTipWidgetState extends State ActionWidget( tooltipActionConfig: widget.tooltipActionConfig, outsidePadding: EdgeInsets.only( - left: widget.tooltipPadding?.left ?? 0, - right: widget.tooltipPadding?.right ?? 0, + left: widget.tooltipPadding.left, + right: widget.tooltipPadding.right, ), alignment: widget.tooltipActionConfig.alignment, crossAxisAlignment: @@ -295,8 +296,9 @@ class _ToolTipWidgetState extends State _TooltipLayoutId( id: TooltipLayoutSlot.arrow, child: CustomPaint( - painter: - _ArrowPainter(strokeColor: widget.tooltipBackgroundColor!), + painter: _ArrowPainter( + strokeColor: widget.tooltipBackgroundColor, + ), size: const Size(Constants.arrowWidth, Constants.arrowHeight), ), ), diff --git a/lib/src/utils/overlay_manager.dart b/lib/src/utils/overlay_manager.dart index 7247f995..cb81db09 100644 --- a/lib/src/utils/overlay_manager.dart +++ b/lib/src/utils/overlay_manager.dart @@ -24,10 +24,11 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import '../../showcaseview.dart'; import '../models/linked_showcase_data_model.dart'; +import '../showcase/showcase.dart'; import '../showcase/showcase_controller.dart'; import '../showcase/showcase_service.dart'; +import '../showcase/showcase_view.dart'; import 'extensions.dart'; import 'shape_clipper.dart'; diff --git a/lib/src/utils/shape_clipper.dart b/lib/src/utils/shape_clipper.dart index 1a9f706c..7d3edd51 100644 --- a/lib/src/utils/shape_clipper.dart +++ b/lib/src/utils/shape_clipper.dart @@ -50,15 +50,22 @@ class ShapeClipper extends CustomClipper { @override ui.Path getClip(ui.Size size) { - // Create a path for the full screen - final mainObjectPath = Path() - ..fillType = ui.PathFillType.evenOdd - ..addRect(Offset.zero & size); + // Using a different clipping approach on web since the optimized approach + // is not working in Flutter (3.10.0 - 3.32.5). + if (kIsWeb) { + return _webClip(size); + } + return _optimisedClip(size); + } - // Optimization: If we have multiple objects, we'll create a combined - // path for all cutouts rather than using Path.combine in a loop which is - // more expensive. - final cutoutPath = Path()..fillType = ui.PathFillType.evenOdd; + /// This clipping method is less optimized but ensures correct cutout rendering + /// on web and all platforms. The [_optimisedClip] method does not work reliably + /// on web, so a conditional check is used to select this implementation for web. + ui.Path _webClip(ui.Size size) { + var mainObjectPath = Path() + ..fillType = ui.PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addRRect(RRect.fromRectAndCorners(ui.Rect.zero)); final linkedObjectLength = linkedObjectData.length; for (var i = 0; i < linkedObjectLength; i++) { @@ -76,8 +83,58 @@ class ShapeClipper extends CustomClipper { widgetInfo.rect.bottom + widgetInfo.overlayPadding.bottom, ); - // Add each cutout to our combined path - cutoutPath.addRRect( + /// 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; + } + + /// Returns a [ui.Path] representing the overlay with cutouts for each showcased widget. + /// + /// This implementation is optimized for non-web platforms. + ui.Path _optimisedClip(ui.Size size) { + // Start with a path for the entire screen + final screenPath = Path()..addRect(Offset.zero & size); + + // If there are no showcase items, return the full screen path + if (linkedObjectData.isEmpty) { + return screenPath; + } + + // Create a path that will contain all the cutout shapes + final cutoutsPath = Path(); + + // Add all showcase shapes to the cutouts path + for (final widgetInfo in linkedObjectData) { + final customRadius = widgetInfo.isCircle + ? Radius.circular( + widgetInfo.rect.height + widgetInfo.overlayPadding.vertical, + ) + : 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, + ); + + cutoutsPath.addRRect( RRect.fromRectAndCorners( rect, topLeft: (widgetInfo.radius?.topLeft ?? customRadius), @@ -88,12 +145,15 @@ class ShapeClipper extends CustomClipper { ); } - // Do a single Path.combine operation instead of multiple - if (!cutoutPath.getBounds().isEmpty) { - mainObjectPath.addPath(cutoutPath, Offset.zero); - } + // Create the final path by subtracting all cutouts from the screen path + // Using PathOperation.difference to cut out the shapes + final finalPath = Path.combine( + PathOperation.difference, + screenPath, + cutoutsPath, + ); - return mainObjectPath; + return finalPath; } @override diff --git a/lib/src/widget/action_widget.dart b/lib/src/widget/action_widget.dart index 74fded0e..f3ae73e3 100644 --- a/lib/src/widget/action_widget.dart +++ b/lib/src/widget/action_widget.dart @@ -21,7 +21,7 @@ */ import 'package:flutter/material.dart'; -import '../../showcaseview.dart'; +import '../models/tooltip_action_config.dart'; class ActionWidget extends StatelessWidget { /// A widget that displays action buttons in a tooltip. diff --git a/lib/src/widget/tooltip_action_button_widget.dart b/lib/src/widget/tooltip_action_button_widget.dart index a2ee5e88..048dccfc 100644 --- a/lib/src/widget/tooltip_action_button_widget.dart +++ b/lib/src/widget/tooltip_action_button_widget.dart @@ -21,7 +21,8 @@ */ import 'package:flutter/material.dart'; -import '../../showcaseview.dart'; +import '../models/tooltip_action_button.dart'; +import '../showcase/showcase_view.dart'; class TooltipActionButtonWidget extends StatelessWidget { /// A widget that renders action buttons within showcase tooltips. @@ -40,11 +41,10 @@ class TooltipActionButtonWidget extends StatelessWidget { super.key, }); - /// This will provide the configuration for the action buttons + /// This will provide the configuration for the action buttons. final TooltipActionButton config; - /// This is used for [TooltipActionButton] to close, next and previous - /// showcase navigation + /// This is used for close, next and previous showcase navigation. final ShowcaseView showCaseState; @override