Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I scroll to a substring in a TextField

My textfield has a lot of text and is scrollable. I want to scroll to a specific substring or index (marked with red) when I click on a button. How can I achieve this?

I have tried following solution:

void scrollToWord(String word) {
  final text = _controller.text;
  
  // Find the position of the word in the text
  int index = text.indexOf(word);
  if (index == -1) {
    // Word not found, return early
    return;
  }

  // Move the cursor to the start of the word
  _controller.selection = TextSelection.fromPosition(TextPosition(offset: index));

  // Calculate the scroll position (find position of word's start)
  final textPainter = TextPainter(
    text: TextSpan(text: text),
    textDirection: TextDirection.ltr,
  );
  textPainter.layout(maxWidth: MediaQuery.of(context).size.width);
  
  // Calculate the word's start position on screen
  final wordStartOffset = textPainter.getOffsetForCaret(TextPosition(offset: index));
  final wordHeight = textPainter.size.height;

  // Scroll to the word's position
  _scrollController.animateTo(
    wordStartOffset.dy, // Scroll position
    duration: Duration(milliseconds: 300),
    curve: Curves.easeInOut,
  );
}

But this does not work, as the word startOffset is way off somehow. I also tried to scroll to a line, where the word is located, but even line calculation is not working in my environment. This is how I build the TextWidget and the screen in general.

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Scaffold(
          appBar: DefaultAppBar(
            title: Strings.writeEssay,
            trailingIcon: Icon(
                widget.question.isSaved
                    ? Icons.star
                    : Icons.star_border_outlined,
                color: textDefault),
            backButtonIcon: Icons.arrow_back_ios_new_outlined,
            onBackButtonPressed: _handleBackButtonPressed,
            onTrailingIconPressed: _handleSaveIconPressed,
          ),
          body: Stack(
            children: [
              Column(
                children: [
                  isCorrected
                      ? mistakes.isNotEmpty
                          ? Padding(
                              padding: const EdgeInsets.only(
                                  left: 16, top: 16, right: 16),
                              child: Container(
                                padding: const EdgeInsets.all(16.0),
                                decoration: BoxDecoration(
                                  border: Border.all(
                                    color: Colors.grey,
                                  ),
                                  borderRadius: BorderRadius.circular(8.0),
                                ),
                                child: IntrinsicHeight(
                                  child: TextCorrectionWidget(
                                    controller: _descriptionController
                                        as StyledTextEditingController,
                                    scrollController: _scrollController,
                                    mistakes: mistakes,
                                  ),
                                ),
                              ),
                            )
                          : Container()
                      : Container(),
                  const SizedBox(height: 10),
                  Expanded(
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
                      child: TextField(
                        scrollController: _scrollController,
                        scrollPhysics: const AlwaysScrollableScrollPhysics(),
                        selectionControls: MaterialTextSelectionControls(),
                        key: UniqueKey(),
                        controller: _descriptionController,
                        maxLines: null,
                        readOnly: false, // todo change
                        textAlignVertical: TextAlignVertical.top,
                        expands: true,
                        autocorrect: false,
                        cursorColor: primaryColor,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          hintText: Strings.writeHereIndicatorText,
                        ),
                        style: const TextStyle(
                            fontSize: 18,
                            fontFamily: 'Rubik',
                            fontWeight: FontWeight.normal,
                            color: textDefault),
                      ),
                    ),
                  ),
                ],
              ),
              Align(
                alignment: Alignment.bottomCenter,
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
                  child: SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: _handleOnSave,
                      style: ElevatedButton.styleFrom(
                        shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(10),
                        ),
                        backgroundColor: primaryColor,
                        padding: const EdgeInsets.symmetric(vertical: 15),
                      ),
                      child: Text(
                        isCorrected ? Strings.seeGrade : Strings.sendEssay,
                        style: const TextStyle(
                            fontFamily: 'Rubik', color: Colors.white),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
        if (_isLoading) ...[
          Container(
            width: double.infinity,
            height: double.infinity,
            color: Colors.black.withAlpha(125),
            child: const Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  CircularProgressIndicator(color: primaryColor),
                  SizedBox(height: 16),
                  DefaultTextStyle(
                      style: TextStyle(
                          color: Colors.white,
                          fontFamily: 'Rubik',
                          fontSize: 18,
                          fontWeight: FontWeight.w500),
                      child: Text(Strings.essayIsCurrentlyCorrection)),
                ]),
          ),
        ]
      ],
    );
  }

img

like image 832
Lukas Avatar asked Nov 03 '25 08:11

Lukas


2 Answers

After some research, I got a solution to this problem.

The Demo Video of the provided solution: Scroll_to_target_text_in_TextField

Let's divide it into parts to be clear:

Problem Statement:

You want to:

  1. Take a word as input from the user (or from any other resource).
  2. Find this word inside the text stored in a TextField's controller.text.
  3. Get the position (offset) of this word within the TextField.
  4. Scroll to that position (offset) so the word becomes in visible scope.

To achieve this, you need two main controllers:

ScrollController: To control the scrolling behavior of the TextField.

TextEditingController: To manage the text and cursor position inside the TextField.

1. The Programmatical Part

Defining and initializing the Controllers:

 // Controller that will catch the target Word from the User
 // Also will be used to control the cursor position
 late final TextEditingController _searchController;

 // The Target contrller that we will scroll from it 
 late final TextEditingController _targetController;

 // The ScrollController of the TextField Widget
 late final ScrollController _scrollController;


// Initialize the Controllers in the initState
 @override
 void initState() {
   _searchController = TextEditingController();
   _targetController = TextEditingController();

   _scrollController = ScrollController();
   super.initState();
 }

 @override
 void dispose() {
   _searchController.dispose();
   _targetController.dispose();
   _scrollController.dispose();
   super.dispose();
 }

Then You Create the method that will control the Scroll:

 void get _scrollToSearchedText {
   log("Start to Scroll .....");

   final String contentText = _targetController.text;

   final String searchText = _searchController.text;

   final int indexOfTextinContent = contentText.indexOf(searchText);

   if (indexOfTextinContent != -1 || contentText.isNotEmpty) {
     log("trying ....");
     // ensure that all frames has been build and there are no Widgets need to bind
     WidgetsBinding.instance.addPostFrameCallback(
       (_) {
         final textPainter = TextPainter(
           text: TextSpan(
             text: contentText.substring(0, indexOfTextinContent),
             style: const TextStyle(fontSize: 16),
           ),
           textDirection: TextDirection.ltr,
         );

         // Layout the text to calculate the size
         // Computes the visual position of the glyphs for painting the text.
         textPainter.layout();

         // Calculate the scroll offset based on the text width and height

         final double scrollOffset = textPainter.size.height *
             (textPainter.size.width /
                 _scrollController.position.viewportDimension);

         // Scroll to the calculated offset

         _scrollController.jumpTo(scrollOffset.clamp(
             0.0, _scrollController.position.maxScrollExtent));

      
         // Move the cursor to the start of the searched word
         _targetController.selection = TextSelection.collapsed(
           offset: indexOfTextinContent,
         );
       },
     );
   }
 }

2. The UI Part

- Create the TextFielWidget Component

class CustomTextFielWidget extends StatelessWidget {
  const CustomTextFielWidget({
    super.key,
    required this.controller,
    required this.hintText,
    this.isTargetField = false,
    this.scrollController,
  });

  final TextEditingController controller;
  final String hintText;
  final bool isTargetField;

