├── .prettierrc ├── tsconfig.json ├── images ├── ball.jpg ├── bike.jpg ├── milk.jpg └── teddybear.jpg ├── app.json ├── babel.config.js ├── .gitignore ├── icons ├── HomeIcon.tsx ├── MoonIcon.tsx ├── SearchIcon.tsx ├── SunIcon.tsx ├── CartIcon.tsx ├── HeartIcon.tsx └── HeartDuotoneIcon.tsx ├── README.md ├── package.json ├── components ├── SearchBar.tsx ├── Trending.tsx ├── BottomTabs.tsx └── Cards.tsx ├── utils └── shader.ts └── App.tsx /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /images/ball.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kacperkapusciak/expo-magic-curtain/HEAD/images/ball.jpg -------------------------------------------------------------------------------- /images/bike.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kacperkapusciak/expo-magic-curtain/HEAD/images/bike.jpg -------------------------------------------------------------------------------- /images/milk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kacperkapusciak/expo-magic-curtain/HEAD/images/milk.jpg -------------------------------------------------------------------------------- /images/teddybear.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kacperkapusciak/expo-magic-curtain/HEAD/images/teddybear.jpg -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-skia-curtain", 4 | "slug": "expo-skia-curtain" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'] 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # prebuild 17 | ios/ 18 | android/ -------------------------------------------------------------------------------- /icons/HomeIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 12 | 13 | ); 14 | export default SvgComponent; 15 | -------------------------------------------------------------------------------- /icons/MoonIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 11 | 12 | ); 13 | export default SvgComponent; 14 | -------------------------------------------------------------------------------- /icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 12 | 13 | ); 14 | export default SvgComponent; 15 | -------------------------------------------------------------------------------- /icons/SunIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 12 | 13 | ); 14 | export default SvgComponent; 15 | -------------------------------------------------------------------------------- /icons/CartIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 12 | 13 | ); 14 | export default SvgComponent; 15 | -------------------------------------------------------------------------------- /icons/HeartIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 12 | 13 | ); 14 | export default SvgComponent; 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo Magic Curtain 🪄💫 2 | 3 | ## Running the project 4 | 5 | Clone the repository onto your computer: 6 | 7 | ```sh 8 | git clone https://github.com/kacperkapusciak/expo-magic-curtain.git 9 | ``` 10 | 11 | Checkout into the project folder: 12 | 13 | ```sh 14 | cd expo-magic-curtain 15 | ``` 16 | 17 | Install the packages with `npm`: 18 | 19 | ```sh 20 | yarn install 21 | ``` 22 | 23 | And, start the project: 24 | 25 | ```sh 26 | yarn start 27 | ``` 28 | 29 | You may use your phone to test the app via [Expo Go](https://docs.expo.dev/get-started/expo-go/) app, or run the project locally using iOS simulator or Android emulator. 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@expo/metro-runtime": "~5.0.4", 4 | "@shopify/react-native-skia": "v2.0.0-next.4", 5 | "@types/react": "~19.0.10", 6 | "expo": "^53.0.11", 7 | "expo-blur": "~14.1.5", 8 | "expo-image": "~2.3.0", 9 | "expo-status-bar": "~2.2.3", 10 | "jotai": "^2.7.1", 11 | "react": "19.0.0", 12 | "react-dom": "19.0.0", 13 | "react-native": "0.79.3", 14 | "react-native-reanimated": "~3.17.4", 15 | "react-native-svg": "15.11.2", 16 | "react-native-web": "^0.20.0", 17 | "typescript": "~5.8.3" 18 | }, 19 | "devDependencies": { 20 | "@babel/core": "^7.19.3", 21 | "prettier": "^3.2.5" 22 | }, 23 | "name": "expo-skia-curtain", 24 | "version": "1.0.0", 25 | "private": true, 26 | "scripts": { 27 | "start": "expo start", 28 | "android": "expo run:android", 29 | "ios": "expo run:ios", 30 | "web": "expo start --web" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /icons/HeartDuotoneIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path } from "react-native-svg"; 3 | const SvgComponent = ({ color = "#001A72", ...rest }) => ( 4 | 5 | 10 | 17 | 18 | ); 19 | export default SvgComponent; 20 | -------------------------------------------------------------------------------- /components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Text, View, useColorScheme } from "react-native"; 2 | 3 | import SearchIcon from "../icons/SearchIcon"; 4 | 5 | export function SearchBar() { 6 | const colorScheme = useColorScheme(); 7 | return ( 8 | 17 | 18 | 24 | Search 25 | 26 | 27 | ); 28 | } 29 | 30 | const styles = StyleSheet.create({ 31 | container: { 32 | height: 60, 33 | borderRadius: 20, 34 | borderCurve: "continuous", 35 | alignItems: "center", 36 | flexDirection: "row", 37 | }, 38 | padding: { 39 | padding: 16, 40 | }, 41 | text: { 42 | fontSize: 18, 43 | marginHorizontal: 8, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /utils/shader.ts: -------------------------------------------------------------------------------- 1 | import { Skia } from "@shopify/react-native-skia"; 2 | 3 | type Value = string | number; 4 | type Values = Value[]; 5 | 6 | export const glsl = (source: TemplateStringsArray, ...values: Values) => { 7 | const processed = source.flatMap((s, i) => [s, values[i]]).filter(Boolean); 8 | return processed.join(""); 9 | }; 10 | 11 | export const frag = (source: TemplateStringsArray, ...values: Values) => { 12 | const code = glsl(source, ...values); 13 | const rt = Skia.RuntimeEffect.Make(code); 14 | if (rt === null) { 15 | throw new Error("Couln't Compile Shader"); 16 | } 17 | return rt; 18 | }; 19 | 20 | export type Transition = string; 21 | 22 | export const transition = (t: Transition) => { 23 | return frag` 24 | uniform shader image1; 25 | uniform shader image2; 26 | 27 | uniform float progress; 28 | uniform float2 resolution; 29 | 30 | half4 getFromColor(float2 uv) { 31 | return image1.eval(uv * resolution); 32 | } 33 | 34 | half4 getToColor(float2 uv) { 35 | return image2.eval(uv * resolution); 36 | } 37 | 38 | ${t} 39 | 40 | half4 main(vec2 xy) { 41 | vec2 uv = xy / resolution; 42 | return transition(uv); 43 | } 44 | `; 45 | }; 46 | -------------------------------------------------------------------------------- /components/Trending.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Text, View, useColorScheme } from "react-native"; 2 | 3 | const trending = [ 4 | "on sale", 5 | "new arrivals", 6 | "top rated", 7 | "best sellers", 8 | "most popular", 9 | ]; 10 | 11 | export function Trending() { 12 | const colorScheme = useColorScheme(); 13 | 14 | return ( 15 | 16 | 22 | Trending searches 23 | 24 | 25 | {trending.map((item) => ( 26 | 35 | 43 | {item} 44 | 45 | 46 | ))} 47 | 48 | 49 | ); 50 | } 51 | 52 | const styles = StyleSheet.create({ 53 | header: { 54 | fontSize: 14, 55 | fontWeight: "bold", 56 | marginVertical: 12, 57 | }, 58 | textWrapper: { 59 | borderWidth: 2, 60 | alignItems: "center", 61 | justifyContent: "center", 62 | borderRadius: 8, 63 | padding: 4, 64 | }, 65 | text: { 66 | fontSize: 12, 67 | textTransform: "uppercase", 68 | }, 69 | wrapper: { 70 | gap: 6, 71 | flexDirection: "row", 72 | flexWrap: "wrap", 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /components/BottomTabs.tsx: -------------------------------------------------------------------------------- 1 | import { View, StyleSheet, useColorScheme, Platform } from "react-native"; 2 | 3 | import HomeIcon from "../icons/HomeIcon"; 4 | import HeartIcon from "../icons/HeartIcon"; 5 | import CartIcon from "../icons/CartIcon"; 6 | import { BlurView } from "expo-blur"; 7 | 8 | export function BottomTabs() { 9 | const colorScheme = useColorScheme(); 10 | 11 | const icons = ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | return ( 26 | 27 | {Platform.OS === "ios" ? ( 28 | 29 | {icons} 30 | 31 | ) : ( 32 | 38 | {icons} 39 | 40 | )} 41 | 42 | ); 43 | } 44 | 45 | const styles = StyleSheet.create({ 46 | container: { 47 | position: "absolute", 48 | bottom: 40, 49 | borderWidth: 2, 50 | borderRadius: 60, 51 | borderColor: "#cbd5e1", 52 | overflow: "hidden", 53 | alignSelf: "center", 54 | borderCurve: "continuous", 55 | }, 56 | blurContainer: { 57 | flexDirection: "row", 58 | padding: 16, 59 | justifyContent: "space-between", 60 | borderRadius: 60, 61 | }, 62 | tab: { 63 | // flex: 1, 64 | paddingHorizontal: 16, 65 | alignItems: "center", 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /components/Cards.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | Image, 4 | StyleSheet, 5 | Dimensions, 6 | Pressable, 7 | Platform, 8 | } from "react-native"; 9 | import Animated, { BounceIn } from "react-native-reanimated"; 10 | import { BlurView } from "expo-blur"; 11 | import { atom, useAtom } from "jotai"; 12 | 13 | import HeartIcon from "../icons/HeartIcon"; 14 | import HeartDuotoneIcon from "../icons/HeartDuotoneIcon"; 15 | 16 | export const themeSwitchAtom = atom(false); 17 | 18 | const bikeAtom = atom(false); 19 | const milkAtom = atom(false); 20 | const teddybearAtom = atom(false); 21 | const ballAtom = atom(false); 22 | 23 | const bounceIn = BounceIn.duration(400).withInitialValues({ 24 | transform: [{ scale: 0.5 }], 25 | }); 26 | 27 | export function Cards() { 28 | return ( 29 | 30 | 31 | 32 | 36 | 37 | 38 | ); 39 | } 40 | function Card({ image, cardAtom }) { 41 | const [isThemeSwitching] = useAtom(themeSwitchAtom); 42 | const [isLiked, setIsLiked] = useAtom(cardAtom); 43 | 44 | const icon = isLiked ? ( 45 | 46 | 47 | 48 | ) : ( 49 | 50 | ); 51 | return ( 52 | 53 | 54 | 55 | { 57 | setIsLiked(!isLiked); 58 | }} 59 | style={{ 60 | borderRadius: 32, 61 | overflow: "hidden", 62 | }} 63 | > 64 | {Platform.OS === "ios" ? ( 65 | 70 | {icon} 71 | 72 | ) : ( 73 | 79 | {icon} 80 | 81 | )} 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | const styles = StyleSheet.create({ 89 | container: { 90 | flexDirection: "row", 91 | justifyContent: "space-between", 92 | flexWrap: "wrap", 93 | gap: 16, 94 | marginVertical: 16, 95 | }, 96 | card: { 97 | position: "relative", 98 | }, 99 | round: { 100 | borderRadius: 16, 101 | }, 102 | image: { 103 | width: Dimensions.get("window").width / 2 - 24, 104 | height: 250, 105 | }, 106 | blurContainer: { 107 | height: 48, 108 | width: 48, 109 | alignItems: "center", 110 | justifyContent: "center", 111 | borderRadius: 32, 112 | }, 113 | iconWrapper: { 114 | position: "absolute", 115 | bottom: 16, 116 | right: 16, 117 | overflow: "hidden", 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { 3 | View, 4 | Text, 5 | StyleSheet, 6 | useColorScheme, 7 | Pressable, 8 | Appearance, 9 | Dimensions, 10 | Platform, 11 | } from "react-native"; 12 | import { useAtom } from "jotai"; 13 | import { 14 | Canvas, 15 | Fill, 16 | Image, 17 | ImageShader, 18 | makeImageFromView, 19 | Shader, 20 | type SkImage, 21 | } from "@shopify/react-native-skia"; 22 | import { 23 | runOnJS, 24 | useDerivedValue, 25 | useSharedValue, 26 | withTiming, 27 | } from "react-native-reanimated"; 28 | 29 | import { BottomTabs } from "./components/BottomTabs"; 30 | import { SearchBar } from "./components/SearchBar"; 31 | import { Trending } from "./components/Trending"; 32 | import { Cards, themeSwitchAtom } from "./components/Cards"; 33 | 34 | import SunIcon from "./icons/SunIcon"; 35 | import MoonIcon from "./icons/MoonIcon"; 36 | import { Transition, glsl, transition } from "./utils/shader"; 37 | import { StatusBar } from "expo-status-bar"; 38 | 39 | const TRANSITION_DURATION = 500; 40 | 41 | const { width, height } = Dimensions.get("window"); 42 | 43 | const wipeLeft: Transition = glsl` 44 | // Author: Jake Nelson 45 | // License: MIT 46 | 47 | vec4 transition(vec2 uv) { 48 | vec2 p=uv.xy/vec2(1.0).xy; 49 | vec4 a=getFromColor(p); 50 | vec4 b=getToColor(p); 51 | return mix(a, b, step(1.0-p.x,progress)); 52 | } 53 | `; 54 | 55 | const wipeRight: Transition = glsl` 56 | // Author: Jake Nelson 57 | // License: MIT 58 | 59 | vec4 transition(vec2 uv) { 60 | vec2 p=uv.xy/vec2(1.0).xy; 61 | vec4 a=getFromColor(p); 62 | vec4 b=getToColor(p); 63 | return mix(a, b, step(0.0+p.x,progress)); 64 | } 65 | `; 66 | 67 | export default function App() { 68 | const progress = useSharedValue(0); 69 | const colorScheme = useColorScheme(); 70 | const [isThemeSwitching, setThemeSwitching] = useAtom(themeSwitchAtom); 71 | 72 | const ref = useRef(null); 73 | const [firstSnapshot, setFirstSnapshot] = useState(null); 74 | const [secondSnapshot, setSecondSnapshot] = useState(null); 75 | 76 | const changeTheme = async () => { 77 | if (isThemeSwitching) return; 78 | 79 | progress.value = 0; 80 | const snapshot1 = await makeImageFromView(ref); 81 | setFirstSnapshot(snapshot1); 82 | Appearance.setColorScheme(colorScheme === "light" ? "dark" : "light"); 83 | setThemeSwitching(true); 84 | }; 85 | 86 | useEffect(() => { 87 | const listener = Appearance.addChangeListener(async () => { 88 | setTimeout(async () => { 89 | const snapshot2 = await makeImageFromView(ref); 90 | setSecondSnapshot(snapshot2); 91 | progress.value = withTiming( 92 | 1, 93 | { duration: TRANSITION_DURATION }, 94 | () => { 95 | runOnJS(setFirstSnapshot)(null); 96 | runOnJS(setSecondSnapshot)(null); 97 | runOnJS(setThemeSwitching)(false); 98 | }, 99 | ); 100 | }, 30); 101 | }); 102 | 103 | return () => { 104 | listener.remove(); 105 | }; 106 | }, []); 107 | 108 | const uniforms = useDerivedValue(() => { 109 | return { progress: progress.value, resolution: [width, height] }; 110 | }); 111 | 112 | const isTransitioning = firstSnapshot !== null && secondSnapshot !== null; 113 | if (isTransitioning) { 114 | return ( 115 | 116 | 117 | 118 | 124 | 130 | 136 | 137 | 138 | 139 | 140 | 141 | ); 142 | } 143 | 144 | return ( 145 | 146 | {firstSnapshot && ( 147 | 148 | 154 | 155 | )} 156 | 167 | 175 | 176 | 177 | 185 | Home 186 | 187 | 188 | {colorScheme === "light" ? ( 189 | 190 | ) : ( 191 | 192 | )} 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | ); 205 | } 206 | 207 | const styles = StyleSheet.create({ 208 | container: { paddingTop: Platform.OS === "ios" ? 50 : 10 }, 209 | fill: { flex: 1 }, 210 | padding: { padding: 16 }, 211 | absolute: { 212 | position: "absolute", 213 | height: height, 214 | width: width, 215 | zIndex: 1, 216 | elevation: 1, 217 | }, 218 | row: { 219 | flexDirection: "row", 220 | justifyContent: "space-between", 221 | alignItems: "center", 222 | }, 223 | themeSwitcher: { paddingBottom: 10, paddingRight: 4 }, 224 | header: { fontSize: 36, fontWeight: "bold", marginBottom: 16 }, 225 | }); 226 | --------------------------------------------------------------------------------