This is a total beginner tutorial, it requires zero prior knowledge. It is thought as an entry point after having had a bit of introduction into Flutter. It's supposed to give the practical input about how to implement the very first app with Flutter.
The goal
The idea is to implement a calculator whilst learning the basics of Flutter. It should be functional but not overly complicated to develop. For that reason, we delimit the feature set by the following:
- It should have the possibility to let the user perform all basic calculations: add, subtract, multiply, divide
- We do not work with float values, thus division only includes integers
- A clean button resets the state and enables the user to define a new calculation
- A bar at the top shows the current calculation with the first operand, the operator and the second operand
- After a tap on the equals sign the user is presented the result of the calculation
What you are going to learn
- Basic layouting using
Rows
andColumns
- Using a
StatefulWidget
to keep track of the state - Extracting reusable UI into its own widget
- Making use of
MediaQuery
to determine the size of the screen
Implementation
Setup
import 'package:flutter/material.dart';
void main() {
runApp(CalculatorApp());
}
class CalculatorApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter basic calculator',
home: Scaffold(
body: Calculation()
),
);
}
}
The root widget of our application is a MaterialApp
. This gives us a lot of predefined functionality that are in line with Google's Material Design. A Scaffold also provides a lot of APIs but for now it's only relevant for us at it makes the default font look good.
The Calculation widget is not yet created, you should get an error saying "The method 'Calculation' isn't defined for the type 'CalculatorApp'". We will fix it in a minute by implementing the widget. The purpose of it is to represent the screen that holds all of the UI elements of our calculator.
Putting separate functionality in its own widget is actually a good practice as it encapsulates responsibility. This is good for testing, performance and readability. Read this article for more information about it.
import 'package:flutter/material.dart';
class Calculation extends StatefulWidget {
@override
_CalculationState createState() => _CalculationState();
}
class _CalculationState extends State<Calculation> {
int result;
@override
Widget build(BuildContext context) {
return ResultDisplay(text: '0');
}
}
The widget needs to be a StatefulWidget
because we want to hold information like the current result and later also the operands and the operator. Tip: if you are unsure whether you need a StatefulWidget
or a StatelessWidget
, just start with a stateless one. Android Studio and VS Code offer a shortcut to convert a StatelessWidget
to a StatefulWidget
which you can use if you change your mind.
ResultDisplay
ResultDisplay
is the only widget that will be displayed in our first iteration. It's the top part of the calculator showing the result of the current calculation.
import 'package:flutter/material.dart';
class ResultDisplay extends StatelessWidget {
ResultDisplay({
@required this.text
});
final int result;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 80,
color: Colors.black,
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 34
),
)
);
}
}
We make our ResultDisplay
widget a stateless widget. That's because it does not change over time. The only dynamic part is the text itself, which the widget gets injected in the constructor. When the text changes, the parent widget will trigger a rebuild of this widget which makes it display the new text. If you want to know more about the difference of StatelessWidget
and StatefulWidget
and when to use which one, read more about it in this article.
We use Dart's syntactic sugar for constructors that do nothing else but mapping the arguments to the class properties. This prevents a little bit of boilerplate.
Now we create a Container
widget with infinite width because we want to span across the whole width of the screen and a fix of 80 pixels. We set the color
attribute in order to determine the background color.
On top of that we want to display the result with a white color and a font size of 34. The text itself is the result of the text
property of this class which gets it in the constructor. We will take care of that later and let it be '0' for now.
If you run the app now, it should look something like this:
It's a start, but apart from the debug banner and the status bar overlay which we are going to take care of afterwards, the 0 is not aligned the way we want. We want it to be centered vertically and have a padding to the right.
There are widgets called Align
and Padding
which we could use. However, we would have an unnecessary deep nesting with Align
being parent of Padding
being parent of Container
. In fact, Align
and Padding
are nothing more than Container
widgets with certain parameters set. So instead of nesting widgets, we just set the respective properties of our already existing Container
widget:
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 80,
color: Colors.black,
child: Container(
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 24),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 34
),
)
)
);
}
Now let's take care of the annoying debug banner and the status bar that covers our newly implemented widget. Getting rid of the debug banner is as simple as setting the debugShowCheckedModeBanner
property in our MaterialApp
:
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter basic calculator',
home: Scaffold(
body: Calculation()
),
);
}
Regarding the status bar, we have two options: either we extend our app into the notch and apply a top padding so that the status bar overlaps nothing particular useful or we decide to render our app content into a SafeArea
. This is a possibility to prevent interference of the UI with the OS-specific top and bottom bars.
class CalculatorApp extends StatefulWidget {
@override
_CalculatorAppState createState() => _CalculatorAppState();
}
class _CalculatorAppState extends State<CalculatorApp> {
@override
void initState() {
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
)
);
super.initState();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter basic calculator',
home: Scaffold(
body: Calculation()
),
);
}
}
class ResultDisplay extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 120,
color: Colors.black,
child: Container(
alignment: Alignment.bottomRight,
padding: EdgeInsets.only(right: 24, bottom: 24),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 34
),
)
)
);
}
We turn the CalculatorApp
into a StatefulWidget
because we need the initState()
method to perform an action once. Doing such tasks in the build()
is not a good practice as it's not guaranteed that the method is only executed once.
Instead of vertically centering the text, which would lead to it being halfway positioned inside the notch, we choose a bottom right alignment and a bottom padding of 24.
class CalculatorApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter basic calculator',
home: Scaffold(
body: SafeArea(
child: Calculation()
)
),
);
}
}
The other option is to wrap our Calculation
widget in a SafeArea
. The disadvantage is that the look of the app is not as immersive as there is a black bar at the top.
Excluding the ResultDisplay
, there are 4 Rows
within the Column
. A Column
allows for vertical widget placement. The Row
likewise in the horizontal direction.
@override
Widget build(BuildContext context) {
return Column(
children: [
ResultDisplay(text: '0'),
Row(
children: [
// Here we want to place the buttons of the first Row
],
)
]
);
}
Creating the keypad button
Let's design a widget first, that resembles a button on our keypad. It should be square, show its label and have a ripple effect on tap.
import 'package:flutter/material.dart';
class CalculatorButton extends StatelessWidget {
CalculatorButton({
@required this.label,
@required this.onTap,
@required this.size,
this.backgroundColor = Colors.white,
this.labelColor = Colors.black
});
final String label;
final VoidCallback onTap;
final double size;
final Color backgroundColor;
final Color labelColor;
@override
Widget build(BuildContext context) {
...
}
}
Again, we use the shorthand constructor to assign the arguments to the member variables. These are:
- label: What's written on the button, e.g. a number or an operator
- onTap: A callback that is executed whenever the button is tapped
- size: The button's dimension. We only need one value as the buttons are supposed to be square
- backgroundColor: The background color, which defaults to white because most of the buttons have a white background
- labelColor: The label color, which defaults to black because most of the buttons have a black label
Now that we have written the constructor, let's design the build()
that actually determines the visuals of our button.
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(6),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey,
offset: Offset(1, 1),
blurRadius: 2
),
],
borderRadius: BorderRadius.all(
Radius.circular(size / 2)
),
color: backgroundColor
),
child: // Label text and other stuff here
)
);
}
We start with a Padding
widget that creates an inset around its child by the specified EdgeInsets
. It's important to do it this way and not use the padding
property of the Container
widget because while the former creates a margin, the latter creates a padding and what we want is a margin between the buttons of the keypad.
The container represents the button itself. width and height are both of the given size. In order to mimic a button, we add a drop shadow using the BoxShadow
widget and the boxShadow
property of the Container
widget.
So far we have a rectangle. We turn it into a circle by using the borderRadius
property with half the size.
The color property of a Container
widget determines its background color. We set it to backgroundColor
.
Now, let's take care of the label and a ripple effect:
...
FlatButton(
shape: CircleBorder(),
onPressed: onTap,
child: Center(
child: Text(
label,
style: TextStyle(fontSize: 24, color: labelColor),
)
),
),
...
A FlatButton
is a certain kind of MaterialButton
. These widgets take care of the button's styling and interaction behaves like Google's Material Design prescribes it.
The official docs say:
Use flat buttons on toolbars, in dialogs, or inline with other content but offset from that content with padding so that the button's presence is obvious. Flat buttons intentionally do not have visible borders and must therefore rely on their position relative to other content for context. In dialogs and cards, they should be grouped together in one of the bottom corners. Avoid using flat buttons where they would blend in with other content, for example in the middle of lists.
Material design flat buttons have an all-caps label, some internal padding, and some defined dimensions. To have a part of your application be interactive, with ink splashes, without also committing to these stylistic choices, consider using InkWell instead.
Okay, while using a FlatButton
would technically work, it doesn't seem the right thing to use here, as we display it in a different way. Actually we only want a button with a ripple effect. Let's follow the recommendation of the documentation and use an InkWell
.
If we just replace the FlatButton
by an InkWell
, we would not see an effect. That's because the parent Container has a background color that would cover everything we want to see. Flutter's team is well aware of this circumstance and developed an Ink widget to address this issue.
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(6),
child: Ink(
width: size,
height: size,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey,
offset: Offset(1, 1),
blurRadius: 2
),
],
borderRadius: BorderRadius.all(
Radius.circular(size / 2)
),
color: backgroundColor
),
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(size / 2)),
),
onTap: onTap,
child: Center(
child: Text(
label,
style: TextStyle(fontSize: 24, color: labelColor),
)
),
),
)
);
}
In order to maintain the circular shape, we apply a RoundedRectangleBorder
as the customBorder
property of the InkWell
widget.
We display the button by placing it under the ResultDisplay
:
Row(
children: [
CalculatorButton(
label: '7',
onTap: () => {},
size: 90,
backgroundColor: Colors.white,
labelColor: Colors.black,
)
],
)
We set the onTap
to an empty function and the size to 90. We will change both of the values later.
Great. Now, let's fill the rest of the keypad. To simplify this, we wrap the CalculatorButton
with a function. This way, we can define default values for the colors because most of the buttons have the same color combination. Also, when we set the size depending on the screen width, we don't have to copy and paste this calculation for every Button.
Widget _getButton({String text, Function onTap, Color backgroundColor = Colors.white, Color textColor = Colors.black}) {
return CalculatorButton(
label: text,
onTap: onTap,
size: 90,
backgroundColor: backgroundColor,
labelColor: textColor,
);
}
Filling the keypad with buttons
Now, lets insert every button we know we are going to need.
@override
Widget build(BuildContext context) {
return Column(
children: [
ResultDisplay(text: '0'),
Row(
children: [
_getButton(text: '7', onTap: () => numberPressed(7)),
_getButton(text: '8', onTap: () => numberPressed(8)),
_getButton(text: '9', onTap: () => numberPressed(9)),
_getButton(text: 'x', onTap: () => operatorPressed('*'), backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
],
),
Row(
children: [
_getButton(text: '4', onTap: () => numberPressed(4)),
_getButton(text: '5', onTap: () => numberPressed(5)),
_getButton(text: '6', onTap: () => numberPressed(6)),
_getButton(text: '/', onTap: () => operatorPressed('/'), backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
],
),
Row(
children: [
_getButton(text: '1', onTap: () => numberPressed(1)),
_getButton(text: '2', onTap: () => numberPressed(2)),
_getButton(text: '3', onTap: () => numberPressed(3)),
_getButton(text: '+', onTap: () => operatorPressed('+'), backgroundColor: Color.fromRGBO(220, 220, 220, 1))
],
),
Row(
children: [
_getButton(text: '=', onTap: calculateResult, backgroundColor: Colors.orange, textColor: Colors.white),
_getButton(text: '0', onTap: () => numberPressed(0)),
_getButton(text: 'C', onTap: clear, backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
_getButton(text: '-', onTap: () => operatorPressed('-'),backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
],
),
]
);
}
...
operatorPressed(String operator) {}
numberPressed(int number) {}
calculateResult() {}
clear() {}
Empty functions were added for the callbacks being executed when a button is pressed. By filling these with actual content, we achieve interactivity.
Adding interactivity
We start by defining what happens when the number is tapped:
numberPressed(int number) {
setState(() {
if (result != null) {
result = null;
firstOperand = number;
return;
}
if (firstOperand == null) {
firstOperand = number;
return;
}
if (operator == null) {
firstOperand = int.parse('$firstOperand$number');
return;
}
if (secondOperand == null) {
secondOperand = number;
return;
}
secondOperand = int.parse('$secondOperand$number');
});
}
There are different cases here:
- If the previous calculation is finished (thus result is not null), set the result to null and let the number that was just pressed to the new first operand
- If the first operand is null (this is the case at the beginning or when the clear button was pressed), set the first operand to the pressed number
- If the operator is null, pressing a number button will concat the number to the first operand. Otherwise we could only perform one-digit operations
- Same logic applies to the second operand
Now let's take care of the user pressing a button with an operator:
operatorPressed(String operator) {
setState(() {
if (firstOperand == null) {
firstOperand = 0;
}
this.operator = operator;
});
}
Since the default value of the first operand is null, we have the case of somebody pressing an operator button with no set first operand. In this case we treat it like zero. We set the member variable operand to the given operand.
Okay, now let's have a look at the function that is executed once the result button is tapped:
calculateResult() {
if (operator == null || secondOperand == null) {
return;
}
setState(() {
switch (operator) {
case '+':
result = firstOperand + secondOperand;
break;
case '-':
result = firstOperand - secondOperand;
break;
case '*':
result = firstOperand * secondOperand;
break;
case '/':
if (secondOperand == 0) {
return;
}
result = firstOperand ~/ secondOperand;
break;
}
firstOperand = result;
operator = null;
secondOperand = null;
result = null;
});
}
The first part makes the function return when either the operator or the second operand is null. Because there is nothing to calculate if one of them is missing.
The second part is about actually performing the calculation. I guess every operator is fairly simple except for the multiply (*) operator. This is due to the fact that we do not support float numbers. That's why we use ~/ which performs integer division. We also make sure that a division by zero is ruled out.
After the calculation we instantly prepare the next calculation by setting the first operand to the result of our current calculation and resetting everything else to null.
We need to wrap everything with setState()
. Otherwise the widget will not rebuild which will result in the changes not affecting the UI.
We will have a look at the behavior of the clear button now:
clear() {
setState(() {
result = null;
operator = null;
secondOperand = null;
firstOperand = null;
});
}
It's as simple as resetting every variable to null.
Displaying the result
So far, we only display a "0" in the ResultDisplay
. However, we want to display the calculation or the result depending on the current state.
ResultDisplay(
text: _getDisplayText(),
)
...
String _getDisplayText() {
if (result != null) {
return '$result';
}
if (secondOperand != null) {
return '$firstOperand$operator$secondOperand';
}
if (operator != null) {
return '$firstOperand$operator';
}
if (firstOperand != null) {
return '$firstOperand';
}
return '0';
}
Instead of providing '0' as the text property of the constructor of our ResultDisplay
, we choose the return value of the _getDisplayText()
method.
This method returns the result if it is not null. If the second operand is set, it displays the whole calculation. If the operator is set, it displays the first operand and the operator. If only the first operator is set, it displays it. Finally if even the first operand is null (e. g. at startup time or when the user has pressed the clean button), it displays '0'.
Proper sizing of the buttons
There is one thing left to do and that's having the UI being responsive instead of the buttons having a static size of 90.
class _CalculationState extends State<Calculation> {
double width;
int firstOperand;
String operator;
int secondOperand;
int result;
@override
void didChangeDependencies() {
width = MediaQuery.of(context).size.width;
super.didChangeDependencies();
}
...
Widget _getButton({String text, Function onTap, Color backgroundColor = Colors.white, Color textColor = Colors.black}) {
return CalculatorButton(
label: text,
onTap: onTap,
size: width / 4 - 12,
backgroundColor: backgroundColor,
labelColor: textColor,
);
}
We add a member variable called width. This will hold the width of the screen. We use didChangeDependencies()
in order to obtain the screen width and set the variable to its width value. didChangeDependencies()
is called after initState()
. We can not use initState()
because we need the BuildContext to obtain the screen width which is not available yet.
To determine the size of a button we simply divide the screen width by four because we have four buttons in a row.
Final words
In this beginner tutorial we implemented a calculator that enables the user to do basic calculations. We extracted the responsibilities in separate widgets to keep the code readable.
Feel free to extend the example by things like floats support, addition operators like square root or a history of all calculations.
Top comments (2)
Thanks a lot, enjoyed reading and applying the instructions in this article. It's a little bit outdated because flutter now applies null safety principles, other than that it's just fantastic for beginners.
I have an interesting Flutter template for the love calculator. I think you'll find it interesting. Check it here - dev.to/pablonax/free-vs-paid-flutt...