diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e9eacb..a6c4d671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ Fixed example app to run in flutter version `v3.32.5`. - Fixed [#564](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/564) - Apply textScaler to tooltip widgets. +- Improvement [#570](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/pull/570) - + Enable tooltip actions to be placed horizontally inside the tooltip. ## [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 64b2de80..d45b7113 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ _**For live web demo, visit [ShowcaseView Web Example](https://simformsolutionsp ## Features - Guide users through your app by highlighting specific widgets step by step. -- Customize tooltips with titles, descriptions, and styling. +- Customize tooltips with titles, descriptions, actions, and styling. - Handles scrolling the widget into view for showcasing. - Support for custom tooltip widgets. - Animation and transition effects for tooltip. diff --git a/doc/documentation.md b/doc/documentation.md index 07132305..a9ecda8f 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -11,7 +11,7 @@ _**For live web demo, visit [ShowcaseView Web Example](https://simformsolutionsp ## Features - Guide user through your app by highlighting specific widget step by step. -- Customize tooltips with titles, descriptions, and styling. +- Customize tooltips with titles, descriptions, actions, and styling. - Handles scrolling the widget into view for showcasing. - Support for custom tooltip widgets. - Animation and transition effects for tooltip. @@ -77,6 +77,8 @@ The package offers extensive customization options for: - Animation effects and durations. - Interactive controls. - Auto-scrolling behavior. +- Tooltip action buttons (previous, next, skip) with customizable styling and positioning. +- Floating action widgets for additional interactive elements during showcases. # Installation diff --git a/example/lib/main.dart b/example/lib/main.dart index 0df39d41..e1a70c40 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -395,28 +395,19 @@ class _MailPageState extends State { description: 'Click here to compose mail', targetBorderRadius: const BorderRadius.all(Radius.circular(16)), showArrow: false, + tooltipActionConfig: const TooltipActionConfig( + position: TooltipActionPosition.insideRight, + gapBetweenContentAndAction: 8, + ), tooltipActions: [ - TooltipActionButton( - type: TooltipDefaultActionType.previous, - name: 'Back', + TooltipActionButton.custom( + button: GestureDetector( onTap: () { - // Write your code on button tap - ShowcaseView.get().previous(); + ShowcaseView.get().dismiss(); }, - backgroundColor: Colors.pink.shade50, - textStyle: const TextStyle( - color: Colors.pink, - )), - const TooltipActionButton( - type: TooltipDefaultActionType.skip, - name: 'Close', - textStyle: TextStyle( - color: Colors.white, - ), - tailIcon: ActionButtonIcon( - icon: Icon( + child: const Icon( Icons.close, - color: Colors.white, + color: Colors.black, size: 15, ), ), diff --git a/lib/src/models/tooltip_action_config.dart b/lib/src/models/tooltip_action_config.dart index c5743d72..480ff91a 100644 --- a/lib/src/models/tooltip_action_config.dart +++ b/lib/src/models/tooltip_action_config.dart @@ -34,6 +34,7 @@ class TooltipActionConfig { this.position = TooltipActionPosition.inside, this.gapBetweenContentAndAction = 10, this.crossAxisAlignment = CrossAxisAlignment.start, + this.mainAxisSize = MainAxisSize.max, this.textBaseline, }) : assert( crossAxisAlignment != CrossAxisAlignment.stretch, @@ -60,6 +61,9 @@ class TooltipActionConfig { /// This must be set if using baseline alignment. final TextBaseline? textBaseline; + /// Defines the main axis size of action buttons of tooltip action widget. + final MainAxisSize mainAxisSize; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -69,7 +73,8 @@ class TooltipActionConfig { position == other.position && gapBetweenContentAndAction == other.gapBetweenContentAndAction && crossAxisAlignment == other.crossAxisAlignment && - textBaseline == other.textBaseline; + textBaseline == other.textBaseline && + mainAxisSize == other.mainAxisSize; } @override @@ -80,5 +85,6 @@ class TooltipActionConfig { gapBetweenContentAndAction, crossAxisAlignment, textBaseline, + mainAxisSize, ]); } diff --git a/lib/src/showcase/showcase.dart b/lib/src/showcase/showcase.dart index 273dbc66..e39848b8 100644 --- a/lib/src/showcase/showcase.dart +++ b/lib/src/showcase/showcase.dart @@ -60,9 +60,9 @@ class Showcase extends StatefulWidget { /// ``` const Showcase({ required GlobalKey key, - required this.description, required this.child, this.title, + this.description, this.titleTextAlign = TextAlign.start, this.descriptionTextAlign = TextAlign.start, this.titleAlignment = Alignment.center, @@ -116,6 +116,11 @@ class Showcase extends StatefulWidget { width = null, container = null, showcaseKey = key, + assert( + title != null || description != null, + "title and description both can't be null. If you don't want to " + "provide those then use Showcase.withWidget() constructor", + ), assert( targetTooltipGap >= 0, 'targetTooltipGap must be greater than 0', @@ -225,6 +230,11 @@ class Showcase extends StatefulWidget { titleTextDirection = null, descriptionTextDirection = null, showcaseKey = key, + assert( + container != null, + 'A container widget must be provided with this constructor. If ' + 'default showcase is desired then use Showcase() constructor', + ), assert( targetTooltipGap >= 0, 'targetTooltipGap must be greater than 0', diff --git a/lib/src/showcase/showcase_controller.dart b/lib/src/showcase/showcase_controller.dart index 59d6cf6f..c40eea2e 100644 --- a/lib/src/showcase/showcase_controller.dart +++ b/lib/src/showcase/showcase_controller.dart @@ -307,7 +307,7 @@ class ShowcaseController { disableDefaultChildGestures: config.disableDefaultTargetGestures, targetPadding: config.targetPadding, ), - ToolTipWidget( + ToolTipWrapper( key: ValueKey(id), title: config.title, titleTextAlign: config.titleTextAlign, @@ -398,8 +398,9 @@ class ShowcaseController { ? config.tooltipActions! : showcaseView.globalTooltipActions ?? []; final actionDataLength = actionData.length; - final lastAction = actionData.lastOrNull; - final actionGap = _getTooltipActionConfig().actionGap; + final actionConfig = _getTooltipActionConfig(); + final actionGap = actionConfig.actionGap; + final areActionsInsideHorizontal = actionConfig.position.isInsideHorizontal; return [ for (var i = 0; i < actionDataLength; i++) @@ -407,9 +408,15 @@ class ShowcaseController { !actionData[i] .hideActionWidgetForShowcase .contains(config.showcaseKey)) + // TODO: Switch to using spacing in [ActionWidget]. Padding( padding: EdgeInsetsDirectional.only( - end: actionData[i] == lastAction ? 0 : actionGap, + end: i == (actionDataLength - 1) || areActionsInsideHorizontal + ? 0 + : actionGap, + bottom: i == (actionDataLength - 1) || !areActionsInsideHorizontal + ? 0 + : actionGap, ), child: TooltipActionButtonWidget( config: actionData[i], diff --git a/lib/src/tooltip/tooltip.dart b/lib/src/tooltip/tooltip.dart index 6f45212f..0cefa206 100644 --- a/lib/src/tooltip/tooltip.dart +++ b/lib/src/tooltip/tooltip.dart @@ -6,12 +6,12 @@ import '../showcase/showcase_controller.dart'; import '../utils/constants.dart'; import '../utils/enum.dart'; import '../widget/action_widget.dart'; -import '../widget/default_tooltip_text_widget.dart'; import 'render_object_manager.dart'; +import 'tooltip_content.dart'; part 'animated_tooltip_multi_layout.dart'; part 'arrow_painter.dart'; part 'render_animation_delegate.dart'; part 'render_position_delegate.dart'; part 'tooltip_layout_id.dart'; -part 'tooltip_widget.dart'; +part 'tooltip_wrapper.dart'; diff --git a/lib/src/tooltip/tooltip_content.dart b/lib/src/tooltip/tooltip_content.dart new file mode 100644 index 00000000..392e7d95 --- /dev/null +++ b/lib/src/tooltip/tooltip_content.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../models/tooltip_action_config.dart'; +import '../widget/action_widget.dart'; +import '../widget/default_tooltip_text_widget.dart'; + +class ToolTipContent extends StatelessWidget { + /// Builds the tooltip content layout based on action position configuration. + /// + /// Supports following layouts: + /// - Vertical layout (actions at bottom) for [TooltipActionPosition.inside] + /// - Horizontal layout (actions on left/right) for + /// [TooltipActionPosition.insideLeft] and [TooltipActionPosition.insideRight] + const ToolTipContent({ + required this.description, + required this.titleTextAlign, + required this.descriptionTextAlign, + required this.titleAlignment, + required this.descriptionAlignment, + required this.tooltipActionConfig, + required this.tooltipActions, + required this.textColor, + this.title, + this.titleTextStyle, + this.descTextStyle, + this.titlePadding, + this.descriptionPadding, + this.titleTextDirection, + this.descriptionTextDirection, + super.key, + }) : assert( + title != null || description != null, + 'Either title or description must be provided', + ); + + 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 Color textColor; + final EdgeInsets? titlePadding; + final EdgeInsets? descriptionPadding; + final TextDirection? titleTextDirection; + final TextDirection? descriptionTextDirection; + final TooltipActionConfig tooltipActionConfig; + final List tooltipActions; + + @override + Widget build(BuildContext context) { + final shouldShowActionsInside = + tooltipActions.isNotEmpty && tooltipActionConfig.position.isInside; + final textTheme = Theme.of(context).textTheme; + + // Build title widget + Widget? titleWidget; + if (title case final title?) { + titleWidget = DefaultTooltipTextWidget( + padding: titlePadding ?? EdgeInsets.zero, + text: title, + textAlign: titleTextAlign, + alignment: titleAlignment, + textColor: textColor, + textDirection: titleTextDirection, + textStyle: titleTextStyle ?? + textTheme.titleLarge?.merge(TextStyle(color: textColor)), + ); + } + + // Build description widget + Widget? descriptionWidget; + if (description case final desc?) { + descriptionWidget = DefaultTooltipTextWidget( + padding: descriptionPadding ?? EdgeInsets.zero, + text: desc, + textAlign: descriptionTextAlign, + alignment: descriptionAlignment, + textColor: textColor, + textDirection: descriptionTextDirection, + textStyle: descTextStyle ?? + textTheme.titleSmall?.merge(TextStyle(color: textColor)), + ); + } + + // Build action widget + Widget? actionWidget; + if (shouldShowActionsInside) { + actionWidget = ActionWidget( + tooltipActionConfig: tooltipActionConfig, + children: tooltipActions, + ); + } + + // For vertical action positioning (default), use Column layout + final contentColumn = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (titleWidget != null) titleWidget, + if (descriptionWidget != null) descriptionWidget, + if (actionWidget != null && + tooltipActionConfig.position.isInsideVertical) + actionWidget, + ], + ); + + // If no horizontal action positioning, return vertical layout + if (actionWidget == null || + !tooltipActionConfig.position.isInsideHorizontal) { + return contentColumn; + } + + // For horizontal action positioning, use Row layout + final gap = SizedBox( + width: tooltipActionConfig.gapBetweenContentAndAction, + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tooltipActionConfig.position.isInsideLeft) ...[ + actionWidget, + gap, + ], + Flexible(child: contentColumn), + if (tooltipActionConfig.position.isInsideRight) ...[ + gap, + actionWidget, + ], + ], + ); + } +} diff --git a/lib/src/tooltip/tooltip_widget.dart b/lib/src/tooltip/tooltip_wrapper.dart similarity index 79% rename from lib/src/tooltip/tooltip_widget.dart rename to lib/src/tooltip/tooltip_wrapper.dart index 43ddf940..5a99b1c0 100644 --- a/lib/src/tooltip/tooltip_widget.dart +++ b/lib/src/tooltip/tooltip_wrapper.dart @@ -21,7 +21,7 @@ */ part of 'tooltip.dart'; -class ToolTipWidget extends StatefulWidget { +class ToolTipWrapper extends StatefulWidget { /// A tooltip widget that is displayed alongside a target widget during a /// showcase. /// @@ -38,7 +38,7 @@ class ToolTipWidget extends StatefulWidget { /// The tooltip automatically handles positioning constraints to ensure it /// stays within screen boundaries and maintains proper spacing from the /// target widget. - const ToolTipWidget({ + const ToolTipWrapper({ required this.title, required this.description, required this.titleTextStyle, @@ -110,10 +110,10 @@ class ToolTipWidget extends StatefulWidget { final double targetTooltipGap; @override - State createState() => _ToolTipWidgetState(); + State createState() => _ToolTipWrapperState(); } -class _ToolTipWidgetState extends State +class _ToolTipWrapperState extends State with TickerProviderStateMixin { late final AnimationController _movingAnimationController = AnimationController( @@ -157,6 +157,12 @@ class _ToolTipWidgetState extends State @override Widget build(BuildContext context) { + assert( + widget.container != null || + (widget.title != null || widget.description != null), + 'Provide either a custom container or a title/description for the ' + 'tooltip. Both cannot be null.', + ); // 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 @@ -189,45 +195,22 @@ class _ToolTipWidgetState extends State 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, - 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, - 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, - alignment: widget.tooltipActionConfig.alignment, - crossAxisAlignment: - widget.tooltipActionConfig.crossAxisAlignment, - children: widget.tooltipActions, - ), - ], + child: ToolTipContent( + title: widget.title, + description: widget.description, + titleTextAlign: widget.titleTextAlign, + descriptionTextAlign: widget.descriptionTextAlign, + titleAlignment: widget.titleAlignment, + descriptionAlignment: widget.descriptionAlignment, + textColor: widget.textColor, + titleTextStyle: widget.titleTextStyle, + descTextStyle: widget.descTextStyle, + titlePadding: widget.titlePadding, + descriptionPadding: widget.descriptionPadding, + titleTextDirection: widget.titleTextDirection, + descriptionTextDirection: widget.descriptionTextDirection, + tooltipActionConfig: widget.tooltipActionConfig, + tooltipActions: widget.tooltipActions, ), ), ), @@ -271,9 +254,6 @@ class _ToolTipWidgetState extends State id: TooltipLayoutSlot.actionBox, child: ActionWidget( tooltipActionConfig: widget.tooltipActionConfig, - alignment: widget.tooltipActionConfig.alignment, - crossAxisAlignment: - widget.tooltipActionConfig.crossAxisAlignment, children: widget.tooltipActions, ), ), diff --git a/lib/src/utils/enum.dart b/lib/src/utils/enum.dart index 75c9ab2d..a537264d 100644 --- a/lib/src/utils/enum.dart +++ b/lib/src/utils/enum.dart @@ -124,10 +124,15 @@ enum TooltipPosition { /// Defines the positioning of action buttons relative to the tooltip content. /// -/// This enum determines whether action buttons (like next, previous, skip buttons) -/// should be placed: -/// - Inside the tooltip container itself, appearing as part of the tooltip content -/// - Outside the tooltip container, appearing as separate UI elements below/above the tooltip +/// This enum determines whether action buttons (like next, previous, skip +/// buttons) should be placed: +/// - Inside the tooltip container itself, appearing as part of the tooltip +/// content +/// - Outside the tooltip container, appearing as separate UI elements +/// below/above the tooltip +/// +/// When inside, actions can be positioned at the bottom, left, or right of +/// the tooltip content. /// /// The position affects the layout calculations, spacing, and visual appearance /// of the tooltip component within the showcase. @@ -138,15 +143,41 @@ enum TooltipActionPosition { /// separate UI elements below/above the tooltip content. outside, - /// Places the action buttons inside the tooltip container. + /// Places the action buttons inside the tooltip container at the bottom. + /// + /// When this option is selected, the action buttons will be rendered as + /// part of the tooltip content, appearing within the same container at the + /// bottom. + inside, + + /// Places the action buttons inside the tooltip container on the left side. /// /// When this option is selected, the action buttons will be rendered as - /// part of the tooltip content, appearing within the same container. - inside; + /// part of the tooltip content, appearing within the same container on the + /// left side. + /// Actions will be arranged vertically. + insideLeft, - bool get isInside => this == inside; + /// Places the action buttons inside the tooltip container on the right side. + /// + /// When this option is selected, the action buttons will be rendered as + /// part of the tooltip content, appearing within the same container on the + /// right side. + /// Actions will be arranged vertically. + insideRight; + + bool get isInside => + this == inside || this == insideLeft || this == insideRight; bool get isOutside => this == outside; + + bool get isInsideHorizontal => this == insideLeft || this == insideRight; + + bool get isInsideVertical => this == inside; + + bool get isInsideLeft => this == insideLeft; + + bool get isInsideRight => this == insideRight; } /// Defines the standard action types that can be used in tooltip action diff --git a/lib/src/widget/action_widget.dart b/lib/src/widget/action_widget.dart index f3ae73e3..993b048e 100644 --- a/lib/src/widget/action_widget.dart +++ b/lib/src/widget/action_widget.dart @@ -44,18 +44,12 @@ class ActionWidget extends StatelessWidget { const ActionWidget({ required this.children, required this.tooltipActionConfig, - required this.alignment, - required this.crossAxisAlignment, this.outsidePadding = EdgeInsets.zero, - this.width, super.key, }); final TooltipActionConfig tooltipActionConfig; final List children; - final double? width; - final MainAxisAlignment alignment; - final CrossAxisAlignment crossAxisAlignment; final EdgeInsets outsidePadding; @override @@ -64,12 +58,21 @@ class ActionWidget extends StatelessWidget { type: MaterialType.transparency, child: Padding( padding: outsidePadding, - child: Row( - mainAxisAlignment: alignment, - crossAxisAlignment: crossAxisAlignment, - textBaseline: tooltipActionConfig.textBaseline, - children: children, - ), + child: tooltipActionConfig.position.isInsideHorizontal + ? Column( + mainAxisSize: tooltipActionConfig.mainAxisSize, + mainAxisAlignment: tooltipActionConfig.alignment, + crossAxisAlignment: tooltipActionConfig.crossAxisAlignment, + textBaseline: tooltipActionConfig.textBaseline, + children: children, + ) + : Row( + mainAxisSize: tooltipActionConfig.mainAxisSize, + mainAxisAlignment: tooltipActionConfig.alignment, + crossAxisAlignment: tooltipActionConfig.crossAxisAlignment, + textBaseline: tooltipActionConfig.textBaseline, + children: children, + ), ), ); }