Building a custom numeric keypad in Flutter

Building a custom numeric keypad in Flutter

A guide to building a custom numeric keypad in Flutter

Before we jump right in, Download the code materials required to follow along here, or visit the repo on GitHub.

You are probably used to seeing mobile applications with custom numeric keypad that mimics the Os numeric keypad, but ever wondered why?. You've probably seen it when entering your PIN or credit card details. But today, we're not going to get lost in the details of why this is the case. Instead, let's jump straight into coding our very own custom numeric keypad in Flutter.

This article is part one of a two-part series. In this article, we'll focus on building the custom numeric keypad. In the second part, we'll dive into the topic of text editing with the custom keypad. I've split the topic into two separate articles to keep things organized and avoid overwhelming readers with too much information at once.

If you choose to go through the starter project, you'll find that there's nothing particularly fancy about it. The lib folder contains two files: main.dart and numeric_keypad.dart. In main.dart, you'll find the standard main() method that runs the MyApp widget. Below that, you'll see the CustomNumPadPage widget - this is where the magic happens - well not actual magic, but you get what I mean.

The CustomNumPadPage is implemented as a StatefulWidget. If you take a look at the State class, you'll find the following code:

late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  // TODO: Implement method to input number to TextField

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1D1E33),
      appBar: AppBar(title: Text(widget.title)),
      body: Column(
        children: <Widget>[
          Expanded(
            flex: 2,
            child: Center(
                // TODO: Add a TextField
                ),
          ),
          Expanded(
            child: NumericKeyPad(
              onInputNumber: (value){},
              onClearLastInput: (){},
              onClearAll: (){},
            ),
          ),
        ],
      ),
    );
  }

Don't worry about your dart analyzer screaming at you to use const literals..., for now, you can ignore it. We'll address this warning later on, so feel free to focus on the main code for now.

There are two things we need to accomplish, build the numeric keypad and then make it interact with the input field; Let's begin by building the numeric keypad.

Open the numeric_keypad.dart file or ctrl + click on the NumericKeyPad() widget instance in your main.dart file which will take you to its implementation; and for our proud mac users, cmd + click should do the trick.
Let me quickly explain the layout. Just like the actual numeric keys on our touch devices, our key will be horizontally laid out, Numbers 1-3 on the first row, 4-6 on the second row and just like that. Run the app - if you haven't yet - you will notice it's all blank, nothing fancy - yet. Find // TODO: Build NumericKeyPad and replace with:

//1
Expanded(
  child: Row(
    //2
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      Expanded(
        child: OutlinedButton(
          style: OutlinedButton.styleFrom(
            backgroundColor: const Color(0xFFF1F4FE),
            shape: const CircleBorder(),
           ),
           onPressed: () {},
           child: Text('1'),
         ),
        ),
        Expanded(
          child: OutlinedButton(
            style: OutlinedButton.styleFrom(
              backgroundColor: const Color(0xFFF1F4FE),
              shape: const CircleBorder(),
            ),
            onPressed: () {},
            child: Text('2'),
          ),
         ),
         Expanded(
           child: OutlinedButton(
             style: OutlinedButton.styleFrom(
               backgroundColor: const Color(0xFFF1F4FE),
               shape: const CircleBorder(),
             ),
             onPressed: () {},
             child: Text('3'),
           ),
          ),
         ],
        ),
       ),

Hot restart and you will see a really ugly layout. Don't scream just yet, give me a minute to redeem myself. So at the moment, we've built just the first row containing Numbers 1-3. But before we decide to go on, you would notice there is an issue with our code - Code duplication. As a developer you have been given great power to create life - well not actual life but mobile apps - and with great power comes great responsibility (I'm not referring to a movie where a boy got bit by a spider which gave him superhuman abilities and then a series of events took place that led to his uncle telling him this same thing before he passed away). It is your responsibility always to write clean code. One sound piece of advice given to programmers is don't repeat yourself, abbreviated as DRY. Keeping your code DRY helps you better maintain your code. Instead of repeating blocks of code, in this case, we can package them into a function or widget. There are some discussions on whether to break down long widget trees into methods on smaller widgets class - we are not going into that discussion, at least not here, not now. But here is an episode of Decoding Flutter on Youtube about Widgets vs helper methods.

We'll extract the actual widget that builds the number buttons into a separate widget class. Copy the code below and paste it underneath the NumericKeyPad class.

//1
class Numeral extends StatelessWidget {
  const Numeral({
    super.key,
    required this.number,
    required this.onKeyPress,
  });
  //2
  final int number;
  //3
  final VoidCallback onKeyPress;

  @override
  Widget build(BuildContext context) {
    return Container(
      //4
      margin: const EdgeInsets.all(10),
      child: OutlinedButton(
        style: OutlinedButton.styleFrom(
          backgroundColor: const Color(0xFFF1F4FE),
          shape: const CircleBorder(),
        ),
        onPressed: onKeyPress,
        child: Text('$number'),
      ),
    );
  }
}

Let me provide a brief explanation
1: Numeral is a widget that represents each number that will appear in the NumericKeyPad widget.
2: The actual numbers that will be displayed on the buttons, from 1-9 and 0.
3: A function that will be triggered when a number is pressed, and typically it will input the number into the input field - except you hashed some mischievous plan.
4: Creates space around a Numeral.

Let's now update the code by replacing the previous block of code that we pasted earlier, the one that replaced // TODO: Build NumericKeyPad with the following block of code:

//1
Expanded(
  child: Row(
    //2
    crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Expanded(
          child: Numeral(number: 1, onKeyPress: () {}),
        ),
        Expanded(
          child: Numeral(number: 2, onKeyPress: () {}),
        ),
        Expanded(
          child: Numeral(number: 3, onKeyPress: () {}),
        ),
       ],
      ),
     ),

1: We've wrapped the entire row that displays the first three numbers in an Expanded widget, which causes it to take up as much available space as possible.
2: We've used CrossAxisAlignment.stretch to ensure that the children of the row are stretched to fill the available space in the cross-axis.

Our code is now a little bit clearer. Now that we have replaced the code that built the first row of numbers, let's add similar horizontal layouts for the remaining numbers. To do this, we can add two more Expanded widgets, each containing another Row widget. The first Row will hold numbers 4-6, and the second row will hold numbers 7-9. To achieve this, we can simply add the following code after the first child of the Column in the NumericKeyPad widget:

Expanded(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    Expanded(
      child: Numeral(number: 4, onKeyPress: () {}),
    ),
    Expanded(
      child: Numeral(number: 5, onKeyPress: () {}),
    ),
    Expanded(
      child: Numeral(number: 6, onKeyPress: () {}),
    ),
  ],
 ),
),
Expanded(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Expanded(
          child: Numeral(number: 7, onKeyPress: () {}),
        ),
        Expanded(
          child: Numeral(number: 8, onKeyPress: () {}),
        ),
        Expanded(
          child: Numeral(number: 9, onKeyPress: () {}),
        ),
      ],
    ),
  ),

Hot restart your app and you'll notice that our UI is now closer to an actual numeric keypad, but the issue of code duplication resurfaces.

The Dart language provides collection if and collection for which can be used to build collections; collection if simply allows you to build a collection using conditionals and collection for*, allows you to build a collection using a loop for repetition. You can have a brief tour of them* here or read more about them extensively here.

To address the code duplication, we can use for-loops since we are dealing with a list. So, replace the entire children within the list with:

//1
for (int i = 1; i < 4; i++)
  Expanded(
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        //2
        for (int j = 1; j < 4; j++)
          Expanded(
            child: Numeral(
              //3
              number: (i - 1) * 3 + j,
              onKeyPress: () => onInputNumber((i - 1) * 3 + j),
            ),
          ),
        ],
       ),
      ),

Code duplication solved. Hot reload and you would see nothing has changed visually - well except our code.

This code consists of two nested loops. The outer (first) loop runs three times, and for each iteration, it creates an Expanded widget containing a Row widget. The inner (second) loop runs three times for each iteration of the outer loop, creating a Numeral widget with a dynamically assigned number for each button on the keypad.

Again, for every time the first loop runs, the second loop will execute three times, based on the predefined condition. Here's how it works:
1: The first loop iterates three times, creating an Expanded widget that contains a Row widget for each iteration. - Expanded(child: Row(),)
2: The second loop runs three times for each iteration of the first loop, and builds the children for each Row widget.
3: A number is dynamically assigned for every Numeral. Let me quickly explain how the number is assigned:

When the first loop runs the first time, i = 1 and for each time the first loop runs, the second loop runs three times, so we do a little math to assign a number to the button.

The first time the first loop runs:
i = 1 and j = 1 => (i - 1) * 3 + j == (1 - 1) * 3 + 1 == 1
i = 1 and j = 2 => (i - 1) * 3 + j == (1 - 1) * 3 + 2 == 2
i = 1 and j = 3 => (i - 1) * 3 + j == (1 - 1) * 3 + 3 == 3

The second time the first loop runs:
i = 2 and j = 1 => (i - 1) * 3 + j == (2 - 1) * 3 + 1 == 4
i = 2 and j = 2 => (i - 1) * 3 + j == (2 - 1) * 3 + 2 == 5
i = 2 and j = 3 => (i - 1) * 3 + j == (2 - 1) * 3 + 3 == 6

The third and final time the first loop runs:
i = 3 and j = 1 => (i - 1) * 3 + j == (3 - 1) * 3 + 1 == 7
i = 3 and j = 2 => (i - 1) * 3 + j == (3 - 1) * 3 + 2 == 8
i = 3 and j = 3 => (i - 1) * 3 + j == (3 - 1) * 3 + 3 == 9

I hope you can make sense of what's happening.

