bsky-app/src/Splash.tsx

279 lines
8.4 KiB
TypeScript

import React, {useCallback, useEffect} from 'react'
import {
View,
StyleSheet,
Image as RNImage,
AccessibilityInfo,
useColorScheme,
} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {Image} from 'expo-image'
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
} from 'react-native-reanimated'
import MaskedView from '@react-native-masked-view/masked-view'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
import splashImagePointer from '../assets/splash.png'
// @ts-ignore
import darkSplashImagePointer from '../assets/splash-dark.png'
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
).uri
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
const width = 1000
const height = width * (67 / 64)
return (
<Svg
fill="none"
// @ts-ignore it's fiiiiine
ref={ref}
viewBox="0 0 64 66"
style={[{width, height}, props.style]}>
<Path
fill={props.fill || '#fff'}
d="M13.873 3.77C21.21 9.243 29.103 20.342 32 26.3v15.732c0-.335-.13.043-.41.858-1.512 4.414-7.418 21.642-20.923 7.87-7.111-7.252-3.819-14.503 9.125-16.692-7.405 1.252-15.73-.817-18.014-8.93C1.12 22.804 0 8.431 0 6.488 0-3.237 8.579-.18 13.873 3.77ZM50.127 3.77C42.79 9.243 34.897 20.342 32 26.3v15.732c0-.335.13.043.41.858 1.512 4.414 7.418 21.642 20.923 7.87 7.111-7.252 3.819-14.503-9.125-16.692 7.405 1.252 15.73-.817 18.014-8.93C62.88 22.804 64 8.431 64 6.488 64-3.237 55.422-.18 50.127 3.77Z"
/>
</Svg>
)
})
type Props = {
isReady: boolean
}
const AnimatedLogo = Animated.createAnimatedComponent(Logo)
export function Splash(props: React.PropsWithChildren<Props>) {
const insets = useSafeAreaInsets()
const intro = useSharedValue(0)
const outroLogo = useSharedValue(0)
const outroApp = useSharedValue(0)
const outroAppOpacity = useSharedValue(0)
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
const [isImageLoaded, setIsImageLoaded] = React.useState(false)
const [isLayoutReady, setIsLayoutReady] = React.useState(false)
const [reduceMotion, setReduceMotion] = React.useState<boolean | undefined>(
false,
)
const isReady =
props.isReady &&
isImageLoaded &&
isLayoutReady &&
reduceMotion !== undefined
const colorScheme = useColorScheme()
const isDarkMode = colorScheme === 'dark'
const logoAnimation = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(intro.value, [0, 1], [0.8, 1], 'clamp'),
},
{
scale: interpolate(
outroLogo.value,
[0, 0.08, 1],
[1, 0.8, 500],
'clamp',
),
},
],
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const bottomLogoAnimation = useAnimatedStyle(() => {
return {
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const reducedLogoAnimation = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(intro.value, [0, 1], [0.8, 1], 'clamp'),
},
],
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const logoWrapperAnimation = useAnimatedStyle(() => {
return {
opacity: interpolate(
outroAppOpacity.value,
[0, 0.1, 0.2, 1],
[1, 1, 0, 0],
'clamp',
),
}
})
const appAnimation = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(outroApp.value, [0, 1], [1.1, 1], 'clamp'),
},
],
opacity: interpolate(
outroAppOpacity.value,
[0, 0.1, 0.2, 1],
[0, 0, 1, 1],
'clamp',
),
}
})
const onFinish = useCallback(() => setIsAnimationComplete(true), [])
const onLayout = useCallback(() => setIsLayoutReady(true), [])
const onLoadEnd = useCallback(() => setIsImageLoaded(true), [])
useEffect(() => {
if (isReady) {
SplashScreen.hideAsync()
.then(() => {
intro.value = withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
// set these values to check animation at specific point
// outroLogo.value = 0.1
// outroApp.value = 0.1
outroLogo.value = withTiming(
1,
{duration: 1200, easing: Easing.in(Easing.cubic)},
() => {
runOnJS(onFinish)()
},
)
outroApp.value = withTiming(1, {
duration: 1200,
easing: Easing.inOut(Easing.cubic),
})
outroAppOpacity.value = withTiming(1, {
duration: 1200,
easing: Easing.in(Easing.cubic),
})
},
)
})
.catch(() => {})
}
}, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady])
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
}, [])
const logoAnimations =
reduceMotion === true ? reducedLogoAnimation : logoAnimation
// special off-spec color for dark mode
const logoBg = isDarkMode ? '#0F1824' : '#fff'
return (
<View style={{flex: 1}} onLayout={onLayout}>
{!isAnimationComplete && (
<View style={StyleSheet.absoluteFillObject}>
<Image
accessibilityIgnoresInvertColors
onLoadEnd={onLoadEnd}
source={{uri: isDarkMode ? darkSplashImageUri : splashImageUri}}
style={StyleSheet.absoluteFillObject}
/>
<Animated.View
style={[
bottomLogoAnimation,
{
position: 'absolute',
bottom: insets.bottom + 40,
left: 0,
right: 0,
alignItems: 'center',
justifyContent: 'center',
opacity: 0,
},
]}>
<Logotype fill="#fff" width={90} />
</Animated.View>
</View>
)}
{isReady &&
(isAndroid || reduceMotion === true ? (
// Use a simple fade on older versions of android (work around a bug)
<>
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
{!isAnimationComplete && (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
logoWrapperAnimation,
{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo
fill={logoBg}
style={[{opacity: 0}, logoAnimations]}
/>
</Animated.View>
)}
</>
) : (
<MaskedView
style={[StyleSheet.absoluteFillObject]}
maskElement={
<Animated.View
style={[
{
// Transparent background because mask is based off alpha channel.
backgroundColor: 'transparent',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo fill={logoBg} style={[logoAnimations]} />
</Animated.View>
}>
{!isAnimationComplete && (
<View
style={[
StyleSheet.absoluteFillObject,
{
backgroundColor: logoBg,
},
]}
/>
)}
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
</MaskedView>
))}
</View>
)
}