Let's create an iOS Calculator Clone in React [+ detailed explanations]

Let's create an iOS Calculator Clone in React [+ detailed explanations]

15 min read

Hello everybody! 馃殌

Today we will be creating an iOS calculator clone using React.

result.png

This tutorial comprises 3 parts: the components structure, the calculator's interface and the functionality.

Let's dive into it and start by creating the components and their structures.

1. The components

Since we're using a component-based approach, we're going to try to modularize our app as much as we can, so with that vision in mind, we'll split it up into 4 components, namely, the following:

路 Calculator.js

This will be the main component. The one who will hold all the logic and functionality, and will interact with the rest of the component through props.

路 Display.js

The screen of the calculator. It will receive a value that will be displayed on screen.

路 Keypad.js

The keypad of the calculator. It will be divided into a few sections, depending on the functionality of every specific set of buttons.

路 Button.js

A reusable component for every button in the calculator.


Now that we've learned about the components we're going to work with, let's turn our attention to the structure of each of them.

1.1. Calculator.js

The parent component, which will be in charge of all the functionality and the one managing the state of the whole calculator app.

import React, { useEffect, useState } from "react";
import Display from "./Display";
import Keypad from "./Keypad";

const Calculator = () => {
  ...
  return (
    <div id="calculator-view" className={"flex column jc-center ai-center"}>
      <div id="viewport" className={"flex column jc-sp-between ai-center"}>
        <Display value={screenValue} />
        <Keypad actionToPerform={handleActionToPerform} allClear={isScreenClear} />
      </div>
    </div >
  )
}

This main component is going to call a couple of different custom components: Display and Keypad, so they need to be imported up above.

Note that the ellipsis (...) in this code snippet means that we'll be injecting some state and functionality for the component soon later.

For now, let's just focus on the structure of the components themselves.

1.2. Display.js

A very simple component that just receives a value and shows it on screen, as mentioned earlier above.

import React from "react";

const Display = (props) => {
   const { value } = props;
   return (
      <div id="display" className="flex">
         <input type="text" tabIndex="-1" value={value} />
      </div>
   )
}

export default Display;

1.3. Keypad.js

Keypad is a component whose function is to serve as a gateway between the calculator and the buttons.

import React from "react";
import Button from "./Button";
const Keypad = (props) => {
   const { actionToPerform, allClear } = props;
   ...
   const handleClickButton = (value, keyType) => {
      actionToPerform(value, keyType);
   }
   return(
      <div id="keypad" className="flex row jc-sp-around">
         <div className="grid">
            {functionKeys.map(
                    functionKey =>
                        <Button key={functionKey.label} label={functionKey.label} value={functionKey.value}
                            buttonStyle="fx-key" onClick={handleClickButton} type="fx" />
             )}
            {numericKeys.map(
                    numericKey =>
                        <Button key={numericKey} label={numericKey} value={numericKey}
                            buttonStyle="numeric-key" onClick={handleClickButton} type="numeric" />
             )}
            {lastRowKeys.map(
                    lastRowKey =>
                        <Button key={lastRowKey.label} label={lastRowKey.label} value={lastRowKey.value}
                            buttonStyle={lastRowKey.buttonStyle} onClick={handleClickButton} type={lastRowKey.type} />
             )} 
         </div>
         <div className="flex column jc-sp-btw">
            {operatorKeys.map(
                    operatorKey =>
                        <Button key={operatorKey.label} label={operatorKey.label} value={operatorKey.value}
                            buttonStyle="op-key" onClick={handleClickButton} type="operator" />
             )}
         </div>
      </div>      
   )
}

export default Keypad;