  final ScrollController? scrollController;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: isTargetField ? context.screenHeight * .5 : null, <---- Height is must
      padding: const EdgeInsets.all(10),
      child: TextField(
        controller: controller,
        scrollController: scrollController,
        scrollPhysics: const AlwaysScrollableScrollPhysics(),
        maxLines: isTargetField ? null : 1,
        style: TextStyle(
          fontSize: isTargetField ? 20 : 18,
          fontWeight: FontWeight.bold,
          fontFamily: isTargetField ? FontFamily.verlaFont : null,
        ),
        decoration: InputDecoration(
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: BorderSide(
              color: Colors.grey.withOpacity(0.6),
              width: 1.3,
            ),
          ),
          hintText: hintText,
          fillColor: Colors.grey.withOpacity(0.3),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: BorderSide(
              color: Colors.grey.withOpacity(0.6),
              width: 2.0,
            ),
          ),
        ),
      ),
    );
  }
}

You must define a height to a the Field or give it a Constrains to prevent it from Expansion

- Create the Button Widget Component

class ScrollButtonWidget extends StatelessWidget {
  const ScrollButtonWidget({
    super.key,
    required this.onScrollTap,
  });
  final void Function() onScrollTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: context.screenWidth * .7,
      height: context.screenHeight * .07,
      child: MaterialButton(
        onPressed: onScrollTap,
        color: Colors.blue,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(20),
        ),
        child: const Text("Scroll To Target Word"),
      ),
    );
  }
}

Finally this is the Full Code

import 'dart:developer';

import 'package:flutter/material.dart';

class ScrollToTextWidget extends StatefulWidget {
  const ScrollToTextWidget({super.key});

  @override
  State<ScrollToTextWidget> createState() => _ScrollToTextWidgetState();
}

class _ScrollToTextWidgetState extends State<ScrollToTextWidget> {
  // Controller that will catch the target Word from the User
  late final TextEditingController _searchController;

  // The Target contrller that we will scroll from it 
  late final TextEditingController _targetController;

  // The ScrollController of the TextField Widget
  late final ScrollController _scrollController;

  @override
  void initState() {
    _searchController = TextEditingController();
    _targetController = TextEditingController();

    _scrollController = ScrollController();
    super.initState();
  }

  @override
  void dispose() {
    _searchController.dispose();
    _targetController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  void get _scrollToSearchedText {
    log("Start to Scroll .....");

    final String contentText = _targetController.text;

    final String searchText = _searchController.text;

    final int indexOfTextinContent = contentText.indexOf(searchText);

    if (indexOfTextinContent != -1 || contentText.isNotEmpty) {
      log("trying ....");
      WidgetsBinding.instance.addPostFrameCallback(
        (_) {
          final TextPainter textPainter = TextPainter(
            text: TextSpan(
              text: contentText.substring(0, indexOfTextinContent),
              style: const TextStyle(fontSize: 16),
            ),
            textDirection: TextDirection.ltr,
          );

          // Layout the text to calculate the size
          // Computes the visual position of the glyphs for painting the text.
          textPainter.layout();

          // Calculate the scroll offset based on the text width and height

          final double scrollOffset = textPainter.size.height *
              (textPainter.size.width /
                  _scrollController.position.viewportDimension);

          // Scroll to the calculated offset

          _scrollController.jumpTo(scrollOffset.clamp(
              0.0, _scrollController.position.maxScrollExtent));

          
          // Move the cursor to the start of the searched word
          _targetController.selection = TextSelection.collapsed(
            offset: indexOfTextinContent,
          );
        },
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Scroll To Text"),
        automaticallyImplyLeading: false,
        backgroundColor: Colors.green,
      ),
      body: Column(
        children: <Widget>[
          Expanded(
            child: SingleChildScrollView(
              child: Column(
                children: <Widget>[
                  gapH1,
                  CustomTextFielWidget(
                    controller: _searchController,
                    hintText: "Enter Target Text",
                  ),
                  gapH1,
                  CustomTextFielWidget(
                    controller: _targetController,
                    hintText: "Content Text",
                    scrollController: _scrollController,
                    isTargetField: true,
                  ),
                  gapH2,
                ],
              ),
            ),
          ),
          ScrollButtonWidget(
            onScrollTap: () {
              _scrollToSearchedText;
            },
          )
        ],
      ),
    );
  }
}

class CustomTextFielWidget extends StatelessWidget {
  const CustomTextFielWidget({
    super.key,
    required this.controller,
    required this.hintText,
    this.isTargetField = false,
    this.scrollController,
  });

