Styling parts of a TextField

Styling the whole TextField can be done via the InputDecoration. But what if you have special styling in mind for only parts of the text? Let’s have a look at how to do it.

For this case, let’s consider an example, in which every punctuation mark is highlighted green and bold. Also, we want every article to be marked red and bold. That’s because we have some kind of language learning app that teaches grammar.

Flutter partially styled TextField
The aim: a partially styled TextField

Extending a TextEditingController

It might be surprising but in order to style parts of a TextField, we will not write a new widget. Instead, we will extend the TextEditingController class. The reason is that the TextEditingController, which can be tied to a TextField using the controller property, provides a method called buildTextSpan, which we will override.

Before we go into the implementation details of what we want to create, let’s first examine what this method does and how it works as it will give us a better foundation we can build upon.

To achieve this, we can have a look at the implementation of this method in the base class:

TextSpan buildTextSpan({required BuildContext context, TextStyle? style , required bool withComposing}) {
  assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid);
  if (!value.isComposingRangeValid || !withComposing) {
    return TextSpan(style: style, text: text);
  }
  final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
      ?? const TextStyle(decoration: TextDecoration.underline);
  return TextSpan(
    style: style,
    children: <TextSpan>[
      TextSpan(text: value.composing.textBefore(value.text)),
      TextSpan(
        style: composingStyle,
        text: value.composing.textInside(value.text),
      ),
      TextSpan(text: value.composing.textAfter(value.text)),
    ],
  );
}

Brief excursion: TextRange

You might ask yourself why there is a need to check about a composing range and what this even is.

In fact, TextRange is nothing else but a representation of a range of characters inside a String. This range can also be empty or only 1 character long.

The class provides some methods to access the characters. Let’s look at an example to make it more clear:

const TextRange range = TextRange(start: 6, end: 11);
const String text = 'Hello world!';

print(range.textBefore(text));
print(range.textInside(text));
print(range.textAfter(text));

We create a new TextRange object with start set to 6 and end set to 11. Afterwards, we print the return value of textBefore(), textInside() and textAfter()

This would give the following output:

I/flutter ( 8356): Hello 
I/flutter ( 8356): world
I/flutter ( 8356): !

I think it’s pretty clear how it works. start and end are the indexes of the range. textBefore() returns all the characters with an index lower than start for the given String. textInside() returns all characters within the index range (end is inclusive). textAfter() returns the substring of every character with a greater index than end.

But why is there a TextRange in the context of a TextField?

At least on Android, the word you are currently typing (or that is hit by the cursor), is marked as underlined. So you know that e. g. the suggestions from the auto correct are referring to the respective word. Everything before and after is not formatted in a special way.

So if we want to access the currently selected word, we can use the composing property of TextEditingValue that represents the above mentioned text that is currently being composed as a TextRange.

Back to the TextEditingController

But the essential part of the buildTextSpan method has not been covered yet:

  return TextSpan(
    style: style,
    children: <TextSpan>[
      TextSpan(text: value.composing.textBefore(value.text)),
      TextSpan(
        style: composingStyle,
        text: value.composing.textInside(value.text),
      ),
      TextSpan(text: value.composing.textAfter(value.text)),
    ],
  );

It returns a TextSpan, which is a class you might know from RichText, allowing you to separately address the format of parts of a text.

We have the same mechanic here.

If we changed the above method in a way that the value.composing.textInside() gets a red color added as a styling:

return TextSpan(
  style: style,
  children: <TextSpan>[
    TextSpan(text: value.composing.textBefore(value.text)),
    TextSpan(
      style: const TextStyle(
        color: Colors.red,
        decoration: TextDecoration.underline,
      ),
      text: value.composing.textInside(value.text),
    ),
    TextSpan(text: value.composing.textAfter(value.text)),
  ],
);

The mentioned behavior would result in this:

Flutter TextRange composing
Using the TextRange methods to style selected text

Overriding the buildTextSpan() method

Okay, let’s conclude what we know so far: we can extend the TextEditingController and we can override the buildTextSpan() method in order to influence the format / style of the displayed text inside the connected TextField.

