Skip to content

Instantly share code, notes, and snippets.

@Far-Se
Last active April 17, 2026 13:27
Show Gist options
  • Select an option

  • Save Far-Se/4816ea47ba06ae0de6b348ae5ee9fab6 to your computer and use it in GitHub Desktop.

Select an option

Save Far-Se/4816ea47ba06ae0de6b348ae5ee9fab6 to your computer and use it in GitHub Desktop.
Flutter Custom Tooltip that truly ignores the pointerEvent.
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