Last active
April 17, 2026 13:27
-
-
Save Far-Se/4816ea47ba06ae0de6b348ae5ee9fab6 to your computer and use it in GitHub Desktop.
Flutter Custom Tooltip that truly ignores the pointerEvent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:async'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| import '../../models/settings.dart'; | |
| enum CustomTooltipTriggerMode { | |
| hover, | |
| tap, | |
| longPress, | |
| manual, | |
| } | |
| class CustomTooltip extends StatefulWidget { | |
| const CustomTooltip({ | |
| super.key, | |
| required this.message, | |
| required this.child, | |
| this.constraints, | |
| this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), | |
| this.margin = EdgeInsets.zero, | |
| this.verticalOffset = 2.0, | |
| this.viewportMargin = 8.0, | |
| this.preferBelow = false, | |
| this.excludeFromSemantics = false, | |
| this.decoration, | |
| this.textStyle, | |
| this.textAlign, | |
| this.waitDuration = const Duration(milliseconds: 10), | |
| this.showDuration = const Duration(milliseconds: 1500), | |
| this.exitDuration = const Duration(milliseconds: 100), | |
| this.enableTapToDismiss = true, | |
| this.triggerMode = CustomTooltipTriggerMode.hover, | |
| this.enableFeedback, | |
| this.onTriggered, | |
| this.mouseCursor, | |
| this.ignorePointer = true, | |
| }); | |
| final String message; | |
| final Widget child; | |
| final BoxConstraints? constraints; | |
| final EdgeInsetsGeometry padding; | |
| final EdgeInsetsGeometry margin; | |
| final double verticalOffset; | |
| final bool preferBelow; | |
| final bool excludeFromSemantics; | |
| final Decoration? decoration; | |
| final TextStyle? textStyle; | |
| final TextAlign? textAlign; | |
| final Duration waitDuration; | |
| final Duration showDuration; | |
| final Duration exitDuration; | |
| final bool enableTapToDismiss; | |
| final CustomTooltipTriggerMode triggerMode; | |
| final bool? enableFeedback; | |
| final VoidCallback? onTriggered; | |
| final MouseCursor? mouseCursor; | |
| final bool ignorePointer; | |
| final double viewportMargin; | |
| @override | |
| State<CustomTooltip> createState() => CustomTooltipState(); | |
| } | |
| class CustomTooltipState extends State<CustomTooltip> { | |
| final LayerLink _layerLink = LayerLink(); | |
| OverlayEntry? _entry; | |
| Timer? _showTimer; | |
| Timer? _hideTimer; | |
| Timer? _autoHideTimer; | |
| bool _visible = false; | |
| Size? _tooltipSize; | |
| Size? _targetSize; | |
| bool get isVisible => _visible; | |
| void ensureTooltipVisible() { | |
| showTooltip(); | |
| } | |
| void showTooltip() { | |
| _cancelTimers(); | |
| if (_visible) { | |
| _restartAutoHideTimer(); | |
| return; | |
| } | |
| _captureTargetSize(); | |
| if (!mounted || _targetSize == null) return; | |
| _tooltipSize = _measureTooltipSize(); | |
| _entry = _createOverlayEntry(); | |
| final OverlayState overlay = Overlay.of(context, rootOverlay: true); | |
| overlay.insert(_entry!); | |
| _visible = true; | |
| widget.onTriggered?.call(); | |
| if (mounted) { | |
| setState(() {}); | |
| } | |
| _restartAutoHideTimer(); | |
| } | |
| void hideTooltip({Duration? delay}) { | |
| _showTimer?.cancel(); | |
| _autoHideTimer?.cancel(); | |
| _hideTimer?.cancel(); | |
| final Duration effectiveDelay = delay ?? Duration.zero; | |
| if (effectiveDelay == Duration.zero) { | |
| _removeTooltip(); | |
| } else { | |
| _hideTimer = Timer(effectiveDelay, _removeTooltip); | |
| } | |
| } | |
| void _removeTooltip({bool rebuild = true}) { | |
| _showTimer?.cancel(); | |
| _autoHideTimer?.cancel(); | |
| _hideTimer?.cancel(); | |
| _showTimer = null; | |
| _autoHideTimer = null; | |
| _hideTimer = null; | |
| _entry?.remove(); | |
| _entry = null; | |
| _visible = false; | |
| _tooltipSize = null; | |
| _targetSize = null; | |
| if (rebuild && mounted) { | |
| setState(() {}); | |
| } | |
| } | |
| void _cancelTimers() { | |
| _showTimer?.cancel(); | |
| _hideTimer?.cancel(); | |
| _autoHideTimer?.cancel(); | |
| _showTimer = null; | |
| _hideTimer = null; | |
| _autoHideTimer = null; | |
| } | |
| void _restartAutoHideTimer() { | |
| _autoHideTimer?.cancel(); | |
| if (widget.triggerMode == CustomTooltipTriggerMode.hover) { | |
| return; | |
| } | |
| if (widget.showDuration > Duration.zero) { | |
| _autoHideTimer = Timer(widget.showDuration, _removeTooltip); | |
| } | |
| } | |
| void _scheduleShow() { | |
| _hideTimer?.cancel(); | |
| _showTimer?.cancel(); | |
| if (_visible) return; | |
| if (widget.waitDuration == Duration.zero) { | |
| showTooltip(); | |
| } else { | |
| _showTimer = Timer(widget.waitDuration, showTooltip); | |
| } | |
| } | |
| void _scheduleHide() { | |
| hideTooltip(delay: widget.exitDuration); | |
| } | |
| void _captureTargetSize() { | |
| if (!mounted) return; | |
| final RenderObject? renderObject = context.findRenderObject(); | |
| final RenderBox? renderBox = renderObject is RenderBox ? renderObject : null; | |
| if (renderBox != null && renderBox.hasSize) { | |
| _targetSize = renderBox.size; | |
| } | |
| } | |
| BoxConstraints _effectiveConstraints() { | |
| return widget.constraints ?? | |
| (kIsWeb || | |
| defaultTargetPlatform == TargetPlatform.macOS || | |
| defaultTargetPlatform == TargetPlatform.windows || | |
| defaultTargetPlatform == TargetPlatform.linux | |
| ? const BoxConstraints(minHeight: 24) | |
| : const BoxConstraints(minHeight: 32)); | |
| } | |
| TextStyle _effectiveTextStyle(BuildContext context) { | |
| final ThemeData theme = Theme.of(context); | |
| return widget.textStyle ?? | |
| theme.textTheme.bodySmall?.copyWith( | |
| color: theme.colorScheme.onSurface, | |
| fontWeight: FontWeight.w500, | |
| ) ?? | |
| TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.w500); | |
| } | |
| Decoration _effectiveDecoration(BuildContext context) { | |
| if (widget.decoration != null) return widget.decoration!; | |
| final ThemeData theme = Theme.of(context); | |
| final Color surface = theme.colorScheme.surface; | |
| final Color accent = Color(globalSettings.themeColors.accentColor); | |
| final int alphaValue = globalSettings.themeColors.gradientAlpha; | |
| return BoxDecoration( | |
| gradient: LinearGradient( | |
| begin: Alignment.topLeft, | |
| end: Alignment.bottomRight, | |
| colors: <Color>[ | |
| surface.withValues(alpha: 0.92), | |
| Color.alphaBlend(accent.withValues(alpha: (alphaValue * 0.14 / 255)), surface.withValues(alpha: 0.92)), | |
| Color.alphaBlend(accent.withValues(alpha: (alphaValue * 0.06 / 255)), surface.withValues(alpha: 0.92)), | |
| ], | |
| ), | |
| borderRadius: BorderRadius.circular(10), | |
| border: Border.all(color: accent.withValues(alpha: 0.12)), | |
| boxShadow: <BoxShadow>[ | |
| BoxShadow( | |
| blurRadius: 16, | |
| offset: const Offset(0, 8), | |
| color: Colors.black.withValues(alpha: theme.brightness == Brightness.dark ? 0.3 : 0.1), | |
| ), | |
| ], | |
| ); | |
| } | |
| Size _measureTooltipSize() { | |
| final TextStyle textStyle = _effectiveTextStyle(context); | |
| final BoxConstraints constraints = _effectiveConstraints(); | |
| final TextPainter textPainter = TextPainter( | |
| text: TextSpan( | |
| text: widget.message, | |
| style: textStyle, | |
| ), | |
| textAlign: widget.textAlign ?? TextAlign.start, | |
| textDirection: Directionality.of(context), | |
| maxLines: null, | |
| ); | |
| final double horizontalPadding = widget.padding.horizontal; | |
| final double verticalPadding = widget.padding.vertical; | |
| final double horizontalMargin = widget.margin.horizontal; | |
| final double verticalMargin = widget.margin.vertical; | |
| final double maxTextWidth = constraints.hasBoundedWidth | |
| ? (constraints.maxWidth - horizontalPadding).clamp(0.0, double.infinity) | |
| : double.infinity; | |
| textPainter.layout(maxWidth: maxTextWidth); | |
| double width = textPainter.width + horizontalPadding + horizontalMargin; | |
| double height = textPainter.height + verticalPadding + verticalMargin; | |
| if (constraints.minWidth.isFinite && width < constraints.minWidth) { | |
| width = constraints.minWidth; | |
| } | |
| if (constraints.maxWidth.isFinite && width > constraints.maxWidth) { | |
| width = constraints.maxWidth; | |
| } | |
| if (constraints.minHeight.isFinite && height < constraints.minHeight) { | |
| height = constraints.minHeight; | |
| } | |
| if (constraints.maxHeight.isFinite && height > constraints.maxHeight) { | |
| height = constraints.maxHeight; | |
| } | |
| return Size(width, height); | |
| } | |
| Offset _calculateOffset() { | |
| final Size? targetSize = _targetSize; | |
| final Size? tooltipSize = _tooltipSize; | |
| if (targetSize == null || tooltipSize == null || !mounted) { | |
| return Offset(0, widget.verticalOffset); | |
| } | |
| final OverlayState overlayState = Overlay.of(context, rootOverlay: true); | |
| final RenderBox? overlayBox = overlayState.context.findRenderObject() as RenderBox?; | |
| if (overlayBox == null || !overlayBox.hasSize) { | |
| final double dx = (targetSize.width / 2) - (tooltipSize.width / 2); | |
| if (widget.preferBelow) { | |
| return Offset(dx, targetSize.height + widget.verticalOffset); | |
| } else { | |
| return Offset(dx, -(tooltipSize.height + widget.verticalOffset)); | |
| } | |
| } | |
| final RenderBox? targetBox = context.findRenderObject() as RenderBox?; | |
| if (targetBox == null || !targetBox.hasSize) { | |
| return Offset(0, widget.verticalOffset); | |
| } | |
| // Target position in overlay coordinates. | |
| final Offset targetTopLeftInOverlay = targetBox.localToGlobal(Offset.zero, ancestor: overlayBox); | |
| // Ideal tooltip position in overlay coordinates. | |
| double desiredLeft = targetTopLeftInOverlay.dx + (targetSize.width / 2) - (tooltipSize.width / 2); | |
| double desiredTop; | |
| if (widget.preferBelow) { | |
| desiredTop = targetTopLeftInOverlay.dy + targetSize.height + widget.verticalOffset; | |
| } else { | |
| desiredTop = targetTopLeftInOverlay.dy - tooltipSize.height - widget.verticalOffset; | |
| } | |
| final double screenPadding = widget.viewportMargin; | |
| final Size overlaySize = overlayBox.size; | |
| // Clamp horizontally. | |
| final double minLeft = screenPadding; | |
| final double maxLeft = overlaySize.width - tooltipSize.width - screenPadding; | |
| desiredLeft = desiredLeft.clamp(minLeft, maxLeft); | |
| // Clamp vertically, and flip if preferred side does not fit. | |
| final double belowTop = targetTopLeftInOverlay.dy + targetSize.height + widget.verticalOffset; | |
| final double aboveTop = targetTopLeftInOverlay.dy - tooltipSize.height - widget.verticalOffset; | |
| final bool fitsBelow = belowTop + tooltipSize.height <= overlaySize.height - screenPadding; | |
| final bool fitsAbove = aboveTop >= screenPadding; | |
| if (widget.preferBelow) { | |
| if (fitsBelow) { | |
| desiredTop = belowTop; | |
| } else if (fitsAbove) { | |
| desiredTop = aboveTop; | |
| } else { | |
| desiredTop = belowTop.clamp( | |
| screenPadding, | |
| overlaySize.height - tooltipSize.height - screenPadding, | |
| ); | |
| } | |
| } else { | |
| if (fitsAbove) { | |
| desiredTop = aboveTop; | |
| } else if (fitsBelow) { | |
| desiredTop = belowTop; | |
| } else { | |
| desiredTop = aboveTop.clamp( | |
| screenPadding, | |
| overlaySize.height - tooltipSize.height - screenPadding, | |
| ); | |
| } | |
| } | |
| // Convert back to follower-local offset. | |
| final double localDx = desiredLeft - targetTopLeftInOverlay.dx; | |
| final double localDy = desiredTop - targetTopLeftInOverlay.dy; | |
| return Offset(localDx, localDy); | |
| } | |
| Widget _buildTooltipContent(BuildContext context) { | |
| final Decoration decoration = _effectiveDecoration(context); | |
| final ThemeData theme = Theme.of(context); | |
| return Container( | |
| margin: widget.margin, | |
| constraints: _effectiveConstraints(), | |
| child: Material( | |
| type: MaterialType.transparency, | |
| child: ClipRRect( | |
| borderRadius: decoration is BoxDecoration | |
| ? (decoration.borderRadius as BorderRadius? ?? BorderRadius.circular(10)) | |
| : BorderRadius.circular(10), | |
| child: DecoratedBox( | |
| decoration: decoration, | |
| child: DecoratedBox( | |
| decoration: BoxDecoration( | |
| gradient: LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: <Color>[ | |
| Colors.white.withValues(alpha: theme.brightness == Brightness.dark ? 0.03 : 0.08), | |
| Colors.transparent, | |
| ], | |
| ), | |
| ), | |
| child: Padding( | |
| padding: widget.padding, | |
| child: DefaultTextStyle( | |
| style: _effectiveTextStyle(context), | |
| child: Text( | |
| widget.message, | |
| textAlign: widget.textAlign, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| OverlayEntry _createOverlayEntry() { | |
| return OverlayEntry( | |
| builder: (BuildContext overlayContext) { | |
| return Positioned.fill( | |
| child: Stack( | |
| children: <Widget>[ | |
| if (widget.enableTapToDismiss && !widget.ignorePointer) | |
| Positioned.fill( | |
| child: GestureDetector( | |
| behavior: HitTestBehavior.translucent, | |
| onTap: _removeTooltip, | |
| ), | |
| ), | |
| CompositedTransformFollower( | |
| link: _layerLink, | |
| showWhenUnlinked: false, | |
| offset: _calculateOffset(), | |
| child: IgnorePointer( | |
| ignoring: widget.ignorePointer, | |
| child: _buildTooltipContent(overlayContext), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| }, | |
| ); | |
| } | |
| void _handleTap() { | |
| if (_visible) { | |
| if (widget.enableTapToDismiss) { | |
| hideTooltip(); | |
| } | |
| return; | |
| } | |
| if (widget.enableFeedback ?? true) { | |
| Feedback.forTap(context); | |
| } | |
| showTooltip(); | |
| } | |
| void _handleLongPress() { | |
| if (widget.enableFeedback ?? true) { | |
| Feedback.forLongPress(context); | |
| } | |
| showTooltip(); | |
| } | |
| Widget _buildTriggerWrapper() { | |
| Widget current = widget.child; | |
| switch (widget.triggerMode) { | |
| case CustomTooltipTriggerMode.hover: | |
| current = MouseRegion( | |
| cursor: widget.mouseCursor ?? MouseCursor.defer, | |
| onEnter: (_) => _scheduleShow(), | |
| onExit: (_) => _scheduleHide(), | |
| child: current, | |
| ); | |
| break; | |
| case CustomTooltipTriggerMode.tap: | |
| current = MouseRegion( | |
| cursor: widget.mouseCursor ?? MouseCursor.defer, | |
| child: GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onTap: _handleTap, | |
| child: current, | |
| ), | |
| ); | |
| break; | |
| case CustomTooltipTriggerMode.longPress: | |
| current = MouseRegion( | |
| cursor: widget.mouseCursor ?? MouseCursor.defer, | |
| child: GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onLongPress: _handleLongPress, | |
| child: current, | |
| ), | |
| ); | |
| break; | |
| case CustomTooltipTriggerMode.manual: | |
| current = MouseRegion( | |
| cursor: widget.mouseCursor ?? MouseCursor.defer, | |
| child: current, | |
| ); | |
| break; | |
| } | |
| if (widget.excludeFromSemantics) { | |
| return ExcludeSemantics(child: current); | |
| } | |
| return Semantics( | |
| tooltip: widget.message, | |
| child: current, | |
| ); | |
| } | |
| @override | |
| void dispose() { | |
| _removeTooltip(rebuild: false); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return CompositedTransformTarget( | |
| link: _layerLink, | |
| child: _buildTriggerWrapper(), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment