Flutter UTF8 TextField length limiter and char counter

Olivier Revial - Oct 23 '20 - - Dev Community

Flutter TextField is awesome, but...

I love Flutter and when it comes to TextFields, 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:



TextField(maxLength: 20)


Enter fullscreen mode Exit fullscreen mode

And you get a beautiful char-limited text field:

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鈥檈虂cran 2020-10-05 a虁 12.50.24

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 !

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 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, so now we will just mimic its behavior to get our very own length limiter that will limit on bytes count:



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;
  }
}


Enter fullscreen mode Exit fullscreen mode

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:



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


Enter fullscreen mode Exit fullscreen mode

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:



TextField(
  maxLength: 20,
  inputFormatters: [
     _Utf8LengthLimitingTextInputFormatter(20),
  ],
)


Enter fullscreen mode Exit fullscreen mode

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鈥檈虂cran 2020-10-05 a虁 15.26.47

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 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:



TextEditingController _utf8TextController = TextEditingController();


Enter fullscreen mode Exit fullscreen mode

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),            
 ],
)


Enter fullscreen mode Exit fullscreen mode

And voil脿 馃コ ! We now have a beautiful text field input limited to 20 bytes, not characters... whatever that means.

Capture d鈥檈虂cran 2020-10-05 a虁 15.41.54

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .