Stop fighting the native iOS keypad and build a custom number pad for Flutter.

Casey Henson
7 min readJan 18, 2020

I know I’m not the only one who struggles with the unpredictable, limiting behavior of the iOS native keyboard when developing on Flutter. And what’s more, I know I’m not the only one who likes to build cool shit.

So today we’re going to throw out those TextEditingControllers and say hello to a beautiful, totally custom numeric keypad for Flutter that will surely please your users (and maybe yourself, too).

Where to begin…

Maybe I should preface this deep dive by providing some context and scope. If you’ve been developing on Flutter then you’ve surely come across the need for text input. When I first experienced this I found it to be interesting, after all the TextEditingController is a pretty robust little guy. It handles triggering the keyboard, letting you know when the user keys up, and all of the essentials that come along with text fields.

But I’ve found that using the native keyboard has become a complete and total headache. Sure I implemented some clever hacks to get the keyboard to hide when changing tabs, and a nifty little bar to provide a “done” button to the top of the iOS keyboard. But still my apps were bugging out on certain devices and I just didn’t have the control I needed to build really great software.

Now for the scope.

My current project requires working with lots of numbers. The end user doesn’t do much text entry but there are a lot of various data points to consider, and the user needs to be able to quickly edit them many times with consistent results. So suffice it to say that if you’re looking for a full replacement keyboard that includes A-Z, there’s probably something out there for you.

This is just for a basic number pad, plus the option for really any other key that you’d like to throw in there.

Heck, now that I think about it, go nuts! You can use this same concept to build whatever kind of keyboard you want.

Inspiration.

So the other day I thought to myself: I’ve been using this SlidingUpPanel plugin for Flutter and absolutely love its adaptability for many different scenarios. I continued thinking to myself: I wonder if it’d be practical or even possible to just provide a few RaisedButtons to let the user tap around to their heart’s content, knowing full well that I can utilize the SlidingUpPanel controller to easily open and close the keyboard?

I just had to try. And if I’m being honest, I guess I really didn’t have to try all that hard.

Step 1: The SlidingUpPanel

I’ll skip the preamble, if you want to know how to install the panel, it’s very simple: https://pub.dev/packages/sliding_up_panel.

Once installed, be sure to init your panel controller in your Stateful Widget State:

PanelController _pc = new PanelController();

Then add it to your SlidingUpPanel Widget:

body: SlidingUpPanel(
controller: _pc,
...
)

My current usage of the SlidingUpPanel as a keyboard also includes the use of onPanelClosed to listen for the user clicking “Done”:

body: SlidingUpPanel(
controller: _pc,
onPanelClosed: () {
//TODO: take action to update state
},
...
)

What’s more is that I’ll be setting an object as the panel property of the SlidingUpPanel which will allow me to render a keyboard within the panel that’s unique to each instance:

body: SlidingUpPanel(
controller: _pc,
onPanelClosed: () {
//TODO: take action to update state
},
panel: customKeyboard, //defined before Build,
minHeight: 0, //this totally hides the panel when closed
maxHeight: 300, //this is plenty of space for a numeric keypad when open
...
)

Okay that’s enough setup for now.

To trigger the panel to open what I’ve done is to simply include a RaisedButton whose text happens to be the value that the user will be editing:

RaisedButton(
onPressed: () {
_pc.open();
if (this.mounted) {
this.setState(() {
customKeyboard = getCustomKeyboard(_pc);
});
}
},
....text and styles go here
)

You’ll notice that to init the keyboard I just need to call _pc.open() . By passing the controller to the keyboard, I’m also able to call _pc.close() from the custom keyboard class (more on that in a sec) to allow the user a very natural feeling open/close behavior.

At this point you can hopefully see that we’re using the SlidingUpPanel as the container for the keyboard. There are a ton of options that I recommend you play with to make the look and feel of the action as on-brand for your app as possible.

Now, off to build the keyboard contents! This was quite fun I thought. I’ll drop most of the code here in a nice, long snippet, then we can pick it all apart.

import 'package:flutter/material.dart';

//MOBX integration
import 'package:flutter_mobx/flutter_mobx.dart';
import '../reactive/keyboard.dart';
final reactiveKeyboard = ReactiveKeyboard();

var output;
getCustomKeyboard(reactiveClass, classType, _pc, value, parameter, showDecimal, property) {
output = value.toString();
reactiveKeyboard.setValue(value);

return WidgetCustomKeyboard(
reactiveClass: reactiveClass,
classType: classType,
pc: _pc,
value: value,
parameter: parameter,
showDecimal: showDecimal,
property: property
);
}

class WidgetCustomKeyboard extends StatefulWidget {

final reactiveClass;
final classType;
final pc;
final value;
final parameter;
final showDecimal;
final property;

WidgetCustomKeyboard({Key key,
@required
this.reactiveClass,
this.classType,
this.pc,
this.value,
this.parameter,
this.showDecimal,
this.property,
}) : super(key: key);

_WidgetCustomKeyboardState createState() => _WidgetCustomKeyboardState();
}

