Creating Interactive Custom Tooltip Widgets in Flutter
Tooltips are essential UI elements that provide contextual information or hints when a user interacts with a component, typically by hovering over or tapping it. While Flutter provides a built-in Tooltip widget, its customization options are limited, often making it challenging to achieve specific designs or interactive behaviors. This article will guide you through creating a highly customizable and interactive custom tooltip widget in Flutter, leveraging OverlayEntry and advanced positioning techniques.
Why Custom Tooltips?
Flutter's standard Tooltip widget is simple and effective for displaying plain text. However, when your design requires:
- Rich content within the tooltip (e.g., images, buttons, multiple text styles).
- Custom shapes, shadows, or background decorations.
- Complex show/hide animations.
- Interactive elements inside the tooltip that the user can click.
- Dynamic positioning logic.
...you'll quickly find the built-in widget insufficient. Building a custom solution gives you complete control over its appearance and behavior.
Core Concepts for Custom Tooltips
To create a custom interactive tooltip, we primarily rely on Flutter's OverlayEntry mechanism and precise positioning:
1. OverlayEntry and OverlayState
The Overlay widget is a stack-like widget that can display widgets on top of others, independently of the widget tree structure. An OverlayEntry is essentially a "portal" through which you can insert any widget into the Overlay. This is perfect for tooltips, which need to appear above all other content.
// To show an OverlayEntry
Overlay.of(context)!.insert(_overlayEntry);
// To hide an OverlayEntry
_overlayEntry?.remove();
2. Positioning with CompositedTransformTarget and CompositedTransformFollower
When creating an overlay, we need to position our custom tooltip relative to its target widget. Directly calculating coordinates can be brittle. Flutter offers a more robust solution:
CompositedTransformTarget: Placed around the widget that the tooltip should "follow." It marks a render object as a potential anchor point.CompositedTransformFollower: Placed within theOverlayEntry. It uses the `link` property to connect to a specificCompositedTransformTargetand then positions its child relative to that target. This ensures the tooltip stays correctly positioned even if the target widget moves or the screen scrolls.
3. Detecting Interaction: GestureDetector and MouseRegion
To trigger the tooltip, we'll use:
GestureDetector: For tap events on mobile devices.MouseRegion: For hover events on web and desktop, providing `onEnter` and `onExit` callbacks.
4. Animations
For a smooth user experience, tooltips should typically animate their appearance and disappearance. We can use `FadeTransition` or `SlideTransition` with an `AnimationController` for this.
Step-by-Step Implementation
Let's build a reusable InteractiveTooltip widget.
1. The InteractiveTooltip Widget Structure
This widget will manage the lifecycle of the OverlayEntry and handle the show/hide logic.
import 'package:flutter/material.dart';
import 'dart:async';
enum TooltipDirection {
top,
bottom,
left,
right,
}
class InteractiveTooltip extends StatefulWidget {
final Widget child;
final Widget Function(BuildContext context, VoidCallback hideTooltip) tooltipContentBuilder;
final TooltipDirection direction;
final Duration showDuration;
final Duration hideDelay;
final Duration animationDuration;
final bool showOnTap;
final bool showOnHover;
const InteractiveTooltip({
Key? key,
required this.child,
required this.tooltipContentBuilder,
this.direction = TooltipDirection.bottom,
this.showDuration = const Duration(seconds: 5),
this.hideDelay = const Duration(milliseconds: 100),
this.animationDuration = const Duration(milliseconds: 200),
this.showOnTap = false,
this.showOnHover = true,
}) : super(key: key);
@override
_InteractiveTooltipState createState() => _InteractiveTooltipState();
}
class _InteractiveTooltipState extends State with SingleTickerProviderStateMixin {
OverlayEntry? _overlayEntry;
final GlobalKey _targetKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
Timer? _hideTimer;
AnimationController? _animationController;
Animation? _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: widget.animationDuration,
);
_animation = CurvedAnimation(parent: _animationController!, curve: Curves.easeOut);
}
@override
void dispose() {
_hideTimer?.cancel();
_overlayEntry?.remove();
_animationController?.dispose();
super.dispose();
}
void _showTooltip() {
if (_overlayEntry != null) return; // Tooltip is already showing
_overlayEntry = OverlayEntry(
builder: (context) {
return FadeTransition(
opacity: _animation!,
child: _TooltipOverlay(
targetKey: _targetKey,
layerLink: _layerLink,
direction: widget.direction,
hideTooltip: _hideTooltip,
tooltipContentBuilder: widget.tooltipContentBuilder,
animationDuration: widget.animationDuration,
),
);
},
);
Overlay.of(context)!.insert(_overlayEntry!);
_animationController?.forward();
if (widget.showDuration != Duration.zero) {
_hideTimer = Timer(widget.showDuration, _hideTooltip);
}
}
void _hideTooltip() {
_hideTimer?.cancel(); // Cancel any pending hide timers
if (_overlayEntry == null) return; // Tooltip is not showing
_animationController?.reverse().then((_) {
if (mounted) { // Ensure the widget is still mounted before removing
_overlayEntry?.remove();
_overlayEntry = null;
}
});
}
void _handleTap() {
if (widget.showOnTap) {
_hideTimer?.cancel(); // Cancel hide if tapped to show
if (_overlayEntry == null) {
_showTooltip();
} else {
_hideTooltip();
}
}
}
void _handleHoverEnter() {
if (widget.showOnHover) {
_hideTimer?.cancel(); // Cancel hide if hovering
_showTooltip();
}
}
void _handleHoverExit() {
if (widget.showOnHover) {
_hideTimer = Timer(widget.hideDelay, _hideTooltip);
}
}
@override
Widget build(BuildContext context) {
Widget child = CompositedTransformTarget(
link: _layerLink,
key: _targetKey,
child: widget.child,
);
if (widget.showOnTap) {
child = GestureDetector(
onTap: _handleTap,
child: child,
);
}
if (widget.showOnHover) {
child = MouseRegion(
onEnter: (_) => _handleHoverEnter(),
onExit: (_) => _handleHoverExit(),
child: child,
);
}
return child;
}
}
2. The _TooltipOverlay Widget for Content and Positioning
This widget will be placed inside the OverlayEntry and uses CompositedTransformFollower for correct positioning.
class _TooltipOverlay extends StatelessWidget {
final GlobalKey targetKey;
final LayerLink layerLink;
final TooltipDirection direction;
final VoidCallback hideTooltip;
final Widget Function(BuildContext context, VoidCallback hideTooltip) tooltipContentBuilder;
final Duration animationDuration;
const _TooltipOverlay({
Key? key,
required this.targetKey,
required this.layerLink,
required this.direction,
required this.hideTooltip,
required this.tooltipContentBuilder,
required this.animationDuration,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final RenderBox renderBox = targetKey.currentContext!.findRenderObject() as RenderBox;
final Size targetSize = renderBox.size;
const double tooltipPadding = 8.0; // Space between target and tooltip
Offset offset;
Alignment alignment;
switch (direction) {
case TooltipDirection.top:
offset = Offset(targetSize.width / 2, -tooltipPadding);
alignment = Alignment.bottomCenter;
break;
case TooltipDirection.bottom:
offset = Offset(targetSize.width / 2, targetSize.height + tooltipPadding);
alignment = Alignment.topCenter;
break;
case TooltipDirection.left:
offset = Offset(-tooltipPadding, targetSize.height / 2);
alignment = Alignment.centerRight;
break;
case TooltipDirection.right:
offset = Offset(targetSize.width + tooltipPadding, targetSize.height / 2);
alignment = Alignment.centerLeft;
break;
}
return Positioned(
width: MediaQuery.of(context).size.width, // Spanning full width to allow flexible positioning
height: MediaQuery.of(context).size.height, // Spanning full height
child: CompositedTransformFollower(
link: layerLink,
offset: offset,
followerAnchor: alignment,
targetAnchor: alignment, // Match follower anchor for simple positioning
child: Align(
alignment: alignment, // Align the tooltip content within the follower's space
child: Material( // Wrap with Material to give elevation/shadow
color: Colors.transparent, // Make Material transparent
child: MouseRegion( // Allow interaction with the tooltip content
onExit: (_) => Timer(animationDuration, hideTooltip), // Hide after a slight delay
child: widget.tooltipContentBuilder(context, hideTooltip),
),
),
),
),
);
}
}
3. Example Usage in main.dart
Here's how you can integrate the InteractiveTooltip into your application:
import 'package:flutter/material.dart';
// Make sure to import the file where InteractiveTooltip is defined
// For example: import 'interactive_tooltip.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom Interactive Tooltip Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Interactive Tooltip Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InteractiveTooltip(
direction: TooltipDirection.bottom,
showOnTap: true,
showOnHover: false, // Only show on tap for this one
showDuration: const Duration(seconds: 10), // Stay for 10 seconds or until dismissed
tooltipContentBuilder: (context, hideTooltip) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'This is a custom interactive tooltip!',
style: TextStyle(color: Colors.white, fontSize: 16),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
// Perform an action, then hide the tooltip
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Action taken!')),
);
hideTooltip();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.blueAccent,
),
child: const Text('Take Action'),
),
],
),
),
child: ElevatedButton(
onPressed: () {}, // Empty onPressed as tap handled by tooltip
child: const Text('Tap Me for Info'),
),
),
const SizedBox(height: 50),
InteractiveTooltip(
direction: TooltipDirection.right,
showOnTap: false,
showOnHover: true, // Only show on hover for this one
hideDelay: const Duration(milliseconds: 300), // Slightly longer delay to allow cursor to enter tooltip
showDuration: Duration.zero, // Keep showing as long as hovered
tooltipContentBuilder: (context, hideTooltip) => Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, color: Colors.indigo, size: 30),
const SizedBox(height: 10),
const Text(
'Hover tooltip with icon!',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 5),
Text(
'You can customize this content extensively.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
textAlign: TextAlign.center,
),
],
),
),
),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.indigo[100],
borderRadius: BorderRadius.circular(5),
),
child: const Text(
'Hover over me',
style: TextStyle(color: Colors.indigo),
),
),
),
],
),
),
);
}
}
Explanation and Customization Points
InteractiveTooltipState Management: The `_InteractiveTooltipState` manages the `OverlayEntry` and an `AnimationController` for fade effects. It uses a `GlobalKey` for the target widget and a `LayerLink` to connect the `CompositedTransformTarget` and `CompositedTransformFollower`.- `tooltipContentBuilder` Callback: This powerful callback allows you to define the exact content of your tooltip. It receives the `BuildContext` and a `hideTooltip` function, which is crucial for making the content itself interactive (e.g., a button inside the tooltip that dismisses it).
- Direction and Positioning: The `TooltipDirection` enum and logic within `_TooltipOverlay` allow you to specify where the tooltip should appear relative to its child. The `offset` and `alignment` properties of `CompositedTransformFollower` are key here.
- Show/Hide Logic:
- `showDuration`: How long the tooltip remains visible after appearing (set to `Duration.zero` to keep it open indefinitely until dismissed).
- `hideDelay`: A short delay before hiding on hover exit, allowing the cursor to move into the tooltip content without immediately dismissing it.
- `showOnTap` / `showOnHover`: Booleans to control activation methods.
- `_hideTimer`: Used to automatically dismiss the tooltip after `showDuration`.
- Interactivity: The `MouseRegion` around the `tooltipContentBuilder` in `_TooltipOverlay` ensures that when the mouse hovers over the tooltip itself, it doesn't immediately trigger `onExit` from the target, preventing rapid flickering.
Conclusion
By leveraging Flutter's OverlayEntry, CompositedTransformTarget, and CompositedTransformFollower, you can build highly customizable and interactive tooltip widgets that go far beyond the capabilities of the default Tooltip. This approach gives you granular control over the tooltip's content, styling, positioning, and animation, enabling you to create rich and engaging user interfaces tailored to your application's specific needs.