Possible to identify global position of characters in Text widgets in Flutter

Issue

Let’s say I have a very long paragraph of text and want to overlay annotations over character 5, 10, and 1500 — how can I find the locations of those characters?

I considered referencing TextSpan components, however, unlike the rest of Flutter, these are not Widgets and cannot have a GlobalKey.

Solution

Easy peasy with TextPainter and Paragraph (thanks @pskink). See the important caveats for multiline web text at the end.

With TextPainter

import 'package:flutter/material.dart';

final loremIpsum =
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Welcome to Flutter'),
        ),
        body: Center(
          child: Container(
            width: 350,
            color: Color.fromARGB(100, 0, 0, 0),
            child: SelectText(),
          ),
        ),
      ),
    );
  }
}

class SelectText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
      final textPainter = TextPainter(
        text: TextSpan(text: loremIpsum),
        textDirection: TextDirection.ltr,
      );

      final width = constraints.maxWidth;
      textPainter.layout(
        minWidth: 20,
        maxWidth: width,
      );
      final height = textPainter.height;

      return Container(
          width: width,
          height: height,
          color: Colors.yellow,
          child: GestureDetector(
            onTapDown: (details) {
              print(
                  "Selection: ${textPainter.getPositionForOffset(details.localPosition)}");
            },
            child: CustomPaint(
              size: Size(width, height), // Parent width, text height
              painter: TextCustomPainter(textPainter),
            ),
          ));
    });
  }
}

class TextCustomPainter extends CustomPainter {
  TextPainter textPainter;

  TextCustomPainter(this.textPainter, {Listenable? repaint})
      : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    textPainter.paint(canvas, Offset(0, 0));
  }

  @override
  bool shouldRepaint(CustomPainter old) {
    return false;
  }
}

With Paragraph

import 'package:flutter/material.dart';
import 'dart:ui';
import 'dart:ui' as ui;

final loremIpsum =
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Welcome to Flutter'),
        ),
        body: Center(
          child: Container(
            width: 350,
            color: Color.fromARGB(100, 0, 0, 0),
            child: SelectText(),
          ),
        ),
      ),
    );
  }
}

class SelectText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final TextStyle style = TextStyle(
      color: Colors.black,
    );

    // Set width to max allowed by parent
    return LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
      final ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(
          ui.ParagraphStyle(
        fontSize: style.fontSize,
        // There unfortunelly are some things to be copied from your common TextStyle to ParagraphStyle :C
        fontFamily: style.fontFamily,
        // IDK why it is like this, this is somewhat weird especially when there is `pushStyle` which can use the TextStyle...
        fontStyle: style.fontStyle,
        fontWeight: style.fontWeight,
        textAlign: TextAlign.justify,
        //maxLines: 25,
      ))
        ..pushStyle(style
            .getTextStyle()) // To use multiple styles, you must make use of the builder and `pushStyle` and then `addText` (or optionally `pop`).
        ..addText(loremIpsum);

      final width = constraints.maxWidth;
      final ui.Paragraph paragraph = paragraphBuilder.build()
        ..layout(ui.ParagraphConstraints(width: width));

      paragraph.layout(ParagraphConstraints(
        width: width,
      ));
      final height = paragraph.height;

      return Container(
          width: width,
          height: height,
          color: Colors.yellow,
          child: GestureDetector(
            onTapDown: (details) {
              // BUG. On web- position is only correct for first line.
              print(
                  "Selection: ${paragraph.getPositionForOffset(details.localPosition)}");
            },
            child: CustomPaint(
              size: Size(width, height), // Parent width, text height
              painter: TextCustomPainter(paragraph),
            ),
          ));
    });
  }
}

class TextCustomPainter extends CustomPainter {
  Paragraph paragraph;

  TextCustomPainter(this.paragraph, {Listenable repaint})
      : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawParagraph(paragraph, const Offset(0, 0));
  }

  @override
  bool shouldRepaint(CustomPainter old) {
    return false;
  }
}

Web caveats

Both of the above methods are currently broken for web as they only correctly report the text position for the first line of text and are totally broken for multi-line text. See: https://github.com/flutter/flutter/issues/44121
This has been an open bug for web more than a year and there’s very slow activity here. Don’t expect a fix any time soon. Currently, some bad behavior is fixed in the master branch IFF you compile with both FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT and compile with --release, making your project undebuggable!

flutter build web  --release --dart-define=FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT=true

Flutter could use a simple addition to TextWidget to make this functionality more easily available :-/

Answered By – user48956

Answer Checked By – Robin (FlutterFixes Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *