121 lines
2.9 KiB
TypeScript
121 lines
2.9 KiB
TypeScript
|
|
import { createContext, useContext, useEffect } from "react"
|
||
|
|
import { StyleProp, View, ViewStyle } from "react-native"
|
||
|
|
import Animated, {
|
||
|
|
SharedValue,
|
||
|
|
useAnimatedStyle,
|
||
|
|
useSharedValue,
|
||
|
|
withRepeat,
|
||
|
|
withTiming,
|
||
|
|
Easing,
|
||
|
|
interpolate,
|
||
|
|
} from "react-native-reanimated"
|
||
|
|
|
||
|
|
import { useAppTheme } from "@/theme/context"
|
||
|
|
import { s } from "@/utils/responsive"
|
||
|
|
|
||
|
|
// Shared animation context for synchronized pulse
|
||
|
|
const PulseContext = createContext<SharedValue<number> | null>(null)
|
||
|
|
|
||
|
|
export interface SkeletonProps {
|
||
|
|
/**
|
||
|
|
* Width of the skeleton. Can be number or percentage string.
|
||
|
|
* @default "100%"
|
||
|
|
*/
|
||
|
|
width?: number | `${number}%`
|
||
|
|
/**
|
||
|
|
* Height of the skeleton.
|
||
|
|
* @default 16
|
||
|
|
*/
|
||
|
|
height?: number
|
||
|
|
/**
|
||
|
|
* Border radius. Use "round" for circle.
|
||
|
|
* @default 4
|
||
|
|
*/
|
||
|
|
radius?: number | "round"
|
||
|
|
/**
|
||
|
|
* Style overrides
|
||
|
|
*/
|
||
|
|
style?: StyleProp<ViewStyle>
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A skeleton placeholder with pulse animation.
|
||
|
|
* When used inside SkeletonContainer, all skeletons animate in sync.
|
||
|
|
*/
|
||
|
|
export function Skeleton(props: SkeletonProps) {
|
||
|
|
const { width = "100%", height = s(16), radius = s(4), style } = props
|
||
|
|
const { theme } = useAppTheme()
|
||
|
|
|
||
|
|
// Use shared animation from context, or create own
|
||
|
|
const sharedProgress = useContext(PulseContext)
|
||
|
|
const localProgress = useSharedValue(0)
|
||
|
|
const progress = sharedProgress || localProgress
|
||
|
|
|
||
|
|
// Only start local animation if not using shared context
|
||
|
|
useEffect(() => {
|
||
|
|
if (!sharedProgress) {
|
||
|
|
localProgress.value = withRepeat(
|
||
|
|
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||
|
|
-1,
|
||
|
|
true,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}, [sharedProgress, localProgress])
|
||
|
|
|
||
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
||
|
|
opacity: interpolate(progress.value, [0, 1], [0.4, 1]),
|
||
|
|
}))
|
||
|
|
|
||
|
|
const borderRadius = radius === "round" ? (typeof height === "number" ? height / 2 : 999) : radius
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Animated.View
|
||
|
|
style={[
|
||
|
|
{ width, height, borderRadius, backgroundColor: theme.colors.border },
|
||
|
|
animatedStyle,
|
||
|
|
style,
|
||
|
|
]}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SkeletonContainerProps {
|
||
|
|
/**
|
||
|
|
* Children (Skeleton components)
|
||
|
|
*/
|
||
|
|
children: React.ReactNode
|
||
|
|
/**
|
||
|
|
* Style overrides for container
|
||
|
|
*/
|
||
|
|
style?: StyleProp<ViewStyle>
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Container that synchronizes pulse animation across all child Skeletons.
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* <SkeletonContainer>
|
||
|
|
* <Skeleton width="60%" height={14} />
|
||
|
|
* <Skeleton width={40} height={40} radius="round" />
|
||
|
|
* </SkeletonContainer>
|
||
|
|
*/
|
||
|
|
export function SkeletonContainer(props: SkeletonContainerProps) {
|
||
|
|
const { children, style } = props
|
||
|
|
|
||
|
|
const progress = useSharedValue(0)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
progress.value = withRepeat(
|
||
|
|
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||
|
|
-1,
|
||
|
|
true,
|
||
|
|
)
|
||
|
|
}, [progress])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<PulseContext.Provider value={progress}>
|
||
|
|
<View style={style}>{children}</View>
|
||
|
|
</PulseContext.Provider>
|
||
|
|
)
|
||
|
|
}
|