Flutter — TextEditingController deep dive, part 1

Let’s take a deep dive into Flutter’s TextEditingController and unlock advanced text-editing capabilities for your app. In this tutorial, you’ll learn how to highlight all substrings in a TextField that match the user’s current selection—a feature reminiscent of “Find All” in text editors.

this is what we will implement from scratch

Setting up the basics

Let’s start with a minimal Flutter app that uses a custom controller:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Text Highlight',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const MyHomePage(),
    );
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class HighlightingTextEditingController extends TextEditingController {
  HighlightingTextEditingController({super.text});
}

class _MyHomePageState extends State<MyHomePage> {
  late HighlightingTextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = HighlightingTextEditingController(
      text: 'matcha is green\nmatcha is green\nmatcha is green',
    );
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TextField(controller: _controller, maxLines: null, expands: true),
    );
  }
}

At this stage, HighlightingTextEditingController simply extends TextEditingController without adding new behavior.

Adding Highlighting Functionality

Let’s enhance our controller to support highlighting:

1. Track Highlight Ranges Add a field to store the list of ranges that should be highlighted.

2. Override buildTextSpan Customize text rendering by overriding this method to apply background color to matched ranges (excluding the active selection).

3. Listen for Selection Changes Whenever the selection changes, search for all matching substrings and update the highlight ranges.

Here’s how the diff

--- before
+++ after
@@ -27,23 +27,75 @@ 
 }
 
 class HighlightingTextEditingController extends TextEditingController {
-  HighlightingTextEditingController({super.text});
+  HighlightingTextEditingController({super.text}) : highlightRanges = [];
+
+  List<TextRange> highlightRanges;
+  final Color highlightColor = Colors.lightGreenAccent;
+
+  // whether the character at this index is to be highlighted or not
+  bool inHighlightRanges(int ix) {
+    return highlightRanges.any((range) => ix >= range.start && ix < range.end);
+  }
+
+  @override
+  TextSpan buildTextSpan({
+    required BuildContext context,
+    TextStyle? style,
+    required bool withComposing,
+  }) {
+    if (highlightRanges.isEmpty || text.isEmpty) {
+      return super.buildTextSpan(
+        context: context,
+        style: style,
+        withComposing: withComposing,
+      );
+    } else {
+      final spans =
+          text.characters.indexed.map((e) {
+            final ix = e.$1;
+            final char = e.$2;
+            final inSelection = ix >= selection.start && ix < selection.end;
+            return TextSpan(
+              text: char,
+              style:
+                  !inSelection && inHighlightRanges(ix)
+                      ? style?.copyWith(backgroundColor: highlightColor)
+                      : style,
+            );
+          }).toList();
+      return TextSpan(children: spans, style: style);
+    }
+  }
 } 
 
 class _MyHomePageState extends State<MyHomePage> {
   late HighlightingTextEditingController _controller;
 
+  void _updateHighlightRanges() {
+    final selectedText = _controller.selection.textInside(_controller.text);
+    final pattern = RegExp(selectedText);
+    final matches = pattern.allMatches(_controller.text);
+    _controller.highlightRanges.clear();
+    for (final match in matches) {
+      _controller.highlightRanges.add(
+        TextRange(start: match.start, end: match.end),
+      );
+    }
+  }
+
   @override
   void initState() {
     super.initState();
     _controller = HighlightingTextEditingController(
       text: 'matcha is green\nmatcha is green\nmatcha is green',
     );
+    _controller.addListener(_updateHighlightRanges);
   }
 
   @override
   void dispose() {
     super.dispose();
+    _controller.removeListener(_updateHighlightRanges);
     _controller.dispose();
   }

Key Takeaways

  • TextEditingController can be subclassed to customize how text is displayed and interacted with in a TextField
  • Overriding buildTextSpan gives you fine-grained control over text styling and highlighting
  • Always manage listeners and dispose of controllers in the widget lifecycle to prevent memory leaks