class _WidgetCustomKeyboardState extends State<WidgetCustomKeyboard> {
@override
Widget build(BuildContext context) {
return Scaffold(
body:
Observer(
builder: (_) {
return Container(
decoration: BoxDecoration(
color: new Color(customColorDarkGrey),
border: Border(
top: BorderSide(width: 2.0, color: new Color(customColorLightGrey)),
),
),
margin: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0),
child:
new ListView(
children: <Widget>[
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
valueField(),
affirmationButton(
widget.reactiveClass, widget.classType, 'done',
widget.parameter, widget.pc, widget.property)
]
),
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
numericInputButton(1),
numericInputButton(2),
numericInputButton(3),
]
),
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
numericInputButton(4),
numericInputButton(5),
numericInputButton(6),
]
),
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
numericInputButton(7),
numericInputButton(8),
numericInputButton(9),
],
),
new Visibility(
child:
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
numericInputButton('.'),
numericInputButton(0),
backButton()
]
),
visible: widget.showDecimal
),
new Visibility(
child:
new Row (
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
blankSpace(),
numericInputButton(0),
backButton()
]
),
visible: !widget.showDecimal
),
],
),
);
}
)
);
}



_appendToOutput(value) {
if (this.mounted) {
this.setState((){
output = output == '' ? value.toString() : output + value.toString();
});
}
reactiveKeyboard.setValue(output);
}
_removeFromOutput() {
if (this.mounted) {
this.setState(() {
output = output.substring(0, output.length-1);
});
}
reactiveKeyboard.setValue(output);
}

valueField() {
return Container(
width: 200,
decoration: BoxDecoration(
color: new Color(customColorDarkGrey),
border: Border(
bottom: BorderSide(width: 1.0, color: new Color(customColorLightGrey)),
),
),
margin: const EdgeInsets.fromLTRB(0.0, 0.0, 10.0, 10.0),
child:
new Padding(
padding: EdgeInsets.fromLTRB(0, 0, 0, 5),
child:
new Text(
reactiveKeyboard.value.toString(),
style: TextStyle(
fontSize: 28,
color: new Color(customColorLightGrey),
)
)
)
);
}

numericInputButton(value) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RaisedButton(
onPressed: () {
_appendToOutput(value);
},
color: new Color(customColorGreen),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
value.toString(),
style: TextStyle(fontSize: 25, color: Colors.white),
),
),
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40)),
),
]
);
}



blankSpace() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RaisedButton(
onPressed: () {
//TODO: nothing really
},
color: new Color(customColorDarkGrey),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: new Text(''),
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40)
),
),
]
);
}

backButton() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RaisedButton(
onPressed: () {
_removeFromOutput();
},
color: new Color(customColorLightGrey),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: new Icon(Icons.backspace),
),
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40)),
),
]
);
}

affirmationButton(reactiveClass, classType, value, key, _pc, property) {
return Container(
margin: EdgeInsets.fromLTRB(0, 0, 0, 5),
child:
new RaisedButton(
onPressed: () {
//TODO: submit data to your model/database/etc
_pc.close();
},
color: new Color(customColorLightGrey),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
value.toString(),
style: TextStyle(fontSize: 16, color: new Color(customColorGreen), fontWeight: FontWeight.bold),
),
),
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40)
),
),
);
}
}

So there’s a nice long look into my keyboard contents. Please ask me anything about it that you don’t understand, let me know what you think could be improved, and if you happen to use it please toss a few claps my way just for the heck of it.

What’s important here to me for this project is that I’m able to utilize my MobX Stores to keep data in the parent widget (the body of the SlidingUpPanel) in sync with what the user enters, but only once the user has hit the affirmation button (“Done” in my case). In the meantime I’m simply storing the users input in an output variable which I’m regenerating as a String with each keypress or backspace so that once the user is done we have one simple String to use as the resulting value.

You might also have caught the valueField() Container which I’ve placed beside the done button to appear as an editable field within the keyboard. This is totally optional but I feel that it adds nice feedback to the end user.

Notice that I’m also passing lots of other variables into the script. This is allowing me to create logic within the CustomKeyboard class to determine which controller should be updated upon affirmation, which I’ve left out of this example (e.g. //TODO: submit data to model ). In truth there are many more things that I’m doing with this and that you can do as well, such as passing data validation to the keyboard to ensure that the input doesn’t get out of hand (think multiple decimal points, etc).

Here’s how it looks when it’s all put together:

Maybe you’re just getting started with Flutter or maybe you’re an expert. Either way, I’d love to hear your thoughts on this implementation and if you were able to use it as inspiration to build a custom keyboard for yourself.

--

--

Casey Henson

Full stack, no stack, half stack, and sometimes double stack developer. Writing about things like technology, life, and my fascination with all of it.