  final TextEditingController controller;
  final String hintText;
  final bool isTargetField;

  final ScrollController? scrollController;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: isTargetField ? context.screenHeight * .5 : null,
      padding: const EdgeInsets.all(10),
      child: TextField(
        controller: controller,
        scrollController: scrollController,
        scrollPhysics: const AlwaysScrollableScrollPhysics(),
        maxLines: isTargetField ? null : 1,
        style: TextStyle(
          fontSize: isTargetField ? 20 : 18,
          fontWeight: FontWeight.bold,
          fontFamily: isTargetField ? FontFamily.verlaFont : null,
        ),
        decoration: InputDecoration(
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: BorderSide(
              color: Colors.grey.withOpacity(0.6),
              width: 1.3,
            ),
          ),
          hintText: hintText,
          fillColor: Colors.grey.withOpacity(0.3),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(10),
            borderSide: BorderSide(
              color: Colors.grey.withOpacity(0.6),
              width: 2.0,
            ),
          ),
        ),
      ),
    );
  }
}

class ScrollButtonWidget extends StatelessWidget {
  const ScrollButtonWidget({
    super.key,
    required this.onScrollTap,
  });
  final void Function() onScrollTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: context.screenWidth * .7,
      height: context.screenHeight * .07,
      child: MaterialButton(
        onPressed: onScrollTap,
        color: Colors.blue,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(20),
        ),
        child: const Text("Scroll To Target Word"),
      ),
    );
  }
}

You can ignore some part of code like gapH1, screenWidth or screenHeight it's a properties I created

Also....

there is some note about the cursor , if you want the cursor be at the beginning of the word you the target textfield must be focus , you can handle this simply by wrapping the TextField with FocusScope Widget and control the focus form it when the user click on the button

like image 137
Mahmoud Al-shehyby Avatar answered Nov 04 '25 23:11

Mahmoud Al-shehyby


In this case, using TextPainter is not the ideal solution as you would need to mirror it's constructor arguments that the underlying RenderBox gives. Luckily, the last RenderBox of TextField is a RenderEditable which offers the same fuctionality as TextPainter.

In most flutter scenarios, values move down and events move up the tree, not here though. In this example I'm using find_rendebox to retrieve RenderEditable with a FocusNode (if more than one child of the context renders text, you should use a node) to manipulate it's ScrollController. If for whatever reason you need to build a widget based on text metrics, you will need to do so across multiple frames to avoid any inconsistency.

Example tl;dr, at random, one word gets focused (scroll to and highlight without using selections).

import 'dart:math';

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

void main() => runApp(
      const MaterialApp(
        home: Material(
          child: App(),
        ),
      ),
    );

extension BuildContextUtils on BuildContext {
  // Similar to [findRenderObject] but targeting a specific type.
  // If an [Element] contains multiple instances of [T] a [FocusNode] is
  // required for disambiguation.
  // Search is O(N), this method should not be called repeatedly or frequently.
  T? find_renderbox<T extends RenderBox>([FocusNode? node]) {
    Element element = (node?.context ?? this) as Element;

    void fetch_next(Element _element) {
      if (element == _element || element.renderObject is T?) {
        // Any element that inherits from [MultiChildRenderObjectElement]
        // will always visit all of it's children.
        return;
      }

      element = _element;

      if (element.renderObject is! T?) {
        element.visitChildElements(fetch_next);
      }
    }

    element.visitChildElements(fetch_next);

    if (element.renderObject is T?) {
      return element.renderObject as T?;
    }
    return null;
  }
}

class App extends StatefulWidget {
  const App({super.key});

  @override
  AppState createState() => AppState();
}

class AppState extends State<App> {
  
  final _node = FocusNode();
  final _scroll_controller = ScrollController();

  final _text_controller = TextEditingController(text: mustard_text);

