Files
jrshikoku/components/StartupOverlay.tsx

145 lines
3.9 KiB
TypeScript

import * as SplashScreen from "expo-splash-screen";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform, StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from "react-native-reanimated";
const SPLASH_IMAGE = require("../assets/splash.png");
const SPLASH_BACKGROUND_COLOR = "#00b8ff";
const SPLASH_IMAGE_WIDTH = 100;
const SPLASH_IMAGE_ASPECT_RATIO = 1242 / 2436;
const EXIT_ANIMATION_DURATION = 320;
const FALLBACK_DELAY = 900;
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
// Keep the native splash visible until the React overlay has been mounted.
// This must run at module scope so the native splash is not hidden too early.
if (Platform.OS !== "web") {
void SplashScreen.preventAutoHideAsync().catch(() => {
// The native splash may already be hidden during a reload.
});
}
export default function StartupOverlay() {
const reducedMotion = useReducedMotion();
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const [visible, setVisible] = useState(Platform.OS !== "web");
const hasStarted = useRef(false);
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrame = useRef<number | null>(null);
const finish = useCallback(() => {
if (Platform.OS === "web" || hasStarted.current) return;
hasStarted.current = true;
if (fallbackTimer.current !== null) {
clearTimeout(fallbackTimer.current);
fallbackTimer.current = null;
}
animationFrame.current = requestAnimationFrame(() => {
animationFrame.current = null;
try {
SplashScreen.hide();
} catch {
// The splash can already be hidden after a native reload.
}
if (reducedMotion) {
opacity.set(0);
setVisible(false);
return;
}
opacity.set(
withTiming(0, {
duration: EXIT_ANIMATION_DURATION,
easing: EASE_OUT,
}),
);
scale.set(
withTiming(1.06, {
duration: EXIT_ANIMATION_DURATION,
easing: EASE_OUT,
}),
);
animationTimer.current = setTimeout(() => {
setVisible(false);
animationTimer.current = null;
}, EXIT_ANIMATION_DURATION + 40);
});
}, [opacity, reducedMotion, scale]);
useEffect(() => {
if (Platform.OS === "web") return;
// A local bundled image normally fires onLoadEnd immediately. The
// fallback keeps a broken image from leaving the native splash forever.
fallbackTimer.current = setTimeout(finish, FALLBACK_DELAY);
return () => {
if (fallbackTimer.current !== null) {
clearTimeout(fallbackTimer.current);
}
if (animationTimer.current !== null) {
clearTimeout(animationTimer.current);
}
if (animationFrame.current !== null) {
cancelAnimationFrame(animationFrame.current);
}
};
}, [finish]);
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.get(),
}));
const imageStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.get() }],
}));
if (!visible) return null;
return (
<Animated.View
pointerEvents="none"
style={[styles.overlay, overlayStyle]}
>
<Animated.Image
source={SPLASH_IMAGE}
resizeMode="contain"
fadeDuration={0}
onLoadEnd={finish}
onError={finish}
style={[styles.image, imageStyle]}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
bottom: 0,
left: 0,
position: "absolute",
right: 0,
top: 0,
alignItems: "center",
backgroundColor: SPLASH_BACKGROUND_COLOR,
justifyContent: "center",
zIndex: 9999,
},
image: {
aspectRatio: SPLASH_IMAGE_ASPECT_RATIO,
width: SPLASH_IMAGE_WIDTH,
},
});