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.
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.
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
- Set’s the background color
- 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. - 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.
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-right
→ marginRight
).
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.
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.
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.
// ...
// 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.
// ...
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.
// ...
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,
},
})