  // Scrolls to a word containing the given index and returns a [Rect] local to
  // to the [RenderEditable] (not the [TextField]) containing the word. Returns
  // null int the case that the text position is truncated.
  // Optionally, you can make the [Rect] relative to a different widget by
  // passing it's global key.
  // note: Empty spaces and new lines are considered words (new lines are zero sized).
  Future<Rect?> text_field_scroll_to_word(int index, FocusNode node, [GlobalKey? offset_to]) async {
    assert(_text_controller.text.length >= index && index >= 0);

    // [RenderEditable] holds it's [TextPainter] as a private variable but
    // not it's constructor values (style, strutStyle, ...).
    // [RenderEditable.painter] is not a [TextPainter]
    final editable = context.find_renderbox<RenderEditable>(node);

    // Nothing to do.
    if (editable == null) return null;

    final word_range = editable.getWordBoundary(TextPosition(offset: index));

    // The position is local to the to the scroll, i.e, it's a scroll offset
    final word_rect = editable.getRectForComposingRange(word_range);

    // This should not trigger if 'editable.maxLines' is null
    if (word_rect == null) return null;

    await _scroll_controller.animateTo(
      _scroll_controller.offset + word_rect.top,
      duration: Durations.medium2,
      curve: Curves.easeOut,
    );

    final out_rect = editable.getRectForComposingRange(word_range);

    if (offset_to != null) {
      final top_left = MatrixUtils.transformPoint(
        editable.getTransformTo(
          offset_to.currentContext?.findRenderObject(),
        ),
        editable.semanticBounds.topLeft,
      );
      return out_rect?.shift(top_left);
    }

    return out_rect;
  }

  Rect? rect;

  final _stack_key = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          SizedBox(
            width: MediaQuery.sizeOf(context).width * 0.8,
            height: MediaQuery.sizeOf(context).height * 0.7,
            child: Stack(
              key: _stack_key,
              children: [
                TextField(
                  expands: true,
                  maxLines: null,
                  focusNode: _node,
                  controller: _text_controller,
                  scrollController: _scroll_controller,
                ),
                if (rect != null)
                  Positioned.fromRect(
                    rect: rect!,
                    child: Container(
                      width: double.infinity,
                      height: double.infinity,
                      color: Colors.green.withAlpha(100),
                    ),
                  )
              ],
            ),
          ),
          FilledButton(
            onPressed: () {
              setState(() => rect = null);

              text_field_scroll_to_word(
                Random().nextInt(
                  _text_controller.text.length,
                ),
                _node,
                _stack_key,
              ).then(
                (new_rect) => setState(() => rect = new_rect),
              );
            },
            child: Text('Random word'),
          ),
        ],
      ),
    );
  }
}

const mustard_text = ('Mustard is a condiment made from the seeds of a mustard plant (white/yellow mustard, '
    'Sinapis alba; brown mustard, Brassica juncea; or black mustard, Brassica nigra).\n\n'
    'The whole, ground, cracked, or bruised mustard seeds are mixed with water, vinegar, '
    'lemon juice, wine, or other liquids, salt, and often other flavorings and spices, to '
    'create a paste or sauce ranging in color from bright yellow to dark brown. The seed '
    'itself has a strong, pungent, and somewhat bitter taste. The taste of mustard '
    'condiments ranges from sweet to spicy.\n\n'
    'Mustard is commonly paired with meats, vegetables and cheeses, especially as a condiment '
    'for sandwiches, hamburgers, and hot dogs. It is also used as an ingredient in many '
    'dressings, glazes, sauces, soups, relishes, and marinades. As a paste or as individual '
    'seeds, mustard is used as a condiment in the cuisine of India and Bangladesh, the '
    'Mediterranean, northern and southeastern Europe, Asia, the Americas, and Africa, making '
    'it one of the most popular and widely used spices and condiments in the world.');
like image 41
SrPanda Avatar answered Nov 04 '25 23:11

SrPanda



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!