Flutter UTF8 TextField length limiter and char counter
Tags: flutter, dart
Published: 2020-10-23 13:34:23

## Flutter `TextField` is awesome, but...

I love Flutter and when it comes to `TextField`s, it's pretty easy to enforce a maximum length and have a counter display that indicates the current number of characters and the maximum length. All you have to do is:

```dart
TextField(maxLength: 20)
```

And you get a beautiful char-limited text field:

![](https://dev-to-uploads.s3.amazonaws.com/i/1vq5xo0mufzmwlzs7seh.png)

Pretty easy, right ?

Not so fast !

What happens if you are using special characters such as emojis, accent chars or graphenes ? Let's see:

![Capture d’écran 2020-10-05 à 12.50.24](https://dev-to-uploads.s3.amazonaws.com/i/hbib25aozrzetms3p1qs.png)

Hmm 🤔, your text is 15 characters long, what's the problem ?

Well... sometimes you don't want to limit text length to visible characters but you want to limit length to the actual number of bytes of your text. For example, if you are sending text to a Bluetooth device or a fixed-length field in a database, you need to make sure that you won't send too many UTF-8 encoded characters. And here is the problem my friends: while a letter is coded on one byte, [special characters and emojis can be coded on 2 to 8 bytes](https://pusher.com/tutorials/styled-text-flutter#unicode) !

In this case, above example "I ❤️ Flutter 😻😍🤩" would actually take 29 bytes when UTF-8 encoded... a bit more than the 15 displayed by Flutter counter. 

And by the way, Flutter [official `TextField` documentation](https://api.flutter.dev/flutter/material/TextField/maxLength.html) is pretty clear on this exact issue:

> Limitations
The text field does not currently count Unicode grapheme clusters (i.e. characters visible to the user), it counts Unicode scalar values, which leaves out a number of useful possible characters (like many emoji and composed characters), so this will be inaccurate in the presence of those characters. If you expect to encounter these kinds of characters, be generous in the maxLength used.

## ...we need an UTF-8 length limiter...

When reading documentation above it becomes clear that we need our own implementation of `TextField` length limiter. Fortunately for us, this is pretty easy to do as `TextField` has a param called `inputFormatters` that takes a list of input formatters:

> Optional input validation and formatting overrides.
>   
> Formatters are run in the provided order when the text input changes.

By the way Flutter default text length limiter works by using a specific input formatter called [LengthLimitingTextInputFormatter](https://api.flutter.dev/flutter/services/LengthLimitingTextInputFormatter-class.html), so now we will just mimic its behavior to get our very own length limiter that will limit on bytes count:

```dart
class _Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
  _Utf8LengthLimitingTextInputFormatter(this.maxLength)
      : assert(maxLength == null || maxLength == -1 || maxLength > 0);

  final int maxLength;

  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue,
      TextEditingValue newValue,
      ) {
    if (maxLength != null &&
        maxLength > 0 &&
        bytesLength(newValue.text) > maxLength) {
      // If already at the maximum and tried to enter even more, keep the old value.
      if (bytesLength(oldValue.text) == maxLength) {
        return oldValue;
      }
      return truncate(newValue, maxLength);
    }
    return newValue;
  }
  
  static TextEditingValue truncate(TextEditingValue value, int maxLength) {
    var newValue = '';
    if (bytesLength(value.text) > maxLength) {
      var length = 0;

      value.text.characters.takeWhile((char) {
        var nbBytes = bytesLength(char);
        if (length + nbBytes <= maxLength) {
          newValue += char;
          length += nbBytes;
          return true;
        }
        return false;
      });
    }
    return TextEditingValue(
      text: newValue,
      selection: value.selection.copyWith(
        baseOffset: min(value.selection.start, newValue.length),
        extentOffset: min(value.selection.end, newValue.length),
      ),
      composing: TextRange.empty,
    );
  }

  static int bytesLength(String value) {
    return utf8.encode(value).length;
  }
}
```

This class is pretty simple : it checks whether we are exceeding maximum length (see `formatEditUpdate` method). If we already are at maximum bytes length and try to type in another char, this char will simply be rejected. In case we suddenly exceed the char limit with more than 1 char (e.g. if multiple chars are being pasted to the text field), we will call `truncate` method to get our string back to `maxLength` bytes. 
Also note the important method here: 

```dart
static int bytesLength(String value) {
  return utf8.encode(value).length;
}
```

This method simply returns the length of a String in bytes rather than in Unicode characters as done in Flutter default's length limiter.

Great, let's use this new input formatter in our `TextField`:

```dart
TextField(
  maxLength: 20,
  inputFormatters: [
     _Utf8LengthLimitingTextInputFormatter(20),
  ],
)
```
*NOTE*: In this example length limiter class must be in the same file as your widget because I declared the formatter class private to the current file.


All good ? 🤔 Hum, actually if we test now our text field input will be effectively limited to 20 (or N) **bytes** and we won't be able to enter a new "character" (letter, emoji, etc) if this new character would make the total bytes length exceed our limit. So even if you are currently at 18/20, you won't be able to type in an awesome 4-bytes emoji 🥳. However, the counter is still `TextField`'s default counter so in my previous example I will be blocked but the counter will be wrong, as shown in the capture below:

![Capture d’écran 2020-10-05 à 15.26.47](https://dev-to-uploads.s3.amazonaws.com/i/3jo4ln7jy3fe4qc3yc4u.png)
 
*Counter should actually be at 15 because I entered 15 bytes worth of data with the heart emoji coded as 4 bytes*.

Let's customize Flutter `TextField` char counter !

## ...and a custom chars counter

To display the correct number of bytes typed in the text input, we need to override default char counter and make sure it's displayed every time a character is typed, as the standard char counter would do.

To do this we can use [`buildCounter`](https://api.flutter.dev/flutter/material/TextField/buildCounter.html) of `TextField` class and return our own widget. By default the builder callback takes a parameter named `currentLength` which is the wrong length, so we will ignore this parameter and calculate string bytes length using UTF-8 encoding instead. To do this, we need a reference to the current value of our `TextField`, so let's create a `TextEditingController` in our widget state:

```dart
TextEditingController _utf8TextController = TextEditingController();
```

Finally, let's set the `buildCounter` callback that uses our text controller to check the UTF-8 bytes length every time the user enters/pastes some text:

```
TextField(
  controller: _utf8TextController,
  maxLength: 20,
  buildCounter: (
    context, { currentLength, isFocused, maxLength }) {
      int utf8Length = utf8.encode(_utf8TextController.text).length;
      return Container(
        child: Text(
          '$utf8Length/$maxLength',
          style: Theme.of(context).textTheme.caption,
          ),
        );
    },
 inputFormatters: [              
   _Utf8LengthLimitingTextInputFormatter(maxLength),            
 ],
)
```

And voilà 🥳 ! We now have a beautiful text field input limited to 20 **bytes**, not characters... whatever that means.

![Capture d’écran 2020-10-05 à 15.41.54](https://dev-to-uploads.s3.amazonaws.com/i/jjlug4y9v5kswy6ecf26.png)
 
*Notes*:
 * I added a `caption` style to the counter to mimic counter original look.
 * This technique would obviously work with `CupertinoTextField` as well as it presents exactly the same parameters.

---
Full code samples are available in this [Github demo repo](https://github.com/orevial/flutter-text-field-utf8-length-limiter-demo).