Last active
April 15, 2025 17:28
-
-
Save wispborne/60a85ea3368bb738519f2048248cc2cb to your computer and use it in GitHub Desktop.
Flutter widget to display a tooltip that follows the cursor and doesn't block clicks, like video game tooltips.
This file contains 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:math' as math; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:trios/themes/theme_manager.dart'; | |
import 'package:trios/thirdparty/dartx/string.dart'; | |
import 'package:trios/widgets/tooltip_frame.dart'; | |
enum TooltipPosition { topLeft, topRight, bottomLeft, bottomRight } | |
/// A render object that positions the tooltip based on mouse position. | |
class _TooltipRenderBox extends RenderShiftedBox { | |
Offset _mousePosition; | |
double _windowEdgePadding; | |
Size _offset; | |
TooltipPosition _position; | |
MediaQueryData _mediaQuery; | |
_TooltipRenderBox({ | |
RenderBox? child, | |
required Offset mousePosition, | |
required double windowEdgePadding, | |
required Size offset, | |
required TooltipPosition position, | |
required MediaQueryData mediaQuery, | |
}) : _mousePosition = mousePosition, | |
_windowEdgePadding = windowEdgePadding, | |
_offset = offset, | |
_position = position, | |
_mediaQuery = mediaQuery, | |
super(child); | |
set mousePosition(Offset value) { | |
if (_mousePosition != value) { | |
_mousePosition = value; | |
markNeedsLayout(); | |
} | |
} | |
set windowEdgePadding(double value) { | |
if (_windowEdgePadding != value) { | |
_windowEdgePadding = value; | |
markNeedsLayout(); | |
} | |
} | |
set offset(Size value) { | |
if (_offset != value) { | |
_offset = value; | |
markNeedsLayout(); | |
} | |
} | |
set position(TooltipPosition value) { | |
if (_position != value) { | |
_position = value; | |
markNeedsLayout(); | |
} | |
} | |
set mediaQuery(MediaQueryData value) { | |
if (_mediaQuery != value) { | |
_mediaQuery = value; | |
markNeedsLayout(); | |
} | |
} | |
@override | |
void performLayout() { | |
child?.layout(constraints.loosen(), parentUsesSize: true); | |
final childSize = child?.size ?? Size.zero; | |
size = constraints.biggest; | |
final maxWidth = size.width; | |
final maxHeight = size.height; | |
final availableHeight = | |
maxHeight - _mediaQuery.padding.top - _mediaQuery.padding.bottom; | |
final availableWidth = | |
maxWidth - _mediaQuery.padding.left - _mediaQuery.padding.right; | |
final upperLimitFromTop = | |
(availableHeight - _windowEdgePadding) - childSize.height; | |
final upperLimitFromLeft = | |
(availableWidth - _windowEdgePadding) - childSize.width; | |
double top = 0; | |
double left = 0; | |
switch (_position) { | |
case TooltipPosition.topLeft: | |
top = (_mousePosition.dy - (_offset.height + childSize.height)).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromTop), | |
); | |
left = (_mousePosition.dx - (_offset.width + childSize.width)).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromLeft), | |
); | |
break; | |
case TooltipPosition.topRight: | |
top = (_mousePosition.dy - (_offset.height + childSize.height)).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromTop), | |
); | |
left = (_mousePosition.dx + _offset.width).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromLeft), | |
); | |
break; | |
case TooltipPosition.bottomLeft: | |
top = (_mousePosition.dy + _offset.height).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromTop), | |
); | |
left = (_mousePosition.dx - (_offset.width + childSize.width)).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromLeft), | |
); | |
break; | |
case TooltipPosition.bottomRight: | |
top = (_mousePosition.dy + _offset.height).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromTop), | |
); | |
left = (_mousePosition.dx + _offset.width).clamp( | |
_windowEdgePadding, | |
math.max(_windowEdgePadding, upperLimitFromLeft), | |
); | |
break; | |
} | |
final childParentData = child?.parentData as BoxParentData?; | |
if (childParentData != null) { | |
childParentData.offset = Offset(left, top); | |
} | |
} | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
if (child != null) { | |
final childParentData = child!.parentData as BoxParentData; | |
context.paintChild(child!, childParentData.offset + offset); | |
} | |
} | |
} | |
/// A widget that positions the tooltip in one layout pass. | |
class _TooltipLayout extends SingleChildRenderObjectWidget { | |
final Offset mousePosition; | |
final double windowEdgePadding; | |
final Size offset; | |
final TooltipPosition position; | |
const _TooltipLayout({ | |
required Widget child, | |
required this.mousePosition, | |
required this.windowEdgePadding, | |
required this.offset, | |
required this.position, | |
}) : super(child: child); | |
@override | |
RenderObject createRenderObject(BuildContext context) { | |
return _TooltipRenderBox( | |
child: child is! RenderBox ? null : child as RenderBox, | |
mousePosition: mousePosition, | |
windowEdgePadding: windowEdgePadding, | |
offset: offset, | |
position: position, | |
mediaQuery: MediaQuery.of(context), | |
); | |
} | |
@override | |
void updateRenderObject( | |
BuildContext context, | |
covariant _TooltipRenderBox renderObject, | |
) { | |
renderObject | |
..mousePosition = mousePosition | |
..windowEdgePadding = windowEdgePadding | |
..offset = offset | |
..position = position | |
..mediaQuery = MediaQuery.of(context); | |
} | |
} | |
enum TooltipWarningLevel { none, warning, error } | |
class MovingTooltipWidget extends StatefulWidget { | |
final Widget child; | |
final Widget tooltipWidget; | |
final double windowEdgePadding; | |
final Size offset; | |
final TooltipPosition position; | |
const MovingTooltipWidget({ | |
super.key, | |
required this.child, | |
required this.tooltipWidget, | |
this.windowEdgePadding = 10.0, | |
this.offset = const Size(5, 5), | |
this.position = TooltipPosition.bottomRight, | |
}); | |
static Widget text({ | |
Key? key, | |
required String? message, | |
required Widget child, | |
TooltipWarningLevel? warningLevel, | |
TextStyle? textStyle, | |
Color? backgroundColor, | |
double windowEdgePadding = 10.0, | |
Size offset = const Size(5, 5), | |
TooltipPosition position = TooltipPosition.bottomRight, | |
}) { | |
return message.isNotNullOrBlank | |
? Builder( | |
builder: (context) { | |
return MovingTooltipWidget( | |
key: key, | |
tooltipWidget: TooltipFrame( | |
backgroundColor: backgroundColor, | |
borderColor: switch (warningLevel) { | |
null || TooltipWarningLevel.none => null, | |
TooltipWarningLevel.warning || | |
TooltipWarningLevel.error => Theme.of( | |
context, | |
).colorScheme.onSecondaryContainer.withOpacity(0.5), | |
}, | |
child: Text( | |
message!, | |
style: (textStyle ?? Theme.of(context).textTheme.bodySmall) | |
?.copyWith( | |
color: switch (warningLevel) { | |
null => textStyle?.color, | |
TooltipWarningLevel.none => null, | |
TooltipWarningLevel.warning => | |
ThemeManager.vanillaWarningColor, | |
TooltipWarningLevel.error => | |
ThemeManager.vanillaErrorColor, | |
}, | |
), | |
), | |
), | |
windowEdgePadding: windowEdgePadding, | |
offset: offset, | |
position: position, | |
child: child, | |
); | |
}, | |
) | |
: child; | |
} | |
static Widget framed({ | |
Key? key, | |
required Widget? tooltipWidget, | |
TooltipWarningLevel? warningLevel, | |
required Widget child, | |
EdgeInsetsGeometry padding = const EdgeInsets.all(8), | |
double windowEdgePadding = 10.0, | |
Size offset = const Size(5, 5), | |
TooltipPosition position = TooltipPosition.bottomRight, | |
}) { | |
if (tooltipWidget == null) return child; | |
return Builder( | |
builder: (context) { | |
return MovingTooltipWidget( | |
key: key, | |
tooltipWidget: TooltipFrame( | |
padding: padding, | |
borderColor: switch (warningLevel) { | |
null || TooltipWarningLevel.none => null, | |
TooltipWarningLevel.warning || | |
TooltipWarningLevel.error => Theme.of( | |
context, | |
).colorScheme.onSecondaryContainer.withOpacity(0.5), | |
}, | |
child: tooltipWidget, | |
), | |
windowEdgePadding: windowEdgePadding, | |
offset: offset, | |
position: position, | |
child: child, | |
); | |
}, | |
); | |
} | |
@override | |
State<MovingTooltipWidget> createState() => _MovingTooltipWidgetState(); | |
} | |
class _MovingTooltipWidgetState extends State<MovingTooltipWidget> { | |
OverlayEntry? _overlayEntry; | |
late final int _depth; | |
bool _blockTooltip = false; // Prevents parent tooltip from activating | |
_MovingTooltipWidgetState? _parentState; // Cache parent reference | |
Offset? _latestGlobalMousePosition; | |
@override | |
void initState() { | |
super.initState(); | |
_parentState = context.findAncestorStateOfType<_MovingTooltipWidgetState>(); | |
_depth = (_parentState?._depth ?? 0) + 1; | |
} | |
@override | |
Widget build(BuildContext context) { | |
return MouseRegion( | |
onEnter: (event) { | |
if (!_blockTooltip) _showTooltip(event.position); | |
}, | |
onHover: (event) => _updateTooltipPosition(event.position), | |
onExit: (_) => _hideTooltip(), | |
child: widget.child, | |
); | |
} | |
void _showTooltip(Offset globalPosition) { | |
_hideTooltip(); | |
if (_blockTooltip) return; | |
_latestGlobalMousePosition = globalPosition; | |
_parentState?._setTooltipBlock(true); // Disable parent tooltip | |
_overlayEntry = OverlayEntry( | |
builder: | |
(_) => Stack( | |
children: [ | |
_TooltipLayout( | |
mousePosition: _latestGlobalMousePosition!, | |
windowEdgePadding: widget.windowEdgePadding, | |
offset: widget.offset, | |
position: widget.position, | |
child: IgnorePointer(child: widget.tooltipWidget), | |
), | |
], | |
), | |
); | |
Overlay.of(context).insert(_overlayEntry!); | |
} | |
void _updateTooltipPosition(Offset globalPosition) { | |
if (_overlayEntry != null && _blockTooltip) { | |
_hideTooltip(); | |
return; | |
} else if (_overlayEntry == null && !_blockTooltip) { | |
_showTooltip(globalPosition); | |
return; | |
} | |
_latestGlobalMousePosition = globalPosition; | |
_overlayEntry?.markNeedsBuild(); | |
} | |
void _hideTooltip() { | |
if (_overlayEntry != null) { | |
_overlayEntry!.remove(); | |
_overlayEntry = null; | |
} | |
_parentState?._setTooltipBlock(false); // Re-enable parent's tooltip | |
} | |
@override | |
void dispose() { | |
_hideTooltip(); | |
super.dispose(); | |
} | |
// Set the flag to disable this widget's tooltip | |
void _setTooltipBlock(bool block) { | |
// Ensure the widget is still mounted before scheduling the callback | |
if (!mounted) return; | |
// Schedule the setState call to run after the current frame | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
// Double-check if the widget is still mounted when the callback executes, | |
// as it might have been disposed in the meantime. | |
if (!mounted) return; | |
setState(() { | |
_blockTooltip = block; | |
}); | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment