Styling parts of a TextField

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:

 1TextSpan buildTextSpan({required BuildContext context, TextStyle? style , required bool withComposing}) {
 2  assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid);
 3  if (!value.isComposingRangeValid || !withComposing) {
 4    return TextSpan(style: style, text: text);
 5  }
 6  final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
 7      ?? const TextStyle(decoration: TextDecoration.underline);
 8  return TextSpan(
 9    style: style,
10    children: <TextSpan>[
11      TextSpan(text: value.composing.textBefore(value.text)),
12      TextSpan(
13        style: composingStyle,
14        text: value.composing.textInside(value.text),
15      ),
16      TextSpan(text: value.composing.textAfter(value.text)),
17    ],
18  );
19}

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:

1const TextRange range = TextRange(start: 6, end: 11);
2const String text = 'Hello world!';
3
4print(range.textBefore(text));
5print(range.textInside(text));
6print(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:

1I/flutter ( 8356): Hello 
2I/flutter ( 8356): world
3I/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:

 1return TextSpan(
 2    style: style,
 3    children: <TextSpan>[
 4      TextSpan(text: value.composing.textBefore(value.text)),
 5      TextSpan(
 6        style: composingStyle,
 7        text: value.composing.textInside(value.text),
 8      ),
 9      TextSpan(text: value.composing.textAfter(value.text)),
10    ],
11  );

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:

 1return TextSpan(
 2  style: style,
 3  children: <TextSpan>[
 4    TextSpan(text: value.composing.textBefore(value.text)),
 5    TextSpan(
 6      style: const TextStyle(
 7        color: Colors.red,
 8        decoration: TextDecoration.underline,
 9      ),
10      text: value.composing.textInside(value.text),
11    ),
12    TextSpan(text: value.composing.textAfter(value.text)),
13  ],
14);

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:

1class TextPartStyleDefinition {
2  TextPartStyleDefinition({
3    required this.pattern,
4    required this.style,
5  });
6
7  final String pattern;
8  final TextStyle style;
9}

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:

1class TextPartStyleDefinitions {
2  TextPartStyleDefinitions({required this.definitionList});
3
4  final List<TextPartStyleDefinition> definitionList;
5}

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:

1class StyleableTextFieldController extends TextEditingController {
2  StyleableTextFieldController({
3    required this.styles,
4  }) : combinedPattern = styles.createCombinedPatternBasedOnStyleMap();
5
6  final TextPartStyleDefinitions styles;
7  final Pattern combinedPattern;
8}

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 does 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.

 1class TextPartStyleDefinitions {
 2  TextPartStyleDefinitions({required this.definitionList});
 3
 4  final List<TextPartStyleDefinition> definitionList;
 5
 6  RegExp createCombinedPatternBasedOnStyleMap() {
 7    final String combinedPatternString = definitionList
 8        .map<String>(
 9          (TextPartStyleDefinition textPartStyleDefinition) =>
10              textPartStyleDefinition.pattern,
11        )
12        .join('|');
13
14    return RegExp(
15      combinedPatternString,
16      multiLine: true,
17      caseSensitive: false,
18    );
19  }
20}

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:

 1@override
 2TextSpan buildTextSpan({
 3  required BuildContext context,
 4  TextStyle? style,
 5  required bool withComposing,
 6}) {
 7  final List<InlineSpan> textSpanChildren = <InlineSpan>[];
 8
 9  text.splitMapJoin(
10    combinedPattern,
11    onMatch: (Match match) {
12      final String? textPart = match.group(0);
13
14      if (textPart == null) return '';
15
16      final TextPartStyleDefinition? styleDefinition =
17          styles.getStyleOfTextPart(
18        textPart,
19        text,
20      );
21
22      if (styleDefinition == null) return '';
23
24      _addTextSpan(
25        textSpanChildren,
26        textPart,
27        style?.merge(styleDefinition.style),
28      );
29
30      return '';
31    },
32    onNonMatch: (String text) {
33      _addTextSpan(textSpanChildren, text, style);
34
35      return '';
36    },
37  );
38
39  return TextSpan(style: style, children: textSpanChildren);
40}
41
42void _addTextSpan(
43  List<InlineSpan> textSpanChildren,
44  String? textToBeStyled,
45  TextStyle? style,
46) {
47  textSpanChildren.add(
48    TextSpan(
49      text: textToBeStyled,
50      style: style,
51    ),
52  );
53}

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:

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

Output:

1every 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?

 1onMatch: (Match match) {
 2      final String? textPart = match.group(0);
 3
 4      if (textPart == null) return '';
 5
 6      final TextPartStyleDefinition? styleDefinition =
 7          styles.getStyleOfTextPart(
 8        textPart,
 9        text,
10      );
11
12      if (styleDefinition == null) return '';
13
14      _addTextSpan(
15        textSpanChildren,
16        textPart,
17        style?.merge(styleDefinition.style),
18      );
19
20      return '';
21    },

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:

 1TextPartStyleDefinition? getStyleOfTextPart(
 2  String textPart,
 3  String text,
 4) {
 5  return List<TextPartStyleDefinition?>.from(definitionList).firstWhere(
 6    (TextPartStyleDefinition? styleDefinition) {
 7      if (styleDefinition == null) return false;
 8
 9      bool hasMatch = false;
10
11      RegExp(styleDefinition.pattern, caseSensitive: false)
12          .allMatches(text)
13          .forEach(
14        (RegExpMatch currentMatch) {
15          if (hasMatch) return;
16
17          if (currentMatch.group(0) == textPart) {
18            hasMatch = true;
19          }
20        },
21      );
22
23      return hasMatch;
24    },
25    orElse: () => null,
26  );
27}

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.

 1class MyHomePage extends StatelessWidget {
 2  @override
 3  Widget build(BuildContext context) {
 4    final TextEditingController textEditingController =
 5        StyleableTextFieldController(
 6      styles: TextPartStyleDefinitions(
 7        definitionList: <TextPartStyleDefinition>[
 8          TextPartStyleDefinition(
 9            style: const TextStyle(
10              color: Colors.green,
11              fontWeight: FontWeight.bold,
12            ),
13            pattern: '[\.,\?\!]',
14          ),
15          TextPartStyleDefinition(
16            style: const TextStyle(
17              color: Colors.red,
18              fontWeight: FontWeight.bold,
19            ),
20            pattern: '(?:(the|a|an) +)',
21          ),
22        ],
23      ),
24    );
25
26    return Scaffold(
27      body: Center(
28        child: TextField(
29          controller: textEditingController,
30          autocorrect: false,
31          enableSuggestions: false,
32          textCapitalization: TextCapitalization.none,
33        ),
34      ),
35    );
36  }
37}

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.

GET FULL CODE

Comments (5) ✍️

Nikhil

Wonderful explanation. I learnt a lot from this. I am using this your article as guide to implement a bulleted text field in flutter from scratch as for learning practice. I am able to add the bullet at the begning of sentence but I am not able to fix the indentation of text in new line.

Expected :

  • This is a short message
  • This is a long message long message continued

What I am getting:

  • This is a short message
  • This is a long message long message continued

I want the “long” in the third line to be aligned to “This” in second line. Can you advise how you will attempt this?

Reply to Nikhil

Marc
In reply to Nikhil's comment

Hey Nikhil,

Thank you for your interest and appreciation. I don’t think, I fully understand your example. Also, due to markdown being used here, the spaces have been condensed, I guess. If you could share an actual code snippet, it would be helpful. Thank you! :)

Reply to Marc

Juan

Really useful Marc, I’ve added it to my project, I’m sure ppl could find it handy if you make it a package ^^ Will checkout now the rest of your content 👀, thank you!
Reply to Juan

Matt Gercz

I have been working on something like this for a week, and I must have turned links to half of stackoverflow and the flutter docs purple just trying to get an understanding how to do this inline styling inside a TextField. This is literally the first (and maybe only) source I have found that managed to synthesize all of the pieces together instead of explaining one small part. Thank you for putting this together, you’ve singlehandedly knocked 20 points off my blood pressure today.
Reply to Matt Gercz

Marc
In reply to Matt Gercz's comment

This kind of feedback makes me continue what I’m doing! Thanks a lot and I’m very glad I could help 🙂.
Reply to Marc

Comment this 🤌

You are replying to 's commentRemove reference