├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
139 |
140 |
141 | );
142 | }
143 |
144 | return (
145 |
146 | {firstSnapshot && (
147 |
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 |
--------------------------------------------------------------------------------