Updated April 11, 2022

Building an iOS Calculator Clone with React Native

Today we’ll be building a clone of the iOS calculator (portrait only). It brings some unique challenges that allow us to leverage Flexbox and other styling properties to build it properly.

This tutorial will help you build reusable components and introduce you to a variety of APIs, components, and strategies in React Native development.

Final calculator

Today we’re just working on the layout. If you’d like to cover building the calculator functionality send us an email - feedback@reactnativeschool.com.

To get started created a new React Native project - I’ll be using Expo but the React Native CLI works fine as well. I’ll also be using TypeScript in this tutorial but that’s not required.

expo init CalculatorApp

Background + StatusBar + SafeAreaView

First let’s build a solid foundation.

App with background color

import React from "react"
import { StatusBar } from "expo-status-bar"
import { StyleSheet, View, SafeAreaView } from "react-native"

export default function App() {
  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView></SafeAreaView>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#202020",
    justifyContent: "flex-end",
  },
})

This code does a few things

  1. Set’s the background color
  2. Adjusts the StatusBar (that’s the part where the time, battery, and other information is displayed). This core component (part of React Native or Expo) lets us change the color to be visible on light and dark backgrounds.
  3. Finally we add a SafeAreaView, a core React Native component, that automatically ensures that our content doesn’t get hidden behind any notches or system UI elements.

One important thing to note in our code is the justifyContent: "flex-end". This will put that content at the bottom of the screen since that’s the end of our flex area.

Styling Text in React Native

Now let’s style the computed value.

Styled computed text

import React from "react"
import { StatusBar } from "expo-status-bar"
import { StyleSheet, View, SafeAreaView, Text } from "react-native"

export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>
      </SafeAreaView>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#202020",
    justifyContent: "flex-end",
  },

  computedValue: {
    color: "#fff",
    fontSize: 40,
    textAlign: "right",
    marginRight: 20,
    marginBottom: 10,
  },
})

As you can see from our StyleSheet a style is simply a JavaScript object of values. These properties map pretty much one-to-one to normal CSS with the exception kebab-case becomes camelCase (margin-rightmarginRight).

We’re also using toLocaleString() on our number so that we format it correctly for the user’s language.

Row Component

Each of our buttons are displayed in a row. Since this isn’t the default behavior for React Native Flexbox we need to style each row to use flexDirection: "row".

I’m creating a custom Row component because typing <Row> is less that <View style={styles.row}> multiple times.

import React from "react"
import { StatusBar } from "expo-status-bar"
import { StyleSheet, View, SafeAreaView, Text } from "react-native"

const Row = ({ children }: { children: any }) => (
  <View style={styles.row}>{children}</View>
)

export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row></Row>
        <Row></Row>
        <Row></Row>
        <Row></Row>
        <Row></Row>
      </SafeAreaView>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#202020",
    justifyContent: "flex-end",
  },

  computedValue: {
    color: "#fff",
    fontSize: 40,
    textAlign: "right",
    marginRight: 20,
    marginBottom: 10,
  },

  row: {
    flexDirection: "row",
  },
})

Creating a Custom Button Component

Now let’s create a Button component to fill in our Row components with. We’ll worry about styling in a moment.

Basic unstyled buttons

import React from "react"
import { StatusBar } from "expo-status-bar"
import {
  StyleSheet,
  View,
  SafeAreaView,
  Text,
  TouchableOpacity,
} from "react-native"

const Row = ({ children }: { children: any }) => (
  <View style={styles.row}>{children}</View>
)

// ADDED
interface IButton {
  value: string
}
const Button = ({ value }: IButton) => {
  const btnStyles: any[] = []
  const txtStyles: any[] = [styles.btnText]

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

// BUTTONS ADDED
export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row>
          <Button value="C" />
          <Button value="+/-" />
          <Button value="%" />
          <Button value="/" />
        </Row>
        <Row>
          <Button value="7" />
          <Button value="8" />
          <Button value="9" />
          <Button value="x" />
        </Row>
        <Row>
          <Button value="4" />
          <Button value="5" />
          <Button value="6" />
          <Button value="-" />
        </Row>
        <Row>
          <Button value="1" />
          <Button value="2" />
          <Button value="3" />
          <Button value="+" />
        </Row>
        <Row>
          <Button value="0" />
          <Button value="." />
          <Button value="=" />
        </Row>
      </SafeAreaView>
    </View>
  )
}

const styles = StyleSheet.create({
  // ...

  // STYLED ADDED
  btnText: {
    color: "#fff",
    fontSize: 25,
    fontWeight: "500",
  },
})

We use the TouchableOpacity component to make it tappable and pass a value prop in, which we display in a Text component.

Standard Button Styles

Now styling. A bulk of our buttons use a grey background so we’ll use that as our default styles.

Default button styles

import React from "react"
import { StatusBar } from "expo-status-bar"
import {
  StyleSheet,
  View,
  SafeAreaView,
  Text,
  TouchableOpacity,
  Dimensions,
} from "react-native"

const Row = ({ children }: { children: any }) => (
  <View style={styles.row}>{children}</View>
)

interface IButton {
  value: string
}
const Button = ({ value }: IButton) => {
  const btnStyles: any[] = [styles.btn]
  const txtStyles: any[] = [styles.btnText]

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

export default function App() {
  // ...
}

const BTN_MARGIN = 5

const screen = Dimensions.get("window")

// Most rows have 4 buttons with a margin on either side
const buttonWidth = screen.width / 4 - BTN_MARGIN * 2

const styles = StyleSheet.create({
  // ...

  btnText: {
    color: "#fff",
    fontSize: 25,
    fontWeight: "500",
  },

  btn: {
    backgroundColor: "#333333",
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    margin: BTN_MARGIN,
    borderRadius: 100,
    height: buttonWidth,
  },
})

This code may not look how you expected it to... why did we define a buttonWidth but not using it for the button’s width?

This is to make the button a perfect circle. I’m letting Flexbox figure out the width of the circle and then using that button’s width (or an approximation) to determine the height of the button.

The code has comments on how/why the value is computed the way it is.

Though more complicated, this is more accurate than just using a static value. It also allows us to change our button text size without throwing off the dimensions of the button itself.

Customizing the Button via Props

Not all of our buttons look the same and we can use a prop to determine which styling to use.

Secondary styles for button

// ...

// ADDED STYLE
interface IButton {
  value: string
  style?: "secondary"
}
const Button = ({ value, style }: IButton) => {
  const btnStyles: any[] = [styles.btn]
  const txtStyles: any[] = [styles.btnText]

  if (style === "secondary") {
    btnStyles.push(styles.btnSecondary)
    txtStyles.push(styles.btnTextSecondary)
  }

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

// ADDED STYLE TO COMPONENT PROPS
export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row>
          <Button value="C" style="secondary" />
          <Button value="+/-" style="secondary" />
          <Button value="%" style="secondary" />
          <Button value="/" />
        </Row>
        <Row>
          <Button value="7" />
          <Button value="8" />
          <Button value="9" />
          <Button value="x" />
        </Row>
        <Row>
          <Button value="4" />
          <Button value="5" />
          <Button value="6" />
          <Button value="-" />
        </Row>
        <Row>
          <Button value="1" />
          <Button value="2" />
          <Button value="3" />
          <Button value="+" />
        </Row>
        <Row>
          <Button value="0" />
          <Button value="." />
          <Button value="=" />
        </Row>
      </SafeAreaView>
    </View>
  )
}

const BTN_MARGIN = 5

const screen = Dimensions.get("window")

// Most rows have 4 buttons with a margin on either side
const buttonWidth = screen.width / 4 - BTN_MARGIN * 2

const styles = StyleSheet.create({
  // ...

  btnText: {
    color: "#fff",
    fontSize: 25,
    fontWeight: "500",
  },

  btn: {
    backgroundColor: "#333333",
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    margin: BTN_MARGIN,
    borderRadius: 100,
    height: buttonWidth,
  },

  btnSecondary: {
    backgroundColor: "#a6a6a6",
  },
  btnTextSecondary: {
    color: "#060606",
  },
})

You can see the usage of the btnStyles and btnTextStyles arrays here. If we add another style onto our array that’s going to inherit the base styles and then override the properties with the latest element of the array.

That means we only need to define the properties we want to override in our secondary styles.

Accent Button Styles

Exact same process as the secondary style to make the accent colors on the action buttons.

Accent button styles

// ...

interface IButton {
  value: string
  style?: "secondary" | "accent"
}
const Button = ({ value, style }: IButton) => {
  const btnStyles: any[] = [styles.btn]
  const txtStyles: any[] = [styles.btnText]

  if (style === "secondary") {
    btnStyles.push(styles.btnSecondary)
    txtStyles.push(styles.btnTextSecondary)
  }

  if (style === "accent") {
    btnStyles.push(styles.btnAccent)
  }

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

// ADDED COMPONENT PROP
export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row>
          <Button value="C" style="secondary" />
          <Button value="+/-" style="secondary" />
          <Button value="%" style="secondary" />
          <Button value="/" style="accent" />
        </Row>
        <Row>
          <Button value="7" />
          <Button value="8" />
          <Button value="9" />
          <Button value="x" style="accent" />
        </Row>
        <Row>
          <Button value="4" />
          <Button value="5" />
          <Button value="6" />
          <Button value="-" style="accent" />
        </Row>
        <Row>
          <Button value="1" />
          <Button value="2" />
          <Button value="3" />
          <Button value="+" style="accent" />
        </Row>
        <Row>
          <Button value="0" />
          <Button value="." />
          <Button value="=" style="accent" />
        </Row>
      </SafeAreaView>
    </View>
  )
}

const BTN_MARGIN = 5

const screen = Dimensions.get("window")

// Most rows have 4 buttons with a margin on either side
const buttonWidth = screen.width / 4 - BTN_MARGIN * 2

const styles = StyleSheet.create({
  // ...

  btnText: {
    color: "#fff",
    fontSize: 25,
    fontWeight: "500",
  },

  btn: {
    backgroundColor: "#333333",
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    margin: BTN_MARGIN,
    borderRadius: 100,
    height: buttonWidth,
  },

  btnSecondary: {
    backgroundColor: "#a6a6a6",
  },
  btnTextSecondary: {
    color: "#060606",
  },

  btnAccent: {
    backgroundColor: "#f09a36",
  },
})

Extra Wide Button

The 0 button is unique in the iOS calculator. To accomplish this we need to override extra properties on the button.

Extra wide button styles

// ...

interface IButton {
  value: string
  style?: "secondary" | "accent" | "double"
}
const Button = ({ value, style }: IButton) => {
  const btnStyles: any[] = [styles.btn]
  const txtStyles: any[] = [styles.btnText]

  if (style === "secondary") {
    btnStyles.push(styles.btnSecondary)
    txtStyles.push(styles.btnTextSecondary)
  }

  if (style === "accent") {
    btnStyles.push(styles.btnAccent)
  }

  if (style === "double") {
    btnStyles.push(styles.btnDouble)
  }

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row>
          <Button value="C" style="secondary" />
          <Button value="+/-" style="secondary" />
          <Button value="%" style="secondary" />
          <Button value="/" style="accent" />
        </Row>
        <Row>
          <Button value="7" />
          <Button value="8" />
          <Button value="9" />
          <Button value="x" style="accent" />
        </Row>
        <Row>
          <Button value="4" />
          <Button value="5" />
          <Button value="6" />
          <Button value="-" style="accent" />
        </Row>
        <Row>
          <Button value="1" />
          <Button value="2" />
          <Button value="3" />
          <Button value="+" style="accent" />
        </Row>
        <Row>
          <Button value="0" style="double" />
          <Button value="." />
          <Button value="=" style="accent" />
        </Row>
      </SafeAreaView>
    </View>
  )
}

const BTN_MARGIN = 5

const screen = Dimensions.get("window")

// Most rows have 4 buttons with a margin on either side
const buttonWidth = screen.width / 4 - BTN_MARGIN * 2

const styles = StyleSheet.create({
  // ...

  btnDouble: {
    alignItems: "flex-start",
    flex: 0,
    // We're taking the place of two buttons their margin so we need
    // to factor the margin into the width. First buttons right margin +
    // second buttons left margin, thus the * 2.
    width: buttonWidth * 2 + BTN_MARGIN * 2,
    // Half the button's width puts it roughly in the middle of the button.
    // We then subtract a little more to make it look better.
    paddingLeft: buttonWidth / 2 - BTN_MARGIN * 1.5,
  },
})

You can see that this time we define a width. To ensure that our custom width is used we set flex to 0 meaning that it won’t fill the space equally with our other flex items.

A few notes on how/why the width and padding our what they are exist in the btnDouble style. This is a good practice so that when you need to work on this in the future you know what you were thinking!

Final Code

Our who calculator fits into one file! Here’s the final code.

import React from "react"
import { StatusBar } from "expo-status-bar"
import {
  StyleSheet,
  View,
  SafeAreaView,
  Text,
  TouchableOpacity,
  Dimensions,
} from "react-native"

const Row = ({ children }: { children: any }) => (
  <View style={styles.row}>{children}</View>
)

interface IButton {
  value: string
  style?: "secondary" | "accent" | "double"
}
const Button = ({ value, style }: IButton) => {
  const btnStyles: any[] = [styles.btn]
  const txtStyles: any[] = [styles.btnText]

  if (style === "secondary") {
    btnStyles.push(styles.btnSecondary)
    txtStyles.push(styles.btnTextSecondary)
  }

  if (style === "accent") {
    btnStyles.push(styles.btnAccent)
  }

  if (style === "double") {
    btnStyles.push(styles.btnDouble)
  }

  return (
    <TouchableOpacity style={btnStyles} onPress={() => console.log(value)}>
      <Text style={txtStyles}>{value}</Text>
    </TouchableOpacity>
  )
}

export default function App() {
  const computedValue = 123456.23

  return (
    <View style={styles.container}>
      <StatusBar style="light" />
      <SafeAreaView>
        <Text style={styles.computedValue}>
          {computedValue.toLocaleString()}
        </Text>

        <Row>
          <Button value="C" style="secondary" />
          <Button value="+/-" style="secondary" />
          <Button value="%" style="secondary" />
          <Button value="/" style="accent" />
        </Row>
        <Row>
          <Button value="7" />
          <Button value="8" />
          <Button value="9" />
          <Button value="x" style="accent" />
        </Row>
        <Row>
          <Button value="4" />
          <Button value="5" />
          <Button value="6" />
          <Button value="-" style="accent" />
        </Row>
        <Row>
          <Button value="1" />
          <Button value="2" />
          <Button value="3" />
          <Button value="+" style="accent" />
        </Row>
        <Row>
          <Button value="0" style="double" />
          <Button value="." />
          <Button value="=" style="accent" />
        </Row>
      </SafeAreaView>
    </View>
  )
}

const BTN_MARGIN = 5

const screen = Dimensions.get("window")

// Most rows have 4 buttons with a margin on either side
const buttonWidth = screen.width / 4 - BTN_MARGIN * 2

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#202020",
    justifyContent: "flex-end",
  },

  computedValue: {
    color: "#fff",
    fontSize: 40,
    textAlign: "right",
    marginRight: 20,
    marginBottom: 10,
  },

  row: {
    flexDirection: "row",
  },

  btnText: {
    color: "#fff",
    fontSize: 25,
    fontWeight: "500",
  },

  btn: {
    backgroundColor: "#333333",
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    margin: BTN_MARGIN,
    borderRadius: 100,
    height: buttonWidth,
  },

  btnSecondary: {
    backgroundColor: "#a6a6a6",
  },
  btnTextSecondary: {
    color: "#060606",
  },

  btnAccent: {
    backgroundColor: "#f09a36",
  },

  btnDouble: {
    alignItems: "flex-start",
    flex: 0,
    // We're taking the place of two buttons their margin so we need
    // to factor the margin into the width. First buttons right margin +
    // second buttons left margin, thus the * 2.
    width: buttonWidth * 2 + BTN_MARGIN * 2,
    // Half the button's width puts it roughly in the middle of the button.
    // We then subtract a little more to make it look better.
    paddingLeft: buttonWidth / 2 - BTN_MARGIN * 1.5,
  },
})
React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our courses and our private Slack community.

Learn More