This component contains a bunch of buttons (don't forget to import the Button component 馃檪), which when pressed, send back some information about their functionality and type. The keypad, in turn, will send this data to the Calculator component through the actionToPerform prop.

Since it's a good practice to abstract your data as much as you can (always in a moderate way), we'll be using arrays to define every set of buttons instead of defining a button component every time we need to create one. This is useful for many reasons.

One of them, among others: Let's say that you wish to change the name of the Button component to Key. If you're calling the component 10 times, you'd have to change the name of the component 10 times. However, if you map through an array that creates a Button component in every iteration, you'd just have to make the change once.

Let's have a look at how these arrays are defined and structured:

const numericKeys = [7, 8, 9, 4, 5, 6, 1, 2, 3];

const operatorKeys = [
   { label: "梅", value: "/" },
   { label: "脳", value: "x" },
   { label: "-", value: "-" },
   { label: "+", value: "+" },
   { label: "=", value: "=" }
];

const functionKeys = [
   { label: allClear ? "AC" : "C", value: allClear ? "AC" : "C" },
   { label: "卤", value: "+/-" },
   { label: "%", value: "%" }
];

const lastRowKeys = [
   { label: "0", value: "0", type: "numeric", buttonStyle: "numeric-key special" },
   { label: "路", value: ".", type: "fx", buttonStyle: "numeric-key" }
];

For numeric keys, we just have an array of integers, ordered by the order of occurrence of each of them.

For operator and function keys, we have an array of objects, each of them containing a label and a value.

For last row keys (they receive this name because they appear at the bottom but have different functionalities as to organize them based on that), we have as well an array of objects, each of them comprising a label, a value, a type and a buttonStyle.

##1.4. Button.js

A reusable component to define buttons.

import React from "react";

const Button = (props) => {
    const { value, type, buttonStyle, label, onClick } = props;

    const handleButtonClick = () => {
        onClick(value, type);
    }
    return (
        <button name={value} className={buttonStyle} onClick={handleButtonClick}>
            {label}
        </button>
    );
};

export default Button;

This component just renders a regular HTML button element.

2. The interface

In this tutorial, we're cloning an existent app, so our replica should be the most faithful possible to the original interface.

For styling the interface, we'll be using SCSS. But of course you can use whichever styling language / tool / resource of your choice: CSS, SASS, LESS, PostCSS, Styled Components...

Here's the code:

//color variables
$white: #fff;
$black: #000;
$dark-gray: #333;
$medium-gray: #444;
$gray: #a5a5a5;
$light-gray: #c4c4c4;
$orange: #ff9d20;
$light-orange: #ffb657;

* {
    font-family: "Source Sans Pro", sans-serif;
    font-weight: 200;
    color: $white;
}

.flex {
    display: flex;
}

.row {
    flex-flow: row nowrap;
}

.column {
    flex-flow: column wrap;
}

.flex-end {
    justify-content: flex-end;
}

.jc-sp-btw {
    justify-content: space-between;
}

.jc-sp-around {
    justify-content: space-around;
}

.jc-center {
    justify-content: center;
}

.ai-center {
    align-items: center;
}

.grid {
    display: grid;
    grid-template-columns: repeat(3, auto);
    gap: 9px; 
}

#calculator-view {
   width: 385px;
   height: 775px;
   background-color: $black;
   border-radius: 70px;
   border: 10px solid $dark-gray;
   #viewport {
      width: 90%;
      height: 70%;
      #display {
         width: 100%;
         input {
            border: none;
            outline: none;
            font-size: 6rem;
            background-color: $black;
            width: 100%;
            text-align: right;
            padding-right: 20px;
         }
      }
      #keypad {
         width: 97%;
         button {
            border: none;
            border-radius: 50px;
            width: 75px;
            height: 75px;
            font-size: 2rem;
            cursor: pointer;
            &.fx-key {
               background-color: $gray;
               color: $black;
               &:hover {
                  background-color: $light-gray;
               }
            }
            &.numeric-key {
               background-color: $dark-gray;
               &:hover {
                  background-color: $medium-gray;
               }
            }
            &.op-key {
               background-color: $orange;
               font-size: 3rem;
               &:hover {
                  background-color: $light-orange;
               }
            }
            &.special {
               width: 100%;
               grid-column-start: 1;
               grid-column-end: span 2;
               text-align: left;
               padding-left: 25px;
            }
         }
      }
   }
}

There's not really much to explain here. We're just simulating the interface of the iOS calculator and this snippet of code would be it! 馃槆

3. The functionality

Let's start by defining the overall state for the calculator (specified and managed in the calculator component).

const Calculator = () => {
   const [accValue, setAccValue] = useState(null);
   const [screenValue, setScreenValue] = useState("0");
   const [currentOperator, setCurrentOperator] = useState(null);
   const [expectsOperand, setExpectsOperand] = useState(false);
   ...
}

export default Calculator;

What's the main idea in here?

Well, we need to divide our component state into four chunks (the minimum required for our calculator to behave as a calculator):

  • accValue: the accumulated value in the calculator. It starts off as null because there's no accumulated value initially.

  • screenValue: the value that is going to be shown on screen. Initially, its value is "0". Note that we're defining it as a string, not a number. We'll talk about this later.

  • currentOperator: the ongoing operator. As well as accValue, it starts off as null for the same reason.

  • expectsOperand: a boolean that lets the calculator know if a new operand should be entered after pressing a button or if, on the contrary, a result, which is final itself, has been already calculated / shown. It will become true when an operator key is pressed, and false otherwise (only operations wait for a second operand. Neither numbers nor functions that apply to a single operand). It starts off as false, since the initial state is stable itself.

Let's now take a look at the different types of functionality that our calculator will implement and their associated keys/buttons.

But first, let me show you the handler that will be called every time a key (Button component) is pressed. It receives the value of the key and the key type (function, numeric or operator) as parameters. The handler itself will call a different function depending on the value of keyType:

const handleActionToPerform = (value, keyType) => {
   switch (keyType) {
      case "fx": handleClickFunctionKey(value); break;
      case "numeric": handleClickNumericKey(value); break;
      case "operator": handleClickOperator(value); break;
   }
}

3.1. Function keys

The function keys are those who implement a function over a single operand or functions related to the screen.

This is what happens when we click on a function button:

const handleClickFunctionKey = value => {
   switch (value) {
      case "AC": allClear(); break;
      case "C": clearScreen(); break;
      case "+/-": reverseSign(); break;
      case "%": percentage(); break;
      case ".": addDecimalPoint(); break;
   };
 }

We have implemented a switch statement that decides which function will be executed next based on the passed in value.

The few different function keys in our calculator implement the following actions:

3.1.1. All clear and clear screen functions: AC/C

The allClear function (AC) clears everything and resets every value to their initial state.

const allClear = () => {
   setAccValue(null);
   setScreenValue("0");
   setCurrentOperator(null);
   setExpectsOperand(false);
}

The clearScreen function (C) clears the value of the current screen, but the rest of the state remains the same.

const clearScreen = () => {
   setScreenValue("0");
}

These two functions are available through the same button, so we need to have a boolean variable who manages the current state of the screen (clear or not) at all times, to be able to know which one of them should be shown as the label of the button. That's the reason why this variable is passed to the Keypad component as a prop.

const isScreenClear = screenValue === "0";

3.1.2. Reverse sign function: +/-

The reverseSign function, as its name indicates, reverses the sign of the screen value.

const reverseSign = () => {
   setScreenValue(String(-parseFloat(screenValue)));
}

String? parseFloat?

Well, it's time to mention how the data is displayed and stored in the calculator. Let's keep in mind the following fact:

  • What we see on screen is a value stored as a string and the values with which we operate are stored as float numbers.

You may be wondering why we don't use floats directly on the screen. The answer is because we could never see something like 0. using a float. That's only possible by using a string.

As easy as that :)

So, in this particular case, we are parsing the screen value (currently a string) into a float value, then we reverse its sign, and then we reconvert it to string to show it on screen.

3.1.3. Percentage function: %

The percentage function divides the screen value by 100.

const percentage = () => {
   setScreenValue(String(parseFloat(screenValue)/100));
};

We're applying the same method to set the new screen value (retrieve the current screen value, parse it to float, operate with it and reconvert it to string).

3.1.4. Decimal point function: .

The addDecimalPoint function adds a dot to visually transform the current screen number into a float-like number (we're actually operating with floats, but remember that the screen value is a string and if we were using a float directly, we could never see something like 0. or 3.).

const addDecimalPoint = () => {
   if (expectsOperand) {
      setScreenValue("0.");
   }
   else {
      if (!screenValue.includes("."))
         setScreenValue(screenValue + ".");
   }
   setExpectsOperand(false);
}

Let's stop for a minute to understand the idea of this function.

When adding a dot (to let the user know that they can add decimals to the number shown on screen), we need to be a little bit more cautious than with the rest of operations.

Let's propose these scenarios:

If the calculator is waiting for an operand, that is, the next step is adding a second operand (let's say we want the second operand to be 0.5), and we directly press on the decimal point key (without pressing a numeric key before), a 0 should be appended in front of that dot. The calculator must not in any case show an operand starting by a dot (.5, for example).

But if the calculator isn't waiting for anything else, that is, the current state is stable (the screen value is a full operand and makes sense by itself, although we still have the possibility of adding more digits), a dot will be concatenated to the screen value if and only if there's no other dot in it. Otherwise, the screen number will remain the same. A number can't have two decimal parts 馃槹

3.1.5. Delete last digit function: <-

In this calculator prototype, a button for removing the last digit of the current screen value is not provided, so we're going to emulate such behavior by using the backspace key of the keyboard.

Note that this is the only one functionality that we're going to implement to be used through the keyboard, not number, operator or function keys. For those, we'll be using the keypad buttons, not the keyboard.

This case works quite differently and we need to add an event listener for such purpose. An event listener is an object that listens for an event to happen and triggers a function every time it occurs.

Let's see the code before going on any further:

useEffect(() => {
   document.addEventListener('keydown', handleKeyDown);
   return () => document.removeEventListener('keydown',handleKeyDown);
   }, [screenValue]);

The event for which the listener waits is a keyboard key being pressed. To specify that behavior, we're passing in the keydown event type.

When this event occurs, the function handleKeyDown will be called and its code will be executed.

Note that we're specifying this event listener within the useEffect hook, which, in addition, is being called conditionally.

Ok, but... Why? Well, because we need this listener to be ready to trigger the function every time the screen value changes. That's it 馃檪

Oh, and don't forget to remove the event listener to avoid odd behaviors in your code.

Let's now take a look at handler for the event:

const handleKeyDown = e => {
   if (e.key === 'Backspace') {
      e.preventDefault();
      clearLastDigit();
   }
}

Note that the handler itself calls another function, which is the one executed to delete the last entered digit:

const clearLastDigit = () => {
   if (screenValue !== "0")
      if (screenValue.length > 1)
         setScreenValue("0");
      else {
         setScreenValue(screenValue.substring(0, screenValue.length - 1));
      }
   }

This function, as mentioned right up above, deletes the last entered digit of the screen value if its length is greater than 1. Otherwise, the screen value becomes zero (the screen value must never be empty).

To carry out this deletion, we'll be calling the substring method from 0 to the current screen value length minus 1.

3.2. Numeric keys

The numeric keys are those keys containing numbers to operate with.

Every time a numeric key is clicked, the following function is called:

const handleClickNumericKey = value => {
   if (expectsOperand) {
      setScreenValue(String(value));
      setExpectsOperand(false);
   }
   else
      setScreenValue(screenValue === "0" ? String(value) : screenValue + value);
}

As done before, let's distinguish between these two scenarios:

If the calculator is waiting for an operand (this means that there's an ongoing operation), the value we're introducing will become the current screen value and we'll tell the calculator that it doesn't need to wait for another operand.

If the calculator isn't waiting for a new operand (this means there is an ongoing operand which we can keep adding digits to), it just appends the new digit to the current screen value if this one is not zero. Otherwise, the screen value will be overwritten.

And, in which cases can the screen value be zero? Well, when the calculator is at initial state, or after cleaning the screen or the stored values, for example.

3.3. Operator keys

The operator keys are those that represent arithmetic operations.

This is what happens when we click on an arbitrary operator key:

const handleClickOperator = operator => {
   const inputValue = parseFloat(screenValue);
   if (accValue === null) {
      setAccValue(inputValue);
   }
   else {
      if (currentOperator) {
         const resultValue = operate(currentOperator, accValue, inputValue);
      setAccValue(resultValue);
      setScreenValue(String(resultValue));
      }
   }
   setExpectsOperand(true);
   setCurrentOperator(operator);
}

How this function exactly works?

First things first and one step at a time.

First of all, we need to store the current screen value previously parsed to float in a constant so we can operate with it.

Then, we'll check if we already have an accumulated value or not.

If there's no accumulated value (we just entered the first operand), we'll set the state for it to this new input value.

Bear in mind that the accumulated value is only stored when an operator is pressed, not when a numeric key is pressed.

Otherwise, if we already have and accumulated value and there's also an operation going on (we just entered the second operand), then we can operate. After the proper operation is carried out, we'll assign the result value to the accumulated value and the screen value (previously parsed to string in this case).

In any case, we need to store the just-clicked operator for later and also tell the calculator to wait for another operand.

Here you have the operate function:

const operate = (operator, accValue, inputValue) => {
   switch (operator) {
      case "+": return accValue + inputValue;
      case "-": return accValue - inputValue;
      case "x": return accValue * inputValue;
      case "/": return accValue / inputValue;
      case "=": return inputValue;
   }
}

This function receives the stored operator, the accumulated value and the last screen value as parameters and, based on the operator value, a different operation, which includes the other two values, is performed .

Really easy, right?


And that's pretty much it! I hope you found this tutorial useful and don't hesitate to pose any doubts or questions you could have related to the tutorial and/or examples above.


馃帀 Don't forget to follow @underscorecode on Instagram and Twitter for more daily webdev content 馃枼 馃枻