React Native Background Timer mit Expo

1. Januar 2021

Ein Weg zum Background-Timer mit bzw. trotz Expo.

Wenn du ejecten kannst / willst, dann nutzt du am besten das package React Native Background Timer (https://github.com/ocetnik/react-native-background-timer). Wenn du aber nicht ejecten willst, gibt’s da einen kleinen Trick! Neben der BackgroundFetch API von Expo (https://docs.expo.io/versions/latest/sdk/background-fetch/), können wir anhand vom AppState simulieren, dass unser Timer im Hintergrund weiterläuft. Die BackgroundFetch API von Expo habe ich nicht getestet, habe aber gelesen, dass die background-tasks nicht zuverlässig im angebenen Intervall erledigt werden.

Kommen wir zum „Trick“ mit dem AppState (danke für die Idee auch an https://aloukissas.medium.com/how-to-build-a-background-timer-in-expo-react-native-without-ejecting-ea7d67478408 ):

import { useEffect, useState, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';

// die Sekunden die wir im Hintergrund weiter laufen lassen wollen
const [seconds, setSeconds] = useState(0);

// unser AppState 
const appState = useRef(AppState.currentState);

useEffect(() => {
    AppState.addEventListener('change', handleAppStateChange);
    return () => AppState.removeEventListener('change', handleAppStateChange);
}, []);

Im useEffect Hook attachen wir einen Listener, welchen wir – wenn unsere Komponente unmounted – wieder enfernen. Kommen wir zur handleAppStateChange Funktion:

const handleAppStateChange = async (nextAppState: AppStateStatus) => {
    if (appState.current === 'background' && nextAppState === 'active') {
        // Unsere App ist wieder im Vordergrund

        // kalkulieren wir die bisher vergangenen Sekunden
        const elapsedSeconds = await getElapsedSeconds();

        // unseren State updaten
        setSeconds(elapsedSeconds)
    }

    // unseren ref-value auf den aktuellen AppState setzen
    appState.current = nextAppState;
};

Cool, wir sehen jetzt, wann unsere App in den Vordergrund kommt bzw. in den Hintergrund geht! Unsere getElapsedSeconds Funktion holt sich den Startzeitpunkt des Timers aus dem AsyncStorage (du kannst natürlich den Storage deiner Wahl nutzen), welchen du beim Start des Timers speicherst und berechnet die seitdem vergangene Zeit (ich nutze dafür dayjs – https://day.js.org/ ):

const getElapsedSeconds = async () => {
    try {
        const startTime = await AsyncStorage.getItem("timer-start");
        const now = dayjs();
        
        // Differenz in Sekunden
        return now.diff(startTime, 's');
    } catch (err) {
        // Fehler beim Lesen aus dem AsyncStorage
        console.warn(err);
    }
};

Ein bisschen komplexer wird es, wenn du auch eine Pause Funktion anbieten willst. Setze jedes mal eine neue Startzeit, wenn der Timer pausiert wird oder die App in den Hintergrund geht und berechne anhand dieser die bisher vergangenen Sekunden. Denk dann aber daran, dass du beim State-Update die bisherigen Sekunden dazu addierst. Dazu passen wir unsere Funktionen getElapsedSeconds & handleAppStateChange wie folgt an:

const [pausedAt, setPausedAt] = useState(null);

const getElapsedSeconds = async () => {
    try {
        // gibt es eine neue Startzeit "pausedAt"? Dann nehme diese, ansonsten wie gehabt
        const startTime = pausedAt ?? await AsyncStorage.getItem("timer-start");
        const now = dayjs();
        
        // Differenz in Sekunden
        return now.diff(startTime, 's');
    } catch (err) {
        // Fehler beim Lesen aus dem AsyncStorage
        console.warn(err);
    }
};

const handleAppStateChange = async (nextAppState: AppStateStatus) => {
    if (appState.current === 'background' && nextAppState === 'active') {
        // Unsere App ist wieder im Vordergrund

        // kalkulieren wir die bisher vergangenen Sekunden
        const elapsedSeconds = await getElapsedSeconds();

        // unseren State updaten
        setSeconds((seconds) => {
            // falls wir einen neuen Startzeitpunkt haben, addieren wir die bisherigen Sekunden
            // mit den vergangenen
            if (pausedAt) return elapsedSeconds + seconds;

            // ansonsten wie gehabt
            return elapsedSeconds;
        });

        // Wenn der Timer pausiert wird, musst du ebenfalls einen neuen Startzeitpunkt setzen
        // das machst du natürlich bei deinem Timer

        // Falls die App in den Hintergrund geht, setzen wir einen neuen Startzeitpunkt
        if (appState.current === 'active' && nextAppState === 'background') {
            setPausedAt(dayjs());
        }
    }

    // unseren ref-value auf den aktuellen AppState setzen
    appState.current = nextAppState;
};

Ich hoffe die Kommentare sind selbsterklärend. Falls du Rückfragen hast, meld dich gerne! Hier ist übrigens mein Timer:

// useStopWatch.ts
import dayjs from 'dayjs';
import { useState, useEffect, useRef } from 'react';
import { sToTime } from '../utils';

type TControls = {
    start: () => void;
    pause: () => void;
    resume: () => void;
    stop: () => void;
};

export function useStopWatch(): {
    controls: TControls;
    time: string;
    running: boolean;
    seconds: number;
} {
    const [started, setStarted] = useState(false);
    const [seconds, setSeconds] = useState(0);
    const [pausedAt, setPausedAt] = useState(null);

    const intervalTimer = useRef();

    // handle our timer
    useEffect(() => {
        if (started) {
            intervalTimer.current = window.setInterval(
                () => setSeconds((seconds) => seconds + 1),
                1000
            );
        } else {
            window.clearInterval(intervalTimer.current);
        }

        return () => window.clearInterval(intervalTimer.current);
    }, [started]);

    const controls: TControls = {
        start: () => setStarted(true),
        
        pause: () => {
            setStarted(false);

            // Hier setze ich den neuen Startzeitpunkt
            setPausedAt(dayjs());
        },

        resume: () => setStarted(true),

        stop: () => {
            setStarted(false);
            setSeconds(0);
        },
    };

    // gibt die Zeit im Format hh:mm:ss zurück
    const time = sToTime(seconds);

    return { controls, time, running: started, seconds };
}
zurück zu allen Beiträgen