Something is still missing, the Number 0. Under the last block of code you copied, add:

 Expanded(
   child: Row(
     crossAxisAlignment: CrossAxisAlignment.stretch,
       children: [
         //1
         const Spacer(),
         //2
         Expanded(
           child: Numeral(
             number: 0,
             onKeyPress: () => onInputNumber(0),
         )),
         Expanded(
           //3
           child: ClearButton(
             onClearLastInput: onClearLastInput,
             onClearAll: onClearAll,
           ),
         ),
       ],
     ),
   ),

1: Represents a flexible space. Shorthand for Expanded(child: const SizedBox.shrink()).
2: The button for the Number 0
3: The clear button that removes values from the input field. ClearButton is yet to be implemented and so you should have an error in your code editor with an error message like The method 'ClearButton' isn't defined.... Let's define it*. At the very end of the* numeric_keypad.dart file, paste the following code:

class ClearButton extends StatelessWidget {
  const ClearButton({
    super.key,
    required this.onClearLastInput,
    required this.onClearAll,
  });

  final VoidCallback onClearLastInput;
  final VoidCallback onClearAll;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onLongPress: onClearAll,
      child: IconButton(
        onPressed: onClearLastInput,
        icon: const Icon(
          Icons.backspace,
          color: Color(0xFFF1F4FE),
        ),
      ),
    );
  }
}

Hot reload and you should see Numbers 0 - 9 and the clear key. Our NumeriKeyPad is done and ready.

Let's jump into main business - no pun intended. In the main.dart file, find and replace // TODO: Add a TextField with:

child: Padding(
  padding: const EdgeInsets.symmetric(horizontal: 12),
  child: TextField(
    //1
    controller: _controller,
    //2
    autofocus: true,
    showCursor: true,
    //3
    keyboardType: TextInputType.none,
    decoration: const InputDecoration(
      filled: true,
      fillColor: Colors.white,
    ),
  ),
),

1: We attach the previously initialized TextEditingController to the textfield. The controller will be used to control text editing - a no-brainer, one would hope.

Now we have our textfield and numeric keypad, let's make them work together; find // TODO: Implement method to input number to TextField and replace with:

void inputNumber(int value) {
    //1
    _controller.text += value.toString();
  }

  void clearLastInput() {
    //2
    _controller.text = _controller.text.substring(
      0,
      _controller.text.length - 1,
    );
  }

  void clearAll() {
    //3
    _controller.clear();
  }

There are three methods inputNumber(), clearLastInput(), and clearAll().
inputNumber - handles inputting a number into the textfield.
clearLastInput - removes the last number in the textfield.
clearAll - clears the entire input in the textfield.

1: _controller.text holds the value of the inputs in the textfield, so when inputting a number, we set the new value in the input field.
+= is called the "Add and assign operator". _controller.text += value.toString() is the same thing as _controller.text = _controller.text + value.String(), It is just a shorthand form.
2: Removes the last input in the textfield. substring() is a method on String object; as the name implies, it returns a substring of the original string. The method takes two parameters, start(required) and end(optional) index.
3: Calls the clear() method on the TextEditingController which sets _controller.text to an empty string.

Finally, find this:

Expanded(
  flex: 2,
  child: NumericKeyPad(
  onInputNumber: (value) {},
  onClearLastInput: () {},
  onClearAll: () {},
 ),
),

and replace it with:

Expanded(
  flex: 2,
  child: NumericKeyPad(
  onInputNumber: inputNumber,
  onClearLastInput: clearLastInput,
  onClearAll: clearAll,
 ),
),

Hot reload, and everything should be working fine - kind of.

You may have noticed that there are a few areas where our current implementation could use some improvement. Allow me to highlight a couple of issues:
- At first glance, it may be apparent that the cursor position does not change when a new value is added to the input field.
- Additionally, you cannot select and replace values
There are also some other concerns to consider. Currently, developers try to address these issues by disabling any interaction with the textfield. While this approach may work in certain contexts where direct interaction with the input field is not necessary, it raises questions when it comes to situations where interaction with the input field is expected.

Picture this scenario: You're typing in the search bar of your favorite browser, only to realize that there's a mistake in what you've typed. The frustrating part? The only way to rectify the mistake is to clear everything and start from scratch.

By hiding the cursor and preventing users from selecting or changing text, this may impede their ability to make corrections or perform necessary edits.

Among all these though, one issue still presents itself but can be quickly resolved. If you attempt to clear the last input by clicking the ClearButton when there is no value in the input field, you would get a RangeError in your console. To fix this just replace the function body of clearLastInput() with:

if(_controller.text.isNotEmpty){
      _controller.text = _controller.text.substring(
        0,
        _controller.text.length - 1,
      );
    }

All we did is wrap the original function body with a conditional statement. So now the function body will be invoked only if the set condition is true. This is the least of our problems though.

Earlier, I mentioned that _controller.text holds the value of the controller, this is not true. In fact, our problem arises when we add new values to the input field using _controller.text = someNewValue. I'll delve into this issue in greater detail in my upcoming article, which will be published soon. Stay tuned for the link, as I'll be sharing it shortly ( I'll post a link to the article soon here).

Did you find this article valuable?

Support David Nwaneri by becoming a sponsor. Any amount is appreciated!