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:
TextField(maxLength: 20)
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:
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;
}
}
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;
}
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),
],
)
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:
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();
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.
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.