145 lines
3.9 KiB
TypeScript
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,
|
|
},
|
|
});
|