Camelid
Camelid

Reputation: 1575

Show tooltip when TextSpan is hovered over

How do I show a tooltip when a particular TextSpan is hovered over? I found Flutter: How to display Tooltip for TextSpan inside RichText but that is for when a TextSpan is tapped on, not hovered over with the mouse, and it uses a SnackBar, whereas I want a tooltip.

Unfortunately I cannot just wrap the TextSpan in a ToolTip because my use case is in an override of TextEditingController.buildTextSpan where I'm returning a TextSpan(children: childSpans), which means I have to use a subclass of TextSpan.

Upvotes: 2

Views: 2619

Answers (1)

Thierry
Thierry

Reputation: 8383

I didn't find a direct way to do this.

But here is a possible solution using Hover on the whole RichText and then identifying which TextSpan is the target, and whether or not it has a tooltip.

enter image description here

Though, it's not an easy ride. Buckle up!

I tried to keep my application as simple as possible:

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'TextSpan Hover Demo',
      home: HomePage(),
    ),
  );
}

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _textKey = GlobalKey();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        padding: EdgeInsets.all(16.0),
        child: RichTextTooltipDetector(
          textKey: _textKey,
          child: RichText(
            key: _textKey,
            text: TextSpan(
              text: 'Hello ',
              children: <TextSpan>[
                TextSpan(
                  text: 'bold',
                  style: TextStyle(fontWeight: FontWeight.bold),
                  semanticsLabel: 'Tooltip: Yeah! It works',
                ),
                TextSpan(text: ' world!'),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

This RichTextTooltipDetector is where I handle the Tooltips as OverlayEntries.

My RichTextTooltipDetector is just a MouseRegion on which I will handle the hover events. This event gives me the local position of the mouse. This position, together with the RichText GlobalKey, is all we need to identify whether we have a tooltip to show or not:

  1. From the RichText Global Key, I get the RenderParagraph
  2. From this RenderParagraph and the localPositionof the PointerHoverEvent, I get the `InlineSpan, if any
  3. When I defined the TextSpan, I highjacked the semanticsLabel. This let me know easily if I need to display a Tooltip or not.

The rest is just basic OverlayEntry management:

  1. Creating an OverlayEntry based on the BuildContext, an offset, and the Tooltip text
  2. Displaying the OverlayEntry with Overlay.of(context).insert(_tooltipOverlay);
  3. After 1 second, hiding the OverlayEntry with _tooltipOverlay?.remove();
import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class _RichTextTooltipDetectorState extends State<RichTextTooltipDetector> {
  OverlayEntry _tooltipOverlay;
  Timer _timer;

  RenderParagraph get _renderParagraph =>
      widget.textKey.currentContext?.findRenderObject() as RenderParagraph;

  InlineSpan _span(PointerHoverEvent event, RenderParagraph paragraph) {
    final textPosition = paragraph.getPositionForOffset(event.localPosition);
    return paragraph.text.getSpanForPosition(textPosition);
  }

  String _tooltipText(PointerHoverEvent event, RenderParagraph paragraph) {
    final span = _span(event, paragraph);
    return span is TextSpan &&
            span.semanticsLabel != null &&
            span.semanticsLabel.startsWith('Tooltip: ')
        ? span.semanticsLabel.split('Tooltip: ')[1]
        : '';
  }

  OverlayEntry _createTooltip(
      BuildContext context, String text, Offset offset) {
    return OverlayEntry(
      builder: (context) => Positioned(
        left: offset.dx,
        top: offset.dy,
        child: Material(
          elevation: 4.0,
          child: Container(
            decoration: BoxDecoration(
              color: Colors.white70,
              border: Border.all(color: Colors.black87, width: 3.0),
            ),
            padding: EdgeInsets.all(8.0),
            child: Text(text),
          ),
        ),
      ),
    );
  }

  void _showTooltip() {
    Overlay.of(context).insert(_tooltipOverlay);
    _timer = Timer(Duration(seconds: 1), () => _hideTooltip());
  }

  void _hideTooltip() {
    _timer?.cancel();
    _tooltipOverlay?.remove();
    _tooltipOverlay = null;
    _timer = null;
  }

  void _handleHover(BuildContext context, PointerHoverEvent event) {
    _hideTooltip();
    final paragraph = _renderParagraph;
    if (event is! PointerHoverEvent || paragraph == null) return;
    final tooltipText = _tooltipText(event, paragraph);
    if (tooltipText.isNotEmpty) {
      RenderBox renderBox = context.findRenderObject();
      var offset = renderBox.localToGlobal(event.localPosition);
      _tooltipOverlay = _createTooltip(context, tooltipText, offset);
      _showTooltip();
    }
  }

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onHover: (event) => _handleHover(context, event),
      child: widget.child,
    );
  }
}

Upvotes: 2

Related Questions