Last active
November 24, 2025 09:03
-
-
Save daehyeonmun2021/fd036309b3fa964b0cc91ac3280da070 to your computer and use it in GitHub Desktop.
PageCurl - React Native Skia
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| Easing, | |
| interpolate, | |
| useDerivedValue, | |
| useSharedValue, | |
| withTiming, | |
| } from 'react-native-reanimated'; | |
| import { | |
| PixelRatio, | |
| Pressable, | |
| StyleSheet, | |
| StyleProp, | |
| Text, | |
| View, | |
| ViewStyle, | |
| } from 'react-native'; | |
| import { SafeAreaView } from 'react-native-safe-area-context'; | |
| import { | |
| Canvas, | |
| Group, | |
| Image, | |
| Paint, | |
| RuntimeShader, | |
| Skia, | |
| rect, | |
| useImage, | |
| vec, | |
| } from '@shopify/react-native-skia'; | |
| import { scheduleOnUI } from 'react-native-worklets'; | |
| // ============================================================================ | |
| // Shader Utilities | |
| // ============================================================================ | |
| type Value = string | number; | |
| type Values = Value[]; | |
| /** | |
| * Template tag for GLSL shader code with value interpolation | |
| */ | |
| const glsl = (source: TemplateStringsArray, ...values: Values) => { | |
| const processed = source.flatMap((s, i) => [s, values[i]]).filter(Boolean); | |
| return processed.join(''); | |
| }; | |
| /** | |
| * Compiles a GLSL fragment shader into a Skia RuntimeEffect | |
| * @throws Error if shader compilation fails | |
| */ | |
| const frag = (source: TemplateStringsArray, ...values: Values) => { | |
| const code = glsl(source, ...values); | |
| const rt = Skia.RuntimeEffect.Make(code); | |
| if (rt === null) { | |
| throw new Error("Couldn't Compile Shader"); | |
| } | |
| return rt; | |
| }; | |
| // ============================================================================ | |
| // Constants | |
| // ============================================================================ | |
| const DPR = PixelRatio.get(); // Device pixel ratio for high-DPI displays | |
| const WIDTH = 310; | |
| const HEIGHT = 380; | |
| const ANGLE = 5.89; // Rotation angle in degrees for visual effect | |
| const IMAGE_RECT = rect(0, 0, WIDTH, HEIGHT); | |
| const IMAGE_RECT_DPR = [IMAGE_RECT.width, IMAGE_RECT.height].map( | |
| (v) => v * DPR, | |
| ); | |
| // Animation configuration: curl animates from bottom-left (30%, 80%) to bottom-right (100%, 100%) | |
| const ANIMATION = { | |
| DURATION: 2000, | |
| START_X: 0.3, | |
| START_Y: 0.8, | |
| END_X: 1, | |
| END_Y: 1, | |
| } as const; | |
| // ============================================================================ | |
| // Page Curl Shader | |
| // ============================================================================ | |
| /** | |
| * Page Curl Shader Effect | |
| * | |
| * Simulates a realistic page curl by modeling the page as a cylinder. | |
| * The shader divides the screen into three regions: | |
| * 1. Beyond the curl (transparent/hidden) | |
| * 2. The curl surface (cylindrical mapping with shadow) | |
| * 3. Before the curl (normal flat page) | |
| * | |
| * Algorithm: | |
| * - Find the "curl line" perpendicular to curl direction | |
| * - Calculate each pixel's signed distance from the curl line | |
| * - Map pixels onto a cylinder surface based on distance | |
| * - Apply shadow gradient on the curled region | |
| * | |
| * Uniforms: | |
| * u_image - Source texture to be curled | |
| * u_resolution - Canvas resolution (width, height) in pixels | |
| * u_originPos - Fixed corner where curl originates (typically bottom-right) | |
| * u_currPos - Animated curl tip position | |
| */ | |
| const pageCurl = frag` | |
| uniform shader u_image; | |
| uniform vec2 u_resolution; | |
| uniform vec2 u_originPos; | |
| uniform vec2 u_currPos; | |
| const float PI = 3.14159265359; | |
| const float RADIUS = 0.1; // Cylinder radius in normalized space | |
| const float SHADOW_EXPONENT = 0.2; // Controls shadow gradient falloff | |
| vec4 main(vec2 fragCoord) { | |
| // 1. Setup coordinate systems | |
| float aspect = u_resolution.x / u_resolution.y; | |
| vec2 aspectScale = vec2(aspect, 1.0); // Scale to square aspect ratio | |
| vec2 uvScale = vec2(u_resolution.x / aspect, u_resolution.y); // For texture sampling | |
| vec2 uv = fragCoord * aspectScale / u_resolution; // Normalized pixel coord [0,1] | |
| // 2. Define curl geometry | |
| vec2 curlTip = u_currPos * aspectScale / u_resolution; // Current curl tip position | |
| vec2 curlDir = normalize(abs(u_originPos) - u_currPos); // Direction from origin to tip | |
| // Find where the curl line intersects the left edge (y-axis) | |
| // This is the "hinge" point where the curl begins | |
| vec2 origin = clamp( | |
| curlTip - curlDir * curlTip.x / curlDir.x, | |
| 0.0, | |
| 1.0 | |
| ); | |
| // 3. Calculate curl line length | |
| // Base length from origin to tip, plus adjustment for canvas edge | |
| float curlLength = clamp( | |
| length(curlTip - origin) + | |
| (aspect - (abs(u_originPos.x) / u_resolution.x) * aspect) / curlDir.x, | |
| 0.0, | |
| aspect / curlDir.x | |
| ); | |
| // Special case: leftward curls don't need edge adjustment | |
| if (curlDir.x < 0.0) { | |
| curlLength = distance(curlTip, origin); | |
| } | |
| // 4. Calculate pixel distance from curl line | |
| // Project pixel onto curl direction, subtract curl length | |
| // Positive = beyond curl, Negative = before curl, Near-zero = on curl surface | |
| float dist = dot(uv - origin, curlDir) - curlLength; | |
| // Find closest point on the curl line to current pixel | |
| vec2 linePoint = uv - dist * curlDir; | |
| // 5. Render based on distance from curl line | |
| // REGION 1: Beyond the curl cylinder → fully transparent | |
| if (dist > RADIUS) { | |
| return vec4(0.0, 0.0, 0.0, 0.0); | |
| } | |
| // REGION 2: On the curl surface [0, RADIUS] | |
| // Map flat coordinates onto cylinder using arc length | |
| else if (dist >= 0.0) { | |
| // Angle around the cylinder (from front to back) | |
| float theta = asin(dist / RADIUS); | |
| // Two possible texture coordinates: back side and front side of cylinder | |
| vec2 backPoint = linePoint + curlDir * (PI - theta) * RADIUS; | |
| vec2 frontPoint = linePoint + curlDir * theta * RADIUS; | |
| // Use back side if it's within bounds, otherwise show front | |
| bool isInBounds = | |
| backPoint.x <= aspect && backPoint.y <= 1.0 && | |
| backPoint.x > 0.0 && backPoint.y > 0.0; | |
| uv = isInBounds ? backPoint : frontPoint; | |
| // Sample texture at the mapped position | |
| vec4 fragColor = u_image.eval(uv * uvScale); | |
| // Apply shadow gradient: darker near the curl edge (dist=RADIUS), lighter at center (dist=0) | |
| float shadowFactor = pow( | |
| clamp((RADIUS - dist) / RADIUS, 0.0, 1.0), | |
| SHADOW_EXPONENT | |
| ); | |
| fragColor.rgb *= shadowFactor; | |
| return fragColor; | |
| } | |
| // REGION 3: Before the curl (dist < 0) | |
| // Show flat page, but account for paper "consumed" by the cylinder | |
| else { | |
| // Offset by the cylinder's circumference (PI * RADIUS) | |
| vec2 mappedPoint = linePoint + curlDir * (abs(dist) + PI * RADIUS); | |
| // Use mapped point if valid, otherwise use original UV | |
| bool isInBounds = | |
| mappedPoint.x <= aspect && mappedPoint.y <= 1.0 && | |
| mappedPoint.x > 0.0 && mappedPoint.y > 0.0; | |
| uv = isInBounds ? mappedPoint : uv; | |
| return u_image.eval(uv * uvScale); | |
| } | |
| } | |
| `; | |
| // ============================================================================ | |
| // Components | |
| // ============================================================================ | |
| type ControlButtonProps = { | |
| label: string; | |
| onPress: () => void; | |
| style?: StyleProp<ViewStyle>; | |
| }; | |
| const ControlButton = ({ label, onPress, style }: ControlButtonProps) => { | |
| return ( | |
| <Pressable | |
| onPress={onPress} | |
| style={({ pressed }) => [ | |
| styles.buttonBase, | |
| styles.buttonSolid, | |
| styles.buttonShadow, | |
| pressed && styles.buttonPressed, | |
| style, | |
| ]} | |
| > | |
| <Text style={styles.buttonText}>{label}</Text> | |
| </Pressable> | |
| ); | |
| }; | |
| // ============================================================================ | |
| // Main Screen | |
| // ============================================================================ | |
| export const PageCurlScreen = () => { | |
| const image = useImage( | |
| 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=80', | |
| ); | |
| const progress = useSharedValue(1); // 0 = start of curl, 1 = fully curled | |
| // Shader uniforms updated on every frame | |
| const uniforms = useDerivedValue(() => { | |
| return { | |
| u_resolution: IMAGE_RECT_DPR, | |
| u_originPos: IMAGE_RECT_DPR, // Fixed bottom-right corner | |
| u_currPos: vec( | |
| // Interpolate curl tip position from start to end | |
| interpolate( | |
| progress.value, | |
| [0, 1], | |
| [ | |
| IMAGE_RECT.width * ANIMATION.START_X, | |
| IMAGE_RECT.width * ANIMATION.END_X, | |
| ], | |
| ) * DPR, | |
| interpolate( | |
| progress.value, | |
| [0, 1], | |
| [ | |
| IMAGE_RECT.height * ANIMATION.START_Y, | |
| IMAGE_RECT.height * ANIMATION.END_Y, | |
| ], | |
| ) * DPR, | |
| ), | |
| }; | |
| }, []); | |
| const play = () => { | |
| scheduleOnUI(() => { | |
| progress.value = 0; // Reset to start | |
| progress.value = withTiming(1, { | |
| duration: ANIMATION.DURATION, | |
| easing: Easing.out(Easing.cubic), // Smooth deceleration | |
| }); | |
| }); | |
| }; | |
| return ( | |
| <SafeAreaView style={styles.safeArea}> | |
| <View style={styles.container}> | |
| <View style={styles.header}> | |
| <Text style={styles.title}>Page Curl Shader</Text> | |
| </View> | |
| <View style={styles.previewBlock}> | |
| <View style={styles.previewCard}> | |
| {image && ( | |
| <Canvas | |
| style={{ | |
| width: IMAGE_RECT.width, | |
| height: IMAGE_RECT.height, | |
| backgroundColor: '#F8F5EC', | |
| transform: [{ rotateZ: `${ANGLE}deg` }], | |
| }} | |
| > | |
| <Group transform={[{ scale: 1 / DPR }]}> | |
| <Group | |
| layer={ | |
| <Paint> | |
| <RuntimeShader source={pageCurl} uniforms={uniforms} /> | |
| </Paint> | |
| } | |
| transform={[{ scale: DPR }]} | |
| > | |
| <Image image={image} rect={IMAGE_RECT} fit="cover" /> | |
| </Group> | |
| </Group> | |
| </Canvas> | |
| )} | |
| </View> | |
| </View> | |
| <View style={styles.controls}> | |
| <ControlButton label="Run" onPress={play} /> | |
| </View> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| }; | |
| // ============================================================================ | |
| // Styles | |
| // ============================================================================ | |
| const styles = StyleSheet.create({ | |
| safeArea: { | |
| flex: 1, | |
| backgroundColor: '#F8F5EC', | |
| }, | |
| container: { | |
| flex: 1, | |
| alignItems: 'center', | |
| paddingHorizontal: 24, | |
| paddingTop: 48, | |
| paddingBottom: 40, | |
| }, | |
| header: { | |
| alignItems: 'center', | |
| marginBottom: 56, | |
| }, | |
| title: { | |
| fontSize: 32, | |
| fontWeight: '800', | |
| letterSpacing: -0.5, | |
| }, | |
| previewBlock: { | |
| alignItems: 'center', | |
| marginBottom: 48, | |
| }, | |
| previewCard: { | |
| padding: 24, | |
| borderRadius: 24, | |
| }, | |
| controls: { | |
| alignItems: 'center', | |
| }, | |
| buttonBase: { | |
| paddingHorizontal: 32, | |
| paddingVertical: 16, | |
| borderRadius: 16, | |
| minWidth: 180, | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }, | |
| buttonSolid: { | |
| backgroundColor: '#6366F1', | |
| }, | |
| buttonShadow: { | |
| boxShadow: '6px 6px 12px rgba(0,0,0,0.3)', | |
| }, | |
| buttonPressed: { | |
| opacity: 0.85, | |
| transform: [{ scale: 0.98 }], | |
| }, | |
| buttonText: { | |
| color: '#FFFFFF', | |
| fontSize: 16, | |
| fontWeight: '700', | |
| letterSpacing: 0.3, | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment