How to create a number input

There are situations, in which you want the user to be able to type only numbers into a form field because a non-numeric value does not make sense in the specific use case. Let’s see how to do it.

When it comes to input fields, Flutter knows nothing else but text (TextField or TextFormField). This makes sense because on a data type level, everything you can input is a text.

If you know that your text is of a specific format, you might want to prevent the user to enter something invalid so that you don’t have to validate every possible case of an invalid text.

Changing the keyboard

The first part of the solution has a very pragmatic underlying logic: if the user’s keyboard does not provide the possibility of entering an invalid character, it prevents him from entering one. This also has a usability aspect: if there is a default keyboard where 95 % of the provided keys are unused because the expected input is only numeric, then the user feels uncomfortable. Also, the really important keys (number keys) don’t take much space on the screen which makes them harder to be hit.

For this case, the TextField and the TextFormField both provide a property called keyboardType. This optional constructor argument expects a TextInputType. There are numerous constructors (mostly hidden behind a static const value that internally call the _ constuctor) for different keyboard types, such as:

  • text
  • multiline
  • number
  • phone
  • datetime
  • emailaddress
  • url
  • visiblePassword
  • name
  • streetAddress

If we want to use a number input without decimals, we can just go for number:

TextFormField(
  ...
  keyboardType: TextInputType.number,
);

However, if we want to allow numbers with decimals, we need to use the named constructor numberWithOptions instead. This allows us to specify whether decimals are possible and whether signed numbers are possible.

TextFormField(
  ...
  keyboardType: TextInputType.numberWithOptions(decimal: true),
);

The two options decimal and signed default to false. So if we use numberWithOptions without any argument, we get the same result as using TextInputType.number.

One important thing to note here, though: the number keyboard on iOS does not have a done button. If you need to have that, you will not get around using either the usual keyboard and a custom validation or one of the available third party plugins, as stated here.

Formatting the input

Specifying a certain keyboard might prevent the user from entering invalid input. However, the input could still come from other sources such as the clipboard: having copied invalid characters such as alphanumerical characters and pasting them into the field is not what this parameter stops the user from.

That’s what the inputFormatters property is for. InputFormatters are basically functions that are called whenever text is being typed, cut, copied or pasted e. g. in a TextField. The input is the text inside the field and the output is also text. That enables the InputFormatter to override the text based on what it’s inside.

This way we can replace parts of the text we don’t want to have with empty String ('').

There is a predefined TextInputFormatter for this exact purpose. It’s called FilteringTextInputFormatter. The caller can provide either a pattern that matches the allowed or the denied characters (via regular expression).

Because we know exactly what type of characters we want to allow (numbers), it’s much easier for us to define the allow filter instead of the deny filter:

inputFormatters: <TextInputFormatter>[
  FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
],

The regular expression matches every digit between 0 and 9. Using \d (for “digit”) would have the same effect.

If we want the Formatter to accept decimals as well, we use a custom TextInputFormatter. We can either define a separate class that extends the TextInputFormatter or we take the shortcut because we have a rather simple one and use the named constructor that lets us define the behavior in just one function:

keyboardType: TextInputType.numberWithOptions(decimal: allowDecimal),
inputFormatters: <TextInputFormatter>[
  FilteringTextInputFormatter.allow(RegExp(r'[0-9]+[,.]{0,1}[0-9]*')),
  TextInputFormatter.withFunction(
    (oldValue, newValue) => newValue.copyWith(
      text: newValue.text.replaceAll('.', ','),
    ),
  ),
],

TextInputFormatters can be chained. This means that within the list if input formatters the order matters. The output of the first formatter is taken as the input for the second and so on.

In our case, we want the first formatter to only allow a pattern with at least one digit [0-9]+ followed by either a dot, a comma or nothing [,.]{0,1} followed by at least zero digits [0-9]*.

Now we know that the output of this formatter is always of the mentioned format. Then we define a second formatter that replaces every occurrence of . by ,. This way, we have a normalized input, which we could easily make further use of. It doesn’t matter if the user enters a . or a , – it will always be transformed to a ..

Putting it all together

If we puzzle the pieces together to a reusable widget, the code could look something like this:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class NumberInput extends StatelessWidget {
  NumberInput({
    required this.label,
    this.controller,
    this.value,
    this.onChanged,
    this.error,
    this.icon,
    this.allowDecimal = false,
  });

  final TextEditingController? controller;
  final String? value;
  final String label;
  final Function? onChanged;
  final String? error;
  final Widget? icon;
  final bool allowDecimal;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      initialValue: value,
      onChanged: onChanged as void Function(String)?,
      readOnly: disabled,
      keyboardType: TextInputType.numberWithOptions(decimal: allowDecimal),
      inputFormatters: <TextInputFormatter>[
        FilteringTextInputFormatter.allow(RegExp(_getRegexString())),
        TextInputFormatter.withFunction(
          (oldValue, newValue) => newValue.copyWith(
            text: newValue.text.replaceAll('.', ','),
          ),
        ),
      ],
      decoration: InputDecoration(
        label: Text(label),
        errorText: error,
        icon: icon,
      ),
    );
  }

  String _getRegexString() =>
      allowDecimal ? r'[0-9]+[,.]{0,1}[0-9]*' : r'[0-9]';
}

Styling the decimals

We could even go a step further and style the decimals, making the separation more obvious for the observer. For this purpose we use the findings of this article about partially styling a TextField.

TextEditingController controller = StyleableTextFieldController(
  styles: TextPartStyleDefinitions(
    definitionList: <TextPartStyleDefinition>[
      TextPartStyleDefinition(
        style: const TextStyle(
          color: Colors.black38,
        ),
        pattern: r',(\d+)$',
      )
    ],
  ),
);

We want the part after the decimal to be grayed out a little bit so we apply a respective TextStyle to the part of the text that matches the regular expression: ,(\d+)$.

Conclusion

Flutter does not provide a predefined input for numbers. However, we can use the capabilities of a TextField (or TextFormField) to mimic the behavior we want a number input to have.

It’s as simple as changing the keyboard to one that only provides numbers and the allowed characters to digits only.

If we want to support decimals as well, we can give the named constructor of TextInputType a respective argument. We can user TextInputFormatters to disallow certain characters and normalize the decimal separator.

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

🥗Buy me a salad

Leave a Comment