Now, instead of defining a static behavior like above, we want to keep it dynamic so that the caller of our TextField constructor can decide, which part of the the text should be styled in which way.

What is a common way to select text? You guessed it: regular expressions. So basically we want the constructor to expect a pattern and a corresponding styling. Actually not only one, but rather a list because we want to be able to define multiple styles.

Visually speaking, the input and output chain can be represented like this:

Flutter partially styled text field input output chain
Regular expressions and TextStyles go in and a styled TextField goes out

At first, we should start with a model of what we just described:

class TextPartStyleDefinition {
  TextPartStyleDefinition({
    required this.pattern,
    required this.style,
  });

  final String pattern;
  final TextStyle style;
}

Like it was being said, our model has two properties: pattern representing the String of the regular expression and style which is the TextStyle that is applied to what the regular expression matches.

Now we want to be able to style multiple parts of the text. That’s why we need a model that wraps a list of TextPartStyleDefinition:

class TextPartStyleDefinitions {
  TextPartStyleDefinitions({required this.definitionList});

  final List<TextPartStyleDefinition> definitionList;
}

Yet, this is nothing else but a thin wrapper with not additional value. But we will add methods to it once we need them.

Let’s continue with creating our custom controller:

class StyleableTextFieldController extends TextEditingController {
  StyleableTextFieldController({
    required this.styles,
  }) : combinedPattern = styles.createCombinedPatternBasedOnStyleMap();

  final TextPartStyleDefinitions styles;
  final Pattern combinedPattern;
}

We have two member variables in this class: styles and combinedPattern. styles is of the type we have just created (TextPartStyleDefinitions) and is supposed to hold the styling information. So what dies combinedPattern do?

Essential, we want to transform the list of style information into one combined regular expression. That’s because after that, we are going to use a function that splits a string according to one pattern.

So basically we just want the createCombinedPatternBasedOnStyleMap() to make use of | in regular expressions to glue together all regular expressions we define in our TextPartStyleDefinitions object.

class TextPartStyleDefinitions {
  TextPartStyleDefinitions({required this.definitionList});

  final List<TextPartStyleDefinition> definitionList;

  RegExp createCombinedPatternBasedOnStyleMap() {
    final String combinedPatternString = definitionList
        .map<String>(
          (TextPartStyleDefinition textPartStyleDefinition) =>
              textPartStyleDefinition.pattern,
        )
        .join('|');

    return RegExp(
      combinedPatternString,
      multiLine: true,
      caseSensitive: false,
    );
  }
}

What the method does internally, is mapping the list of TextPartStyleDefinition to a list of String (containing their pattern) and then gluing them together via join() using | as the separator.

This combined String is used as the input for a RegExp with certain parameters.

Now, back to the buildTextSpan() method inside our StyleableTextFieldController:

@override
TextSpan buildTextSpan({
  required BuildContext context,
  TextStyle? style,
  required bool withComposing,
}) {
  final List<InlineSpan> textSpanChildren = <InlineSpan>[];

  text.splitMapJoin(
    combinedPattern,
    onMatch: (Match match) {
      final String? textPart = match.group(0);

      if (textPart == null) return '';

      final TextPartStyleDefinition? styleDefinition =
          styles.getStyleOfTextPart(
        textPart,
        text,
      );

      if (styleDefinition == null) return '';

      _addTextSpan(
        textSpanChildren,
        textPart,
        style?.merge(styleDefinition.style),
      );

      return '';
    },
    onNonMatch: (String text) {
      _addTextSpan(textSpanChildren, text, style);

      return '';
    },
  );

  return TextSpan(style: style, children: textSpanChildren);
}

void _addTextSpan(
  List<InlineSpan> textSpanChildren,
  String? textToBeStyled,
  TextStyle? style,
) {
  textSpanChildren.add(
    TextSpan(
      text: textToBeStyled,
      style: style,
    ),
  );
}

There is a lot going on here so let’s examine it step by step.

First, we need to understand, how splitMapJoin works. We do this by using an example:

  String sampleText = 'Every word in this text is uppercase. All other words are lowercase.';
  String replacedText = sampleText.splitMapJoin(
    (RegExp('word')),
    onMatch:    (Match m) => m.group(0)?.toUpperCase() ?? '',
    onNonMatch: (String n) => n.toLowerCase(),
  );
  
  print(replacedText);

Output:

every WORD in this text is uppercase. all other WORDs are lowercase.

The function splitMapJoin() works this way: given a RegExp, it splits the whole String into matches and non-matches. Matches are those parts of the String that are matched by the RegExp. Non-matches is everything in between.

Inside the onMatch and onNonMatch callback functions, one can define the code that is executed for every part of the text. It requires a return value of String because the function combines all parts to a new string afterwards.

So in our case, what was written inside the onMatch function?

    onMatch: (Match match) {
      final String? textPart = match.group(0);

      if (textPart == null) return '';

      final TextPartStyleDefinition? styleDefinition =
          styles.getStyleOfTextPart(
        textPart,
        text,
      );

      if (styleDefinition == null) return '';

      _addTextSpan(
        textSpanChildren,
        textPart,
        style?.merge(styleDefinition.style),
      );

      return '';
    },

Like I said, every part of the text that matches a given RegExp, needs to be styled the way it was defined. So when onMatch is called, we need to find out, which of the patterns match. When there was no matching TextPartStyleDefinition, we return. Otherwise, we apply this style to a TextSpan and add it to the list of TextSpan we initialized earlier.

The onMatch function does not apply anything and just adds a TextSpan with the style from the method arguments.

Now let’s examine the getStyleOfTextPart() method:

TextPartStyleDefinition? getStyleOfTextPart(
  String textPart,
  String text,
) {
  return List<TextPartStyleDefinition?>.from(definitionList).firstWhere(
    (TextPartStyleDefinition? styleDefinition) {
      if (styleDefinition == null) return false;

      bool hasMatch = false;

      RegExp(styleDefinition.pattern, caseSensitive: false)
          .allMatches(text)
          .forEach(
        (RegExpMatch currentMatch) {
          if (hasMatch) return;

          if (currentMatch.group(0) == textPart) {
            hasMatch = true;
          }
        },
      );

      return hasMatch;
    },
    orElse: () => null,
  );
}

The aim of the getStyleOfTextPart() method is to receive a text part and then return the respective style information. In order to do that, it finds the first TextPartStyleDefinition, for which the whole match (currentMatch.group(0)) equals to whatever textPart is provided to this method.

If no TextPartStyleDefinition matches the given text, it returns null.

Reaping the rewards

Now that we have built a dynamic way of styling parts of TextField, let’s make good use of it and fulfill the initially set requirements: highlighting punctuation marks green and bold and articles red and bold.

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final TextEditingController textEditingController =
        StyleableTextFieldController(
      styles: TextPartStyleDefinitions(
        definitionList: <TextPartStyleDefinition>[
          TextPartStyleDefinition(
            style: const TextStyle(
              color: Colors.green,
              fontWeight: FontWeight.bold,
            ),
            pattern: '[\.,\?\!]',
          ),
          TextPartStyleDefinition(
            style: const TextStyle(
              color: Colors.red,
              fontWeight: FontWeight.bold,
            ),
            pattern: '(?:(the|a|an) +)',
          ),
        ],
      ),
    );

    return Scaffold(
      body: Center(
        child: TextField(
          controller: textEditingController,
          autocorrect: false,
          enableSuggestions: false,
          textCapitalization: TextCapitalization.none,
        ),
      ),
    );
  }
}

Providing a TextPartStyleDefinitions that encapsulates a list of our defined TextPartStyleDefinition, we are able to quickly set up style definitions for every part of our TextField. For the punctuation mark, we use a pattern that explicitly matches certain characters: [.,\?!]. Regarding the articles, we use non-capturing-groups.

Wrap up

In order to give the caller the possibility to partially style a TextField, we extended the TextFieldController and let it accept a list of style definitions which consist of a selector (a RegExp) and style (TextStyle). This creates a very flexible and powerful styling mechanism.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment