Auto Toggle Dark Theme on your React Native Application

Dark vs Light

In this article, I will show you how you can change the theme of your app depending on the time of the day. We will change the theme of the app depending on if the sun has set or risen.

Our Application

Note: We are using path aliases so ~ is the same as saying src/, this keeps the import paths cleaner. More information here #ShamelessPlug.

AutoTheme.tsx

However, we will only check the location once per day, we store the latitude and longitude locally on the device and if it within a day since it was set, then we use these locally stored values. However if the stored values are older than a day then we find the new latitude and longitude, use those and replace the old values with these new values.

The AutoTheme is a class, let’s take a look at the main function of the class

export default class AutoTheme {
private static oneDay = 24 * 60 * 60 * 1000;

public async shouldToggleDarkTheme() {
const currentTime = new Date(Date.now());
const {sunrise, sunset} = await this.getSunriseAndSunsetTime(currentTime);
let toggleTheme = true;

if (sunrise !== null && sunset !== null) {
if (currentTime > sunrise && currentTime < sunset) {
toggleTheme = false;
}
}

return toggleTheme;
}
// ...
}

The logic is fairly simple

  • Get the current time
  • Get the time the sun will rise/set
  • If the current time is between sunrise and sunset
  • then leave the theme light (return false, i.e. don’t toggle the theme to dark)
  • else
  • toggle the theme to dark

In the example below, we would toggle the dark theme on because the sun has already set for that day.

currentTime = 5.48pm
sunrise = 6.30am
sunset = 4.45pm

So how do we get the sunrise/sunset time ? First, we need to get the latitude and longitude. Then using the latitude and longitude we work out the sunset and sunrise times (for the current day). Sometimes with the sunset-sunrise library, it will show you the sunrise for the next day. If this is the case we simply remove a day from the sunrise date, so we’re always comparing the sunrise/sunset and current time on the same day.

private async getSunriseAndSunsetTime(currentTime: Date) {
const {latitude, longitude} = await this.getLatitudeLongitude();
let sunrise = getSunrise(latitude, longitude, currentTime);
const sunset = getSunset(latitude, longitude, currentTime);

if (sunrise > sunset) {
sunrise = new Date(sunset.getTime() - AutoTheme.oneDay);
}
return {sunset, sunrise};
}

As stated above we get the latitude-longitude data either from local storage (async storage), or we get completely new latitude-longitude data from the users’s current location. We check if the stored location is older than a day and if it is we get the user’s current location.

private async getLatitudeLongitude() {
const currentDate = new Date(Date.now());
const lastQueried = await AsyncStorage.getItem('@LastQueriedLocation');
let latitude: number;
let longitude: number;
let lastQueriedDate: Date;

if (lastQueried) {
lastQueriedDate = new Date(lastQueried);
} else {
lastQueriedDate = new Date(currentDate.getTime() - AutoTheme.oneDay);
}

if (currentDate.getTime() - lastQueriedDate.getTime() >= AutoTheme.oneDay) {
({latitude, longitude} = await this.getNewLatitudeLongitude(currentDate));
} else {
latitude = Number(await AsyncStorage.getItem('@Latitude'));
longitude = Number(await AsyncStorage.getItem('@Longitude'));
}

return {latitude, longitude};
}

The final function is used to get the user’s current location (latitude and longitude), we then store this current location in local storage (async storage), alongside the current date. This date is used to check later on if we need to get the user’s location again.

private async getNewLatitudeLongitude(currentDate: Date) {
let latitude;
let longitude;

const granted = await RNLocation.requestPermission({
ios: 'whenInUse',
android: {
detail: 'coarse',
},
});

if (granted) {
let location: Location | null;
try {
location = await RNLocation.getLatestLocation({timeout: 60000});
} catch {
Snackbar.show({
title: 'Failed to get location, please check it is turned on',
});
throw Error('No location found');
}

if (location !== null) {
latitude = location.latitude;
longitude = location.longitude;

await Promise.all([
AsyncStorage.setItem('@Latitude', JSON.stringify(latitude)),
AsyncStorage.setItem('@Longitude', JSON.stringify(longitude)),
AsyncStorage.setItem(
'@LastQueriedLocation',
JSON.stringify(currentDate),
),
]);
}
}

if (latitude === undefined || longitude === undefined) {
throw Error('No location found');
}

return {latitude, longitude};
}

ThemeContext.tsx

Context provides a way to pass data through the component tree without having to pass props down manually at every level. — https://reactjs.org/docs/context.html

In our case, we don’t want to have to pass the Theme to every component as a prop. So we store it in our a React context. Firstly, we define some types that will be used in our React context file, such as the light and dark theme constants.

import React, {Context, createContext, useState} from 'react';

type ThemeColors = '#17212D' | '#FFF';

interface ITheme {
background: ThemeColors;
color: ThemeColors;
isDark: boolean;
}

const LIGHT_THEME: ITheme = {
background: '#FFF' as ThemeColors,
color: '#17212D' as ThemeColors,
isDark: false,
};

const DARK_THEME: ITheme = {
background: '#17212D' as ThemeColors,
color: '#FFF' as ThemeColors,
isDark: true,
};

interface IThemeContext {
theme: ITheme;
changeTheme: (isDark: boolean) => void;
}

Next, we create our context with some default values. This then allows us to access the provider and consumer (ThemeContext.Provider);

  • Provider: The component that will provide the value of the context (stored).
  • Consumer: The component that will consume the value

Note: We will not use the consumer part in our app because we are accessing the value in other ways (React hooks).

const ThemeContext: Context<IThemeContext> = createContext({
changeTheme: (_: boolean) => {
return;
},
theme: LIGHT_THEME,
});

Now let’s define our provider.

const ThemeProvider: React.FC = ({children}) => {
const [themeState, setTheme] = useState({
theme: LIGHT_THEME,
});

const changeTheme = (isDark: boolean) => {
setTheme({
theme: isDark ? DARK_THEME : LIGHT_THEME,
});
};

return (
<ThemeContext.Provider
value={{
changeTheme,
theme: themeState.theme,
}}>
{children}
</ThemeContext.Provider>
);
};

The useState function is a React hook, which returns the current state themeState and function to change the state setTheme, in this case, we can pass theme (light theme as default) so that the state can only be a theme object, cannot change it to say 0.

const [themeState, setTheme] = useState({
theme: LIGHT_THEME,
});

Then we define the function that can change our theme, if isDark is true then the theme becomes dark else it becomes light.

const changeTheme = (isDark: boolean) => {
setTheme({
theme: isDark ? DARK_THEME : LIGHT_THEME,
});
};

Finally, we define the actual component for theme provider, it takes in any React component. This way any component surronded by the provider can access/change the app theme. We need to give the provider a function to change the value and the value itself.

return (
<ThemeContext.Provider
value={{
changeTheme,
theme: themeState.theme,
}}>
{children}
</ThemeContext.Provider>
);

App.tsx

import React from 'react';

import {ThemeProvider} from '~/providers/ThemeContext';
import MainApp from '~/MainApp';

export default class App extends React.Component<{}, {}> {
public render() {
return (
<ThemeProvider>
<MainApp />
</ThemeProvider>
);
}
}

MainApp.tsx

// Access Theme context within this React class.
public static contextType = ThemeContext;
public context!: React.ContextType<typeof ThemeContext>;

// Set default state for the class.
public state = {
autoTheme: false,
autoToggleTheme: new AutoTheme(),
};

public render() {
const theme = this.context.theme;
return (
<View style={{flex: 1, backgroundColor: theme.background}}>
<Header />
<ListItem
containerStyle={{
backgroundColor: theme.background,
}}
topDivider={true}
bottomDivider={true}
titleStyle={{color: theme.color}}
title="Auto Toggle Dark Theme"
switch={{
onValueChange: this.autoTheme.bind(this, !this.state.autoTheme),
thumbColor: 'white',
trackColor: {false: 'gray', true: 'blue'},
value: this.state.autoTheme,
}}
/>

<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Text style={{color: theme.color, fontSize: 30}}>
Current Theme: {this.context.theme.isDark ? 'Dark' : 'Light'}
</Text>
</View>
</View>
);
}

// ...
Image for post
Image for post
Main Page

The theme is changed using the line this.context.changeTheme(isDark); essentially sets the theme for the app. We can then do something like this.context.theme.color to get the current colour or this.context.theme.background to get the background colour the app should be using.

// Called when the switch is toggled
private autoTheme = async (value: boolean) => {
this.setState({autoTheme: value});
let isDark = false;
if (value) {
isDark = await new AutoTheme().shouldToggleDarkTheme();
}

this.context.changeTheme(isDark);
};

// ...

The other key function is this one, where we listen for when the app goes from background to the foreground, if this happens we then call the auto theme module and check if we should toggle the theme, say you do this between sunsets. You background the app at 6.58 PM, the sunsets at 7.0 2PM and you foreground the app at 7.04 PM then when the user returns this.context.changeTheme(true) will be called like this (true) and then the values returned by this.context.theme would change to the dark theme.

import {..., AppState} from 'react-native';

public async componentDidMount() {
AppState.addEventListener('change', this.appInFocus);
}

public componentWillUnmount() {
AppState.removeEventListener('change', this.appInFocus);
}

private appInFocus = async (nextAppState: any) => {
if (nextAppState === 'active' && this.state.autoTheme) {
const isDark = await this.state.autoToggleTheme.shouldToggleDarkTheme();
this.context.changeTheme(isDark);
}
};

Header.tsx

The context allows us have a global state throughout our app and the hooks allow us to access this state without needing to turn our components into a class. Though as you have seen we can also access the context within our React classes.

import {Header as ElementsHeader} from 'react-native-elements';

import logoDark from '~/assets/images/logo-dark.png';
import logoLight from '~/assets/images/logo-light.png';
import {ThemeContext} from '~/providers/ThemeContext';

const Header = () => {
const {background, color, isDark} = useContext(ThemeContext).theme;

return (
<ElementsHeader
containerStyle={{backgroundColor: background}}
centerComponent={
<View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
<Text style={{color}}>Example</Text>
<Image
source={isDark ? logoLight : logoDark}
style={{height: 25, width: 25}}
/>
<Text style={{color}}>App</Text>
</View>
}
/>
);
};

Run the app

git clone git@gitlab.com:hmajid2301/medium.git
cd "medium/19. Theme your React Native app/ExampleApp"
yarn
yarn run start

# Then in another terminal
yarn run android

Example App

Image for post
Image for post
App Demo

Appendix

Written by

Software Engineer | Pythonista | Typescripter | Docker Advocate | https://haseebmajid.dev

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store