loading...
Cover image for Flutter tutorial: Implementing a calculator

Flutter tutorial: Implementing a calculator

flutterclutter profile image flutter-clutter Originally published at flutterclutter.dev ・12 min read

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

Flutter calculator result

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 and Columns
  • 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:

The first time we run our app

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.

How we arrange the calculator

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.

Our first button!

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.

finished UI

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.

FULL CODE

Posted on by:

Discussion

markdown guide