I posted the following article before to get Date and Timestamp.
But sometimes it’s better to have a simple UI. I wanted text fields to input hours/minutes/seconds.
Go the my GitHub repository if you need the full code.
Put three TextField on the same Row
Let’s make the layout first. We need to put three TextField
with two colon. However, the following error occurs if we put TextField
in Row
widget.
The following assertion was thrown during performLayout():
An InputDecorator, which is typically created by a TextField, cannot have an unbounded width.
This happens when the parent widget does not provide a finite width constraint. For example, if the
InputDecorator is contained by a Row, then its width must be constrained. An Expanded widget or a
SizedBox can be used to constrain the width of the InputDecorator or the TextField that contains it.
The solution is described in the error. TextField
needs to be wrapped by SizedBox
with width for example.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TimeInputByTextField extends HookConsumerWidget {
TimeInputByTextField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("Time input by TextField"),
),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50, // width needs to be specified
child: generateTextField(),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(),
),
],
),
),
),
);
}
Widget generateTextField() {
return TextField();
}
}
The same logic is needed for the three TextField
. So I defined a method that returns TextField
. We can define the desired TextField
behavior only once in this method.
Set controller created by Flutter Hooks and control the input value
We need a controller respectively to get the input value. To make it easier, I use Flutter Hooks.
Create TextEditingController by Flutter Hooks
When a user inputs a value, the data must be checked. The check can be done in the listener of a controller.
The listener has to be added in useEffect()
and it has to be removed when the widget is rebuilt to avoid adding the same listener multiple times.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class TimeInputByTextField extends HookConsumerWidget {
TimeInputByTextField({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Add Controllers here
final hourController = useTextEditingController(text: "00");
final minController = useTextEditingController(text: "00");
final secController = useTextEditingController(text: "00");
useEffect(() {
final hourListener = () => formatTime(controller: hourController);
final minListener = () => formatTime(controller: minController, hasLimit: true);
final secListener = () => formatTime(controller: secController, hasLimit: true);
hourController.addListener(hourListener);
minController.addListener(minListener);
secController.addListener(secListener);
// This is called when it's disposed or rebuild
return () {
hourController.removeListener(hourListener);
minController.removeListener(minListener);
secController.removeListener(secListener);
};
});
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("Time input by TextField"),
),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50,
child: generateTextField(hourController),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(minController),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(secController),
),
],
),
),
),
);
}
Widget generateTextField(TextEditingController controller) {
return TextField(
controller: controller,
);
}
void formatTime({
required TextEditingController controller,
hasLimit = false,
}) {
// logic here...
}
}
Number constraint input for TextField
The value must be number only. It must be defined in TextField
. The following two configurations are enough to do. If the app is used on a mobile device, setting keyboardType
might be enough. However, non-number value might be given if a user uses copy and paste. It’s robuster to configure inputFormatters
.
// Properties in TextField
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
Always 2 digits and reinput when it has focus
I want to achieve the following two things.
- It’s possible to input a new value without removing the existing value if a
TextField
has focus again. - It should be shown with zero padding if a user gives only one value.
Let’s consider the first specification. The max length is 2 for all the 3 TextField
. TextField
has maxLength
property but if we set it to 2, it’s impossible to give a new value again until the text is removed. So, the value needs to be cut when the length is 3. In addition, the cursor has to be at the end of the text when it has focus.
The second one is easy. If a user gives only one value and presses enter, 0 needs to be added to the value.
This is the code that I tried first.
void formatTime({
required TextEditingController controller,
hasLimit = false,
}) {
if (controller.text.isEmpty) {
controller.text = "00";
}
if (controller.text.length == 3) {
controller.text = controller.text[2];
// move the cursor at the end
controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
return;
}
final num = int.parse(controller.text);
if (controller.text.length == 2) {
return;
}
if (controller.text.length == 1 && hasLimit && num > 5) {
controller.text = controller.text.padLeft(2, "0");
}
}
The cursor needs to be set correctly when the text is modified. The cursor is located at the left side of the new value for some reason.
This code looks to be working but formatTime()
is called twice when controller.text.length == 3
. The listener is triggered when something is updated. It updates selection property. Therefore, it’s called twice.
Let’s check whether the value is updated or not.
void formatTime(
WidgetRef ref, {
required StateProvider<String> lastProvider,
required TextEditingController controller,
hasLimit = false,
}) {
// check whether the value is updated or not
if (ref.read(lastProvider.notifier).state == controller.text) {
return;
}
if (controller.text.isEmpty) {
controller.text = "00";
}
// update the last value
ref.read(lastProvider.notifier).state = controller.text;
if (controller.text.length == 3) {
controller.text = controller.text[2];
controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
return;
}
final num = int.parse(controller.text);
if (controller.text.length == 2) {
return;
}
if (controller.text.length == 1 && hasLimit && num > 5) {
controller.text = controller.text.padLeft(2, "0");
}
}
We still need to do with the cursor position and the padding. When TextField
is tap, the cursor position might not be at the end. We need to set it correctly when the widget has focus.
When a user gives only one number and enter, zero padding doesn’t work. The logic needs to be added onSubmitted
. It’s not perfect because paddingZero()
is not triggered when a user taps another TextField
. I haven’t found the solution for it. I tried to add the logic to onTapOutside
but it’s not triggered when another TextField
is tapped.
Widget generateTextField(TextEditingController controller) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onTap: () => controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
),
onSubmitted: (value) => paddingZero(controller),
onTapOutside: (event) => paddingZero(controller),
maxLength: 3,
);
}
void paddingZero(TextEditingController controller) {
if (controller.text.length == 1) {
controller.text = controller.text.padLeft(2, "0");
}
}
Move the focus to the next TextField
If the following is configured in TextField
, focus moves to the next when input is completed.
textInputAction: TextInputAction.next,
But it’s not enough for my case. When TextField
is used for minutes or seconds, the range is 0 – 59. The focus should be moved to the TextField
for seconds if a user gives
- 6, 7, 8, or 9 for the first input
- two digits
on TextField
for minutes. If it’s on TextField
for seconds, the focus should be unfocused.
A focus can be moved to the desired widget by passing FocusNode
and calling requestFocus
.
void formatTime(
BuildContext context,
WidgetRef ref, {
required StateProvider<String> lastProvider,
required TextEditingController controller,
FocusNode? nextFocus,
hasLimit = false,
}) {
if (ref.read(lastProvider.notifier).state == controller.text) {
return;
}
if (controller.text.isEmpty) {
controller.text = "00";
}
ref.read(lastProvider.notifier).state = controller.text;
if (controller.text.length == 3) {
controller.text = controller.text[2];
controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
return;
}
final num = int.parse(controller.text);
if (controller.text.length == 2) {
// added here
focusOrUnfocus(context, nextFocus);
return;
}
if (controller.text.length == 1 && hasLimit && num > 5) {
controller.text = controller.text.padLeft(2, "0");
// added here
focusOrUnfocus(context, nextFocus);
}
}
void focusOrUnfocus(BuildContext context, FocusNode? nextFocus) {
if (nextFocus != null) {
FocusScope.of(context).requestFocus(nextFocus);
} else {
FocusScope.of(context).unfocus();
}
}
To make it work as expected, we need to create FocusNode
and pass it correctly.
Widget build(BuildContext context, WidgetRef ref) {
final hourFocus = useFocusNode();
final minFocus = useFocusNode();
final secFocus = useFocusNode();
// omit the code ...
useEffect(() {
// move to minutes
final hourListener =
() => formatTime(context, ref, lastProvider: hourLastProvider, controller: hourController, nextFocus: minFocus);
// move to seconds
final minListener = () => formatTime(context, ref,
lastProvider: minLastProvider, controller: minController, nextFocus: secFocus, hasLimit: true);
// unfocus
final secListener =
() => formatTime(context, ref, lastProvider: secLastProvider, controller: secController, hasLimit: true);
// omit the code ...
});
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("Time input by TextField"),
),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50,
child: generateTextField(hourController, hourFocus),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(minController, minFocus),
),
Text(":"),
SizedBox(
width: 50,
child: generateTextField(secController, secFocus),
),
],
),
),
),
);
}
Related articles
Check the following post if you don’t know how to use Riverpod.
Comments