├── .eslintrc ├── .expo-shared └── assets.json ├── .gitignore ├── App.tsx ├── README.md ├── app.json ├── assets ├── adaptive-icon.png ├── favicon.png ├── icon.png └── splash.png ├── babel.config.js ├── eas.json ├── package.json ├── src ├── AllTheGestures │ ├── AllTheGestures.tsx │ ├── README.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ ├── Step1.tsx │ │ ├── Step2.tsx │ │ ├── Step3.tsx │ │ ├── Step4.tsx │ │ └── Step5.tsx ├── AnimatedReactions │ ├── AnimatedReactions.tsx │ ├── README.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ ├── Step1.tsx │ │ ├── Step2.tsx │ │ ├── Step3.tsx │ │ ├── Step4.tsx │ │ ├── Step5.tsx │ │ ├── Step6.tsx │ │ └── Step7.tsx ├── Drawings │ ├── Drawings.tsx │ ├── Readme.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ ├── Start.tsx │ │ ├── Step1.tsx │ │ └── Step2.tsx ├── Examples.tsx ├── GestureBasedPicker │ ├── GestureBasedPicker.tsx │ ├── README.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ ├── Step1.tsx │ │ ├── Step2.tsx │ │ ├── Step3.tsx │ │ ├── Step4.tsx │ │ └── Step5.tsx ├── PhotoEditor │ ├── Filters.tsx │ ├── Helpers.tsx │ ├── PhotoEditor.tsx │ ├── index.ts │ └── steps │ │ ├── Final │ │ ├── Filters.tsx │ │ └── PhotoEditor.tsx │ │ └── Readme.md ├── PinchToZoom │ ├── PinchToZoom.tsx │ ├── Readme.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ └── Start.tsx ├── ReactLogo │ ├── ReactLogo.tsx │ ├── Readme.md │ ├── index.ts │ └── steps │ │ ├── Final.tsx │ │ ├── Start.tsx │ │ ├── Step1.tsx │ │ └── Step2.tsx ├── Routes.ts ├── ShapeMorphing │ ├── Eye.tsx │ ├── Helpers.ts │ ├── Mouth.tsx │ ├── ShapeMorphing.tsx │ ├── Slider.tsx │ ├── Title.tsx │ ├── assets │ │ ├── SF-Pro-Display-Bold.otf │ │ ├── SF-Pro-Display-Medium.otf │ │ └── SF-Pro-Display-Regular.otf │ ├── index.ts │ └── steps │ │ ├── Final │ │ ├── Eye.tsx │ │ ├── Helpers.ts │ │ ├── Mouth.tsx │ │ ├── ShapeMorphing.tsx │ │ ├── Slider.tsx │ │ └── Title.tsx │ │ └── Readme.md ├── SkiaLogo │ ├── Background.tsx │ ├── PathGradient.tsx │ ├── Readme.md │ ├── SkiaLogo.tsx │ ├── index.ts │ └── steps │ │ ├── Bonus.tsx │ │ ├── Final.tsx │ │ ├── Start.tsx │ │ └── Step1.tsx ├── Stickers │ ├── AppjsLogo.tsx │ ├── ReactLogo.tsx │ ├── SkiaLogo.tsx │ ├── Stickers.tsx │ └── index.ts ├── assets │ ├── zurich.jpg │ ├── zurich2.jpg │ └── zurich3.jpg ├── components │ ├── CenterScreen.tsx │ ├── LoadAssets.tsx │ ├── Math.ts │ └── matrixMath.ts └── index.d.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-native-wcandillon", 3 | "rules": { 4 | "@typescript-eslint/no-non-null-assertion": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; 3 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 4 | 5 | import type { Routes } from "./src/Routes"; 6 | import { Examples } from "./src/Examples"; 7 | import { AnimatedReactions } from "./src/AnimatedReactions"; 8 | import { GestureBasedPicker } from "./src/GestureBasedPicker"; 9 | import { ReactLogo } from "./src/ReactLogo"; 10 | import { SkiaLogo } from "./src/SkiaLogo/SkiaLogo"; 11 | import { ShapeMorphing } from "./src/ShapeMorphing"; 12 | import { PinchToZoom } from "./src/PinchToZoom"; 13 | import { Drawings } from "./src/Drawings"; 14 | import { PhotoEditor } from "./src/PhotoEditor"; 15 | import { LoadAssets } from "./src/components/LoadAssets"; 16 | import { Stickers } from "./src/Stickers"; 17 | import { AllTheGestures } from "./src/AllTheGestures"; 18 | 19 | const Stack = createNativeStackNavigator(); 20 | const assets: number[] = []; 21 | 22 | function App() { 23 | return ( 24 | 25 | 26 | 27 | 28 | 32 | 36 | 37 | 38 | 39 | 40 | null }} 44 | /> 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | // eslint-disable-next-line import/no-default-export 55 | export default App; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drawings, gestures, and animations workshop – App.js Conference 2022 2 | 3 | ## Hosted by 4 | 5 | - William Candillon ([@wcandillon](https://twitter.com/wcandillon)) 6 | - Krzysztof Magiera ([@kzzzf](https://twitter.com/kzzzf)) 7 | 8 | ## Setup 9 | 10 | During this workshop we will work with an Expo / React Native app published in this repo. 11 | In order to make setup more seamless we have prepared a so-called Expo's development client builds that include all the native code for all the dependencies that are used as a part of the workshop. 12 | 13 | You should be able to use iOS simulator, Android emulator, or any modern Andorid or iOS phone to perform the exercises, however, we recommend that you stick to one choice to avoid additional setup steps you may need to do in the future. 14 | If you choose to work with an emulator (either iOS or Android) make sure that you have that emulator installed and configured as setting it up is outside of this setup scope. 15 | 16 | ## Before you begin 17 | 18 | use the below command to install [Expo CLI](https://docs.expo.dev/workflow/expo-cli/): 19 | 20 | ```bash 21 | npm install -g expo-cli 22 | ``` 23 | 24 | Or make sure it is up to date if you have it installed already: 25 | 26 | ```bash 27 | expo --version 28 | ``` 29 | 30 | ## Preparing device or simulator 31 | 32 | Depending on what device or simulator you choose to use, you'll need to install custom made Development Client application in your environment. 33 | Follow one of the sections below for detailed instructions. 34 | 35 | ### For iOS simulator 36 | 37 | 1. Download Development Client build [from this link](https://expo.dev/artifacts/eas/wrhPiE1BYBuuSRbN89S6FM.tar.gz) 38 | 2. Extract `appjsworkshop.app` file from the downloaded archive 39 | 3. Launch your iOS simulator 40 | 4. Drag and drop the `.app` file onto the simulator 41 | 42 | ### For iOS device 43 | 44 | 1. Scan the QR code below with your iOS phone: 45 | 46 | ![https://expo.dev/accounts/kmagiera/projects/appjs-workshop/builds/eb4c78ce-0f05-4230-8784-0eef2fe27a69](https://user-images.githubusercontent.com/726445/172498858-006a0e3e-b3a7-4c66-9825-2a7494959f08.png) 47 | 48 | 2. Click "install" button on the website that the code opens, and confirm with "install" button on the dialog that pops up after that 49 | 3. After app installation is completed navigate to "Settings" > "General" > "VPN & Device Management" section 50 | 4. Tap "650 INDUSTRIES INC." record in the "ENTERPRISE APP" section 51 | 5. Tap "Trust 650 INDUSTRIES INC." on the following page and confirm the selection when prompted 52 | 6. Make sure you can now launch "appjs-workshop" app installed on your phone 53 | 54 | ### For Android emulator 55 | 56 | 1. Download Development Client build [from this link](https://expo.dev/artifacts/eas/kaL8myvJwAJc6xSCSinbVT.apk) 57 | 2. Launch Android emulator on your computer 58 | 3. Drag and drop the downloaded `.apk` file onto emulator 59 | 60 | ### For Android device 61 | 62 | 1. Scan this QR code on your device: 63 | 64 | ![https://expo.dev/accounts/kmagiera/projects/appjs-workshop/builds/9b3a85c7-a39a-4872-b392-e3b7fa8e173](https://user-images.githubusercontent.com/726445/172498765-5bcfcb5b-cd7e-4619-be24-30056f5dbc0e.png) 65 | 66 | 2. Tap "install" button on the website that opens after scannig the code 67 | 68 | ## Running the app 69 | 70 | After completing Development Client installation step, you now should be able to clone this repo and launch the app. 71 | Follow the below steps from the terminal: 72 | 73 | 1. Clone the repo: 74 | 75 | ```bash 76 | git clone git@github.com:software-mansion-labs/drawings-and-animations-workshop.git && cd drawings-and-animations-workshop 77 | ``` 78 | 79 | 2. Install project dependencies (run the below command from the project main directory): 80 | 81 | ```bash 82 | yarn 83 | ``` 84 | 85 | 3. Launch the app with Expo CLI: 86 | 87 | ```bash 88 | expo start --dev-client 89 | ``` 90 | 91 | 4. The above step will print instructions on how to launch the app on phone or simulator. For iOS siulator you'll need to press "i", for Android press "a", and if you'd like to run the app on a physical device you'll need to scan the QR code that will be displayed on the command line output. 92 | 93 | ## Next step 94 | 95 | **Go to: [Animated Reactions](./src/AnimatedReactions/)** 96 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "appjs-workshop", 4 | "slug": "appjs-workshop", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "updates": { 15 | "fallbackToCacheTimeout": 0 16 | }, 17 | "assetBundlePatterns": ["**/*"], 18 | "ios": { 19 | "supportsTablet": true, 20 | "bundleIdentifier": "co.appjs.workshop22" 21 | }, 22 | "android": { 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/adaptive-icon.png", 25 | "backgroundColor": "#FFFFFF" 26 | }, 27 | "package": "co.appjs.workshop22" 28 | }, 29 | "web": { 30 | "favicon": "./assets/favicon.png" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/assets/favicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: ['react-native-reanimated/plugin'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 0.52.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal", 9 | "ios": { 10 | "enterpriseProvisioning": "universal", 11 | "simulator": true 12 | } 13 | }, 14 | "simulator": { 15 | "developmentClient": true, 16 | "ios": { 17 | "simulator": true 18 | } 19 | }, 20 | "preview": { 21 | "distribution": "internal" 22 | }, 23 | "production": {} 24 | }, 25 | "submit": { 26 | "production": {} 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appjs-workshop", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "eject": "expo eject", 11 | "lint": "eslint . --ext .ts,.tsx --max-warnings 0" 12 | }, 13 | "dependencies": { 14 | "@expo/vector-icons": "^13.0.0", 15 | "@react-native-async-storage/async-storage": "~1.17.3", 16 | "@react-navigation/native": "^6.0.10", 17 | "@react-navigation/native-stack": "^6.6.2", 18 | "@react-navigation/stack": "^6.2.1", 19 | "@shopify/react-native-skia": "^0.1.130", 20 | "adaptive-bezier-curve": "^1.0.3", 21 | "expo": "~45.0.0", 22 | "expo-asset": "~8.5.0", 23 | "expo-dev-client": "~0.9.6", 24 | "expo-font": "~10.1.0", 25 | "expo-image-picker": "~13.1.1", 26 | "expo-splash-screen": "~0.15.1", 27 | "expo-status-bar": "~1.3.0", 28 | "react": "17.0.2", 29 | "react-dom": "17.0.2", 30 | "react-native": "0.68.2", 31 | "react-native-gesture-handler": "~2.2.1", 32 | "react-native-reanimated": "~2.8.0", 33 | "react-native-safe-area-context": "4.2.4", 34 | "react-native-screens": "~3.11.1", 35 | "react-native-web": "0.17.7" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.12.9", 39 | "@types/react": "17.0.39", 40 | "@types/react-native": "0.67.7", 41 | "eslint": "^7.9.0", 42 | "eslint-config-react-native-wcandillon": "^3.8.0", 43 | "typescript": "~4.3.5" 44 | }, 45 | "resolutions": { 46 | "@types/react": "17.0.30" 47 | }, 48 | "private": true 49 | } 50 | -------------------------------------------------------------------------------- /src/AllTheGestures/AllTheGestures.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Icon from "@expo/vector-icons/MaterialIcons"; 5 | import Animated, { 6 | useAnimatedStyle, 7 | useSharedValue, 8 | } from "react-native-reanimated"; 9 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 10 | 11 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 12 | 13 | function Movable({ children }: { children: ReactNode }) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export function AllTheGestures() { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/AllTheGestures/README.md: -------------------------------------------------------------------------------- 1 | # Dragging, rotating, and pinchin + custom Layout Animation combo 2 | 3 | In this section we build a "canvas" canvas component, where stickers can be added, moved, and rotated. 4 | 5 | ## Step 1 – Building canvas 6 | 7 | ![3 1 mp4](https://user-images.githubusercontent.com/726445/172513362-3f7dab44-fb09-4085-a558-71ba5515a237.gif) 8 | 9 |
10 | [1] Create a full-screen “canvas” component (just a View for now) and render one of the stickers in it 11 | 12 | Start with the following component as a base: 13 | 14 | ```js 15 | export function AllTheGestures() { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | ``` 23 | 24 |

25 | 26 |
27 | [2] Add GestureDetector with pan gesture to it, such that you can move the icon around the canvas – use translateX and translateY + onChange event callback for the gesture 28 | 29 | Let us create a separate component called `Movable` that implements this logic. 30 | We start by defining it and rendering gesture detector: 31 | 32 | ```js 33 | function Movable({ children }: { children: ReactNode }) { 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | } 40 | ``` 41 | 42 | Now, let us create a shared value that'd store the position and use it in `useAnimatedStyle`: 43 | 44 | ```js 45 | const position = useSharedValue({ x: 0, y: 0 }); 46 | const styles = useAnimatedStyle(() => { 47 | return { 48 | transform: [ 49 | { translateX: position.value.x }, 50 | { translateY: position.value.y }, 51 | ], 52 | }; 53 | }); 54 | ``` 55 | 56 | Remember to connect styles to the anikmated component that we want to transform: 57 | 58 | ```js 59 | return ( 60 | 61 | 62 | {children} 63 | 64 | 65 | ); 66 | ``` 67 | 68 | Finally, we define new `Pan` gesture instance and implement `onChange` handler to offset the position: 69 | 70 | ```js 71 | const pan = Gesture.Pan().onChange((e) => { 72 | const { x, y } = position.value; 73 | position.value = { x: x + e.changeX, y: y + e.changeY }; 74 | }); 75 | ``` 76 | 77 | Again, we need to remember to pass the created gesture as a configuration to `GestureDetector`: 78 | 79 | ```js 80 | return ( 81 | 82 | 83 | {children} 84 | 85 | 86 | ); 87 | ``` 88 | 89 |

90 | 91 | ## Step 2 – Using matrices 92 | 93 | ![3 1 mp4](https://user-images.githubusercontent.com/726445/172513391-3487879c-bdf6-40cc-afe1-3d7a86c0c519.gif) 94 | 95 |
96 | [1] Refactor movable component to use matrix – this will allow for more complex modifications in the future (check out hints for matrix math code) 97 | 98 | Rename `position` shared value to `matrix`, and initialize it with identity matrix created with `createIdentityMatrix()` from `matrixMath.ts` helper file. 99 | 100 | ```js 101 | const matrix = useSharedValue(createIdentityMatrix()); 102 | ``` 103 | 104 | Update animated styles to use `matrix` transform instead of separate `translateX` and `translateY` transforms: 105 | 106 | ```js 107 | const styles = useAnimatedStyle(() => { 108 | return { 109 | transform: [{ matrix: matrix.value }], 110 | }; 111 | }); 112 | ``` 113 | 114 | Finally, in gesture `onChange` callback, we can now use `translate3d` helper method that takes current transform matrix and new offsets and outpus new combined matrix translated by the provided 3d vecrtor: 115 | 116 | ```js 117 | const pan = Gesture.Pan().onChange((e) => { 118 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 119 | }); 120 | ``` 121 | 122 |

123 | 124 | ## Step 3 – Scale and rotate 125 | 126 | ![3 3 mp4](https://user-images.githubusercontent.com/726445/172513412-4b8c369f-7ef8-4eda-a326-f046027578d5.gif) 127 | 128 |
129 | [1] Add pinch and rotate gesture to control size and orientation of the icon 130 | 131 | Creane new `Rotate` gesture instance and use `rotateZ` helper method to transform the matrix in `onChange` handler: 132 | 133 | ```js 134 | const rotate = Gesture.Rotation().onChange((e) => { 135 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 136 | }); 137 | ``` 138 | 139 | Similarily, for the scale gesture create new `Pinch` instance and use `scale3d` helper method: 140 | 141 | ```js 142 | const scale = Gesture.Pinch().onChange((e) => { 143 | matrix.value = scale3d( 144 | matrix.value, 145 | e.scaleChange, 146 | e.scaleChange, 147 | 1, 148 | 0, 149 | 0, 150 | 0 151 | ); 152 | }); 153 | ``` 154 | 155 | Finally, connect all the gestures together into `GestureDetector` component: 156 | 157 | ```js 158 | 159 | ``` 160 | 161 |

162 | 163 |
164 | [BONUS 1] Add two-finger-pan gesture to rotate along X or Y axis (3D rotation) 165 | 166 | Take a look at `rotateZ` method and try to come up with a symmetric version that performs the rotation along the X or Y axis. 167 | Note that with rotation, the event data contains an angle, while with two finger pan you'll be getting number that corresponds to distance. 168 | 169 |

170 | 171 | ## Step 4 – Items collection 172 | 173 | ![3 4 mp4](https://user-images.githubusercontent.com/726445/172513438-b1c399e8-b921-4970-853b-fdc93e27f246.gif) 174 | 175 |
176 | [1] Bring the picker component built in the previous excercise and render it at the bottom of the 177 | 178 | In this step, you can copy code from [GestureBasedPicker.tsx](../GestureBasedPicker/steps/Step5.tsx) – only copy `Toolbar` component and things it depends on. 179 | 180 | Render the toolbar at the bottom of the screen such that it is also centered: 181 | 182 | ```js 183 | export function AllTheGestures() { 184 | return ( 185 | 186 | 187 | 188 | 189 | 196 | 197 | 198 | 199 | ); 200 | } 201 | ``` 202 | 203 |

204 | 205 |
206 | [2] Refactor canvas component to keep a list of displayed items in a state variable 207 | 208 | Add local state that keeps a list of added items: 209 | 210 | ```js 211 | const [items, setItems] = useState([]); 212 | ``` 213 | 214 | Then render the items: 215 | 216 | ```js 217 | return ( 218 | 219 | {items.map((item, index) => ( 220 | {item} 221 | ))} 222 | 229 | 230 | 231 | 232 | ); 233 | ``` 234 | 235 |

236 | 237 |
238 | [3] Make toolbar buttons add new items to the canvas on click – both on tap and on long press. Use measure method to pass icon's dimensions such that we can add bigger strickers by long pressing them 239 | 240 | First, let us define a method that adds new items to the list. We will then pass that method down to individual icons for them to call it. The method will take the basic icon configuration like shape name and color, and also the frame of the icon that we will use to determine the size of it: 241 | 242 | ```js 243 | const addItem = (icon, color, frame) => { 244 | setItems([ 245 | ...items, 246 | , 247 | ]); 248 | }; 249 | ``` 250 | 251 | Refactor `Toolbar` and `Sticker` component to take `addItem` callback as prop, and pass the method down to each of the `Sticker` instaces. 252 | 253 | In order to call `addItem` we need to get the dimensions of the icon that is clicked. 254 | For this purpose we will use `measure` method from `react-naitve-reanimated`. 255 | In order to call `measure`, we need to first define a "ref" object with `useAnimatedRef`: 256 | 257 | ```js 258 | const iconRef = useAnimatedRef(); 259 | ``` 260 | 261 | Later, we assign it to the component we want to measure using React's `ref` property: 262 | 263 | ```js 264 | return ( 265 | 266 | 273 | 274 | ); 275 | ``` 276 | 277 | Now we can add a call to `addItem` to `onEnd` callbacks for `Tap` and `LongPress` gestures. 278 | However, since gesture callbacks run on ths UI thread, we need to use `runOnJS` helper from `react-native-reanimated` to execute that callback. 279 | To avoid code duplication, we can define a helper method that measures the icon and calls the callback: 280 | 281 | ```js 282 | function addItemFromUI() { 283 | 'worklet'; 284 | const size = measure(iconRef); 285 | runOnJS(addItem)(iconName, color, { 286 | x: size.pageX, 287 | y: size.pageY, 288 | width: size.width, 289 | height: size.height, 290 | }); 291 | } 292 | ``` 293 | 294 | Finally, we can call this helper method from `onEnd` gesture callbacks: 295 | 296 | ```js 297 | const tap = Gesture.Tap().onEnd(() => { 298 | addItemFromUI(); 299 | }); 300 | const longPress = Gesture.Tap() 301 | .maxDuration(1e8) 302 | .onBegin(() => { 303 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 304 | }) 305 | .onEnd(() => { 306 | addItemFromUI(); 307 | }) 308 | .onFinalize(() => { 309 | scale.value = withSpring(1); 310 | }); 311 | ``` 312 | 313 |

314 | 315 | ## Step 5 – Final Layout Animations touch 316 | 317 | ![3 5 mp4](https://user-images.githubusercontent.com/726445/172513465-5a1e3866-76c1-4694-a735-7b1666ea182e.gif) 318 | 319 |
320 | [1] Add custom entering animation for the new items from canvas such that they slide in from the position on the toolbar (use previously measured dimensions to get the initial position for the animation) 321 | 322 | For this custom entering animation we will animate `originX` and `originY` properties. 323 | We want the view to start animate from the toolbar and then move to the final destination on the canvas. 324 | Since we measure the icon, we know it's top-left corner position in relative to the screen. 325 | However, `originX` and `originY` correspond to the center position of the view relative to its parent. 326 | In order to convert between these two, we can use `targetGlobalOriginX` attribute that the animation callback receives in the `values` object, and that corresponds to the center position of the view but relative to the screen. 327 | 328 | Now, the starting center of the view relative to the canvas item parent can be calculated as: 329 | 330 | ```js 331 | const startX = 332 | x - values.targetGlobalOriginX - (values.targetWidth - width) / 2; 333 | ``` 334 | 335 | Similarily, for the Y coordinate we get: 336 | 337 | ```js 338 | const startY = 339 | y - values.targetGlobalOriginY - (values.targetHeight - height) / 2; 340 | ``` 341 | 342 | As a result, the initial values for the entering animation can be defined as follows: 343 | 344 | ```js 345 | const initialValues = { 346 | originX: startX, 347 | originY: startY, 348 | }; 349 | ``` 350 | 351 | Now, we need to specify how the animation should be performed. 352 | This is defined by the `animations` object that consists of keys corresponding to the prop being animated. 353 | In our case we want a simple timing animation to the view's target positions: 354 | 355 | ```js 356 | const config = { duration: 600 }; 357 | const animations = { 358 | originX: withTiming(values.targetOriginX, config), 359 | originY: withTiming(values.targetOriginY, config), 360 | }; 361 | ``` 362 | 363 | Now, the complete entering animation should look as follows: 364 | 365 | ```js 366 | function moveInFrom({ x, y, width, height }) { 367 | return (values) => { 368 | 'worklet'; 369 | const startX = 370 | x - values.targetGlobalOriginX - (values.targetWidth - width) / 2; 371 | const startY = 372 | y - values.targetGlobalOriginY - (values.targetHeight - height) / 2; 373 | const config = { duration: 600 }; 374 | const animations = { 375 | originX: withTiming(values.targetOriginX, config), 376 | originY: withTiming(values.targetOriginY, config), 377 | }; 378 | const initialValues = { 379 | originX: startX, 380 | originY: startY, 381 | }; 382 | return { initialValues, animations }; 383 | }; 384 | } 385 | ``` 386 | 387 | We can now use this animation for our new component that we add to the items state array: 388 | 389 | ```js 390 | const addItem = (icon: string, color: ColorValue, frame: Frame) => { 391 | setItems([ 392 | ...items, 393 | , 399 | ]); 400 | }; 401 | ``` 402 | 403 |

404 | 405 |
406 | [BONUS 1] Make the slide-in animation go along some curve and not just along the straight path 407 | 408 | 😈 409 | 410 |

411 | 412 | ## Next step 413 | 414 | **Go to: [Drawings – React Logo](../ReactLogo/)** 415 | -------------------------------------------------------------------------------- /src/AllTheGestures/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AllTheGestures"; 2 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React, { useState, useCallback } from "react"; 3 | import type { ColorValue } from "react-native"; 4 | import { View } from "react-native"; 5 | import Icon from "@expo/vector-icons/MaterialIcons"; 6 | import Animated, { 7 | withTiming, 8 | useAnimatedStyle, 9 | useSharedValue, 10 | withSpring, 11 | useAnimatedRef, 12 | withDelay, 13 | runOnJS, 14 | measure, 15 | } from "react-native-reanimated"; 16 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 17 | 18 | import { 19 | createIdentityMatrix, 20 | rotateZ, 21 | scale3d, 22 | translate3d, 23 | } from "../components/matrixMath"; 24 | 25 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 26 | 27 | type Frame = { x: number; y: number; width: number; height: number }; 28 | type AddItemCallback = (icon: string, color: ColorValue, frame: Frame) => void; 29 | 30 | const WIDTH = 50; 31 | 32 | function Sticker({ 33 | iconName, 34 | color, 35 | addItem, 36 | }: { 37 | iconName: string; 38 | color: ColorValue; 39 | addItem: AddItemCallback; 40 | }) { 41 | const iconRef = useAnimatedRef(); 42 | function addItemFromUI() { 43 | "worklet"; 44 | const size = measure(iconRef); 45 | runOnJS(addItem)(iconName, color, { 46 | x: size.pageX, 47 | y: size.pageY, 48 | width: size.width, 49 | height: size.height, 50 | }); 51 | } 52 | const scale = useSharedValue(1); 53 | const tap = Gesture.Tap().onEnd(() => { 54 | addItemFromUI(); 55 | }); 56 | const longPress = Gesture.Tap() 57 | .maxDuration(1e8) 58 | .onBegin(() => { 59 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 60 | }) 61 | .onEnd(() => { 62 | addItemFromUI(); 63 | }) 64 | .onFinalize(() => { 65 | scale.value = withSpring(1); 66 | }); 67 | const styles = useAnimatedStyle(() => { 68 | return { 69 | transform: [{ scale: scale.value }], 70 | zIndex: scale.value > 1 ? 100 : 1, 71 | }; 72 | }); 73 | return ( 74 | 75 | 82 | 83 | ); 84 | } 85 | 86 | const STICKERS_COUNT = 4; 87 | 88 | function snapPoint(x: number, vx: number) { 89 | "worklet"; 90 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 91 | const position = Math.max( 92 | -STICKERS_COUNT + 1, 93 | Math.min(0, Math.round(tossX / WIDTH)) 94 | ); 95 | return position * WIDTH; 96 | } 97 | 98 | function Toolbar({ addItem }: { addItem: AddItemCallback }) { 99 | const offsetY = useSharedValue(0); 100 | const pan = Gesture.Pan() 101 | .onChange((e) => { 102 | offsetY.value += e.changeX; 103 | }) 104 | .onEnd((e) => { 105 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 106 | velocity: e.velocityX, 107 | }); 108 | }); 109 | const styles = useAnimatedStyle(() => { 110 | return { 111 | transform: [{ translateX: offsetY.value }], 112 | }; 113 | }); 114 | return ( 115 | 121 | 122 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 139 | 140 | ); 141 | } 142 | 143 | function Movable({ children }: { children: ReactNode }) { 144 | const matrix = useSharedValue(createIdentityMatrix()); 145 | const styles = useAnimatedStyle(() => { 146 | return { 147 | transform: [{ matrix: matrix.value }], 148 | }; 149 | }); 150 | 151 | const pan = Gesture.Pan().onChange((e) => { 152 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 153 | }); 154 | 155 | const rotate = Gesture.Rotation().onChange((e) => { 156 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 157 | }); 158 | 159 | const scale = Gesture.Pinch().onChange((e) => { 160 | matrix.value = scale3d( 161 | matrix.value, 162 | e.scaleChange, 163 | e.scaleChange, 164 | 1, 165 | 0, 166 | 0, 167 | 0 168 | ); 169 | }); 170 | 171 | return ( 172 | 173 | 174 | 175 | {children} 176 | 177 | 178 | 179 | ); 180 | } 181 | 182 | function moveInFrom({ x, y, width, height }: Frame) { 183 | return (values) => { 184 | "worklet"; 185 | const startX = 186 | x - values.targetGlobalOriginX - (values.targetWidth - width) / 2; 187 | const startY = 188 | y - values.targetGlobalOriginY - (values.targetHeight - height) / 2; 189 | const config = { duration: 600 }; 190 | const animations = { 191 | originX: withTiming(values.targetOriginX, config), 192 | originY: withTiming(values.targetOriginY, config), 193 | }; 194 | const initialValues = { 195 | originX: startX, 196 | originY: startY, 197 | }; 198 | return { initialValues, animations }; 199 | }; 200 | } 201 | 202 | export function AllTheGestures() { 203 | const [items, setItems] = useState([] as ReactNode[]); 204 | 205 | const addItem = (icon: string, color: ColorValue, frame: Frame) => { 206 | setItems([ 207 | ...items, 208 | , 214 | ]); 215 | }; 216 | 217 | return ( 218 | 219 | {items.map((item, index) => ( 220 | {item} 221 | ))} 222 | 230 | 231 | 232 | 233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View } from "react-native"; 3 | import Icon from "@expo/vector-icons/MaterialIcons"; 4 | import Animated, { 5 | useAnimatedStyle, 6 | useSharedValue, 7 | } from "react-native-reanimated"; 8 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 9 | 10 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 11 | 12 | function Movable({ children }: { children: ReactNode }) { 13 | const position = useSharedValue({ x: 0, y: 0 }); 14 | const styles = useAnimatedStyle(() => { 15 | return { 16 | transform: [ 17 | { translateX: position.value.x }, 18 | { translateY: position.value.y }, 19 | ], 20 | }; 21 | }); 22 | 23 | const pan = Gesture.Pan().onChange((e) => { 24 | const { x, y } = position.value; 25 | position.value = { x: x + e.changeX, y: y + e.changeY }; 26 | }); 27 | 28 | return ( 29 | 30 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | 37 | export function AllTheGestures() { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Step2.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Icon from "@expo/vector-icons/MaterialIcons"; 5 | import Animated, { 6 | useAnimatedStyle, 7 | useSharedValue, 8 | } from "react-native-reanimated"; 9 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 10 | 11 | import { createIdentityMatrix, translate3d } from "../components/matrixMath"; 12 | 13 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 14 | 15 | function Movable({ children }: { children: ReactNode }) { 16 | const matrix = useSharedValue(createIdentityMatrix()); 17 | const styles = useAnimatedStyle(() => { 18 | return { 19 | transform: [{ matrix: matrix.value }], 20 | }; 21 | }); 22 | 23 | const pan = Gesture.Pan().onChange((e) => { 24 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 25 | }); 26 | 27 | return ( 28 | 29 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | 36 | export function AllTheGestures() { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Step3.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Icon from "@expo/vector-icons/MaterialIcons"; 5 | import Animated, { 6 | useAnimatedStyle, 7 | useSharedValue, 8 | } from "react-native-reanimated"; 9 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 10 | 11 | import { 12 | createIdentityMatrix, 13 | rotateZ, 14 | scale3d, 15 | translate3d, 16 | } from "../components/matrixMath"; 17 | 18 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 19 | 20 | function Movable({ children }: { children: ReactNode }) { 21 | const matrix = useSharedValue(createIdentityMatrix()); 22 | const styles = useAnimatedStyle(() => { 23 | return { 24 | transform: [{ matrix: matrix.value }], 25 | }; 26 | }); 27 | 28 | const pan = Gesture.Pan().onChange((e) => { 29 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 30 | }); 31 | 32 | const rotate = Gesture.Rotation().onChange((e) => { 33 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 34 | }); 35 | 36 | const scale = Gesture.Pinch().onChange((e) => { 37 | matrix.value = scale3d( 38 | matrix.value, 39 | e.scaleChange, 40 | e.scaleChange, 41 | 1, 42 | 0, 43 | 0, 44 | 0 45 | ); 46 | }); 47 | 48 | return ( 49 | 50 | 51 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export function AllTheGestures() { 60 | return ( 61 | 62 | 63 | 64 | 65 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Step4.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React, { useState } from "react"; 3 | import type { ColorValue } from "react-native"; 4 | import { View } from "react-native"; 5 | import Icon from "@expo/vector-icons/MaterialIcons"; 6 | import Animated, { 7 | withTiming, 8 | useAnimatedStyle, 9 | useSharedValue, 10 | withSpring, 11 | useAnimatedRef, 12 | withDelay, 13 | runOnJS, 14 | measure, 15 | } from "react-native-reanimated"; 16 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 17 | 18 | import { 19 | createIdentityMatrix, 20 | rotateZ, 21 | scale3d, 22 | translate3d, 23 | } from "../components/matrixMath"; 24 | 25 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 26 | 27 | type Frame = { x: number; y: number; width: number; height: number }; 28 | type AddItemCallback = (icon: string, color: ColorValue, frame: Frame) => void; 29 | 30 | const WIDTH = 50; 31 | 32 | function Sticker({ 33 | iconName, 34 | color, 35 | addItem, 36 | }: { 37 | iconName: string; 38 | color: ColorValue; 39 | addItem: AddItemCallback; 40 | }) { 41 | const iconRef = useAnimatedRef(); 42 | function addItemFromUI() { 43 | "worklet"; 44 | const size = measure(iconRef); 45 | runOnJS(addItem)(iconName, color, { 46 | x: size.pageX, 47 | y: size.pageY, 48 | width: size.width, 49 | height: size.height, 50 | }); 51 | } 52 | const scale = useSharedValue(1); 53 | const tap = Gesture.Tap().onEnd(() => { 54 | addItemFromUI(); 55 | }); 56 | const longPress = Gesture.Tap() 57 | .maxDuration(1e8) 58 | .onBegin(() => { 59 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 60 | }) 61 | .onEnd(() => { 62 | addItemFromUI(); 63 | }) 64 | .onFinalize(() => { 65 | scale.value = withSpring(1); 66 | }); 67 | const styles = useAnimatedStyle(() => { 68 | return { 69 | transform: [{ scale: scale.value }], 70 | zIndex: scale.value > 1 ? 100 : 1, 71 | }; 72 | }); 73 | return ( 74 | 75 | 82 | 83 | ); 84 | } 85 | 86 | const STICKERS_COUNT = 4; 87 | 88 | function snapPoint(x: number, vx: number) { 89 | "worklet"; 90 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 91 | const position = Math.max( 92 | -STICKERS_COUNT + 1, 93 | Math.min(0, Math.round(tossX / WIDTH)) 94 | ); 95 | return position * WIDTH; 96 | } 97 | 98 | function Toolbar({ addItem }: { addItem: AddItemCallback }) { 99 | const offsetY = useSharedValue(0); 100 | const pan = Gesture.Pan() 101 | .onChange((e) => { 102 | offsetY.value += e.changeX; 103 | }) 104 | .onEnd((e) => { 105 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 106 | velocity: e.velocityX, 107 | }); 108 | }); 109 | const styles = useAnimatedStyle(() => { 110 | return { 111 | transform: [{ translateX: offsetY.value }], 112 | }; 113 | }); 114 | return ( 115 | 121 | 122 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 139 | 140 | ); 141 | } 142 | 143 | function Movable({ children }: { children: ReactNode }) { 144 | const matrix = useSharedValue(createIdentityMatrix()); 145 | const styles = useAnimatedStyle(() => { 146 | return { 147 | transform: [{ matrix: matrix.value }], 148 | }; 149 | }); 150 | 151 | const pan = Gesture.Pan().onChange((e) => { 152 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 153 | }); 154 | 155 | const rotate = Gesture.Rotation().onChange((e) => { 156 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 157 | }); 158 | 159 | const scale = Gesture.Pinch().onChange((e) => { 160 | matrix.value = scale3d( 161 | matrix.value, 162 | e.scaleChange, 163 | e.scaleChange, 164 | 1, 165 | 0, 166 | 0, 167 | 0 168 | ); 169 | }); 170 | 171 | return ( 172 | 173 | 174 | 175 | {children} 176 | 177 | 178 | 179 | ); 180 | } 181 | 182 | export function AllTheGestures() { 183 | const [items, setItems] = useState([] as ReactNode[]); 184 | 185 | const addItem = (icon: string, color: ColorValue, frame: Frame) => { 186 | setItems([ 187 | ...items, 188 | , 189 | ]); 190 | }; 191 | 192 | return ( 193 | 194 | {items.map((item, index) => ( 195 | {item} 196 | ))} 197 | 205 | 206 | 207 | 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/AllTheGestures/steps/Step5.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import React, { useState, useCallback } from "react"; 3 | import type { ColorValue } from "react-native"; 4 | import { View } from "react-native"; 5 | import Icon from "@expo/vector-icons/MaterialIcons"; 6 | import Animated, { 7 | withTiming, 8 | useAnimatedStyle, 9 | useSharedValue, 10 | withSpring, 11 | useAnimatedRef, 12 | withDelay, 13 | runOnJS, 14 | measure, 15 | } from "react-native-reanimated"; 16 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 17 | 18 | import { 19 | createIdentityMatrix, 20 | rotateZ, 21 | scale3d, 22 | translate3d, 23 | } from "../components/matrixMath"; 24 | 25 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 26 | 27 | type Frame = { x: number; y: number; width: number; height: number }; 28 | type AddItemCallback = (icon: string, color: ColorValue, frame: Frame) => void; 29 | 30 | const WIDTH = 50; 31 | 32 | function Sticker({ 33 | iconName, 34 | color, 35 | addItem, 36 | }: { 37 | iconName: string; 38 | color: ColorValue; 39 | addItem: AddItemCallback; 40 | }) { 41 | const iconRef = useAnimatedRef(); 42 | function addItemFromUI() { 43 | "worklet"; 44 | const size = measure(iconRef); 45 | runOnJS(addItem)(iconName, color, { 46 | x: size.pageX, 47 | y: size.pageY, 48 | width: size.width, 49 | height: size.height, 50 | }); 51 | } 52 | const scale = useSharedValue(1); 53 | const tap = Gesture.Tap().onEnd(() => { 54 | addItemFromUI(); 55 | }); 56 | const longPress = Gesture.Tap() 57 | .maxDuration(1e8) 58 | .onBegin(() => { 59 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 60 | }) 61 | .onEnd(() => { 62 | addItemFromUI(); 63 | }) 64 | .onFinalize(() => { 65 | scale.value = withSpring(1); 66 | }); 67 | const styles = useAnimatedStyle(() => { 68 | return { 69 | transform: [{ scale: scale.value }], 70 | zIndex: scale.value > 1 ? 100 : 1, 71 | }; 72 | }); 73 | return ( 74 | 75 | 82 | 83 | ); 84 | } 85 | 86 | const STICKERS_COUNT = 4; 87 | 88 | function snapPoint(x: number, vx: number) { 89 | "worklet"; 90 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 91 | const position = Math.max( 92 | -STICKERS_COUNT + 1, 93 | Math.min(0, Math.round(tossX / WIDTH)) 94 | ); 95 | return position * WIDTH; 96 | } 97 | 98 | function Toolbar({ addItem }: { addItem: AddItemCallback }) { 99 | const offsetY = useSharedValue(0); 100 | const pan = Gesture.Pan() 101 | .onChange((e) => { 102 | offsetY.value += e.changeX; 103 | }) 104 | .onEnd((e) => { 105 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 106 | velocity: e.velocityX, 107 | }); 108 | }); 109 | const styles = useAnimatedStyle(() => { 110 | return { 111 | transform: [{ translateX: offsetY.value }], 112 | }; 113 | }); 114 | return ( 115 | 121 | 122 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 139 | 140 | ); 141 | } 142 | 143 | function Movable({ children }: { children: ReactNode }) { 144 | const matrix = useSharedValue(createIdentityMatrix()); 145 | const styles = useAnimatedStyle(() => { 146 | return { 147 | transform: [{ matrix: matrix.value }], 148 | }; 149 | }); 150 | 151 | const pan = Gesture.Pan().onChange((e) => { 152 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 153 | }); 154 | 155 | const rotate = Gesture.Rotation().onChange((e) => { 156 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 157 | }); 158 | 159 | const scale = Gesture.Pinch().onChange((e) => { 160 | matrix.value = scale3d( 161 | matrix.value, 162 | e.scaleChange, 163 | e.scaleChange, 164 | 1, 165 | 0, 166 | 0, 167 | 0 168 | ); 169 | }); 170 | 171 | return ( 172 | 173 | 174 | 175 | {children} 176 | 177 | 178 | 179 | ); 180 | } 181 | 182 | function moveInFrom({ x, y, width, height }: Frame) { 183 | return (values) => { 184 | "worklet"; 185 | const startX = 186 | x - values.targetGlobalOriginX - (values.targetWidth - width) / 2; 187 | const startY = 188 | y - values.targetGlobalOriginY - (values.targetHeight - height) / 2; 189 | const config = { duration: 600 }; 190 | const animations = { 191 | originX: withTiming(values.targetOriginX, config), 192 | originY: withTiming(values.targetOriginY, config), 193 | }; 194 | const initialValues = { 195 | originX: startX, 196 | originY: startY, 197 | }; 198 | return { initialValues, animations }; 199 | }; 200 | } 201 | 202 | export function AllTheGestures() { 203 | const [items, setItems] = useState([] as ReactNode[]); 204 | 205 | const addItem = (icon: string, color: ColorValue, frame: Frame) => { 206 | setItems([ 207 | ...items, 208 | , 214 | ]); 215 | }; 216 | 217 | return ( 218 | 219 | {items.map((item, index) => ( 220 | {item} 221 | ))} 222 | 230 | 231 | 232 | 233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/AnimatedReactions/AnimatedReactions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Animated, { 3 | withTiming, 4 | useAnimatedStyle, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { Pressable } from "react-native"; 8 | 9 | import { CenterScreen } from "../components/CenterScreen"; 10 | 11 | function Heart() { 12 | return ( 13 | {}}> 14 | 17 | 18 | ); 19 | } 20 | 21 | export function AnimatedReactions() { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/AnimatedReactions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AnimatedReactions"; 2 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | BounceIn, 5 | Easing, 6 | useAnimatedStyle, 7 | useSharedValue, 8 | withTiming, 9 | ZoomOut, 10 | } from "react-native-reanimated"; 11 | import { Pressable } from "react-native"; 12 | 13 | import { CenterScreen } from "../components/CenterScreen"; 14 | 15 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 16 | 17 | const VX_MAX = 35; 18 | const VY_MAX = 80; 19 | 20 | function randomSpeed() { 21 | return { 22 | vx: Math.random() * 2 * VX_MAX - VX_MAX, 23 | vy: Math.random() * VY_MAX, 24 | angular: Math.random() * Math.PI - Math.PI / 2, 25 | }; 26 | } 27 | 28 | function FlyingHeart() { 29 | const time = useSharedValue(0); 30 | const { vx, vy, angular } = useRef(randomSpeed()).current; 31 | const duration = 30; 32 | const g = 15; 33 | 34 | const styles = useAnimatedStyle(() => { 35 | const t = time.value / 1000; 36 | const x = vx * t; 37 | const y = vy * t + (-g * t * t) / 2; 38 | const angle = angular * t; 39 | return { 40 | transform: [ 41 | { translateX: x }, 42 | { translateY: -y }, 43 | { rotateZ: `${angle}rad` }, 44 | ], 45 | }; 46 | }); 47 | useEffect(() => { 48 | time.value = withTiming(duration * 1000, { 49 | duration: (duration * 1000) / 10, 50 | easing: Easing.linear, 51 | }); 52 | }, []); 53 | return ( 54 | 60 | ); 61 | } 62 | 63 | function ExplodingHearts({ count = 20 }) { 64 | return ( 65 | <> 66 | {Array.from({ length: count }).map((_, index) => { 67 | return ; 68 | })} 69 | 70 | ); 71 | } 72 | 73 | function Heart() { 74 | const [selected, setSelected] = useState(false); 75 | 76 | return ( 77 | <> 78 | setSelected(!selected)}> 79 | 87 | 88 | {selected && } 89 | 90 | ); 91 | } 92 | 93 | export function AnimatedReactions() { 94 | return ( 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Animated, { 3 | withTiming, 4 | useAnimatedStyle, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { Pressable } from "react-native"; 8 | 9 | import { CenterScreen } from "../components/CenterScreen"; 10 | 11 | function Heart() { 12 | const scale = useSharedValue(1); 13 | const styles = useAnimatedStyle(() => { 14 | return { 15 | transform: [{ scale: scale.value }], 16 | }; 17 | }); 18 | 19 | return ( 20 | { 22 | scale.value = withTiming(scale.value + 0.5); 23 | }} 24 | > 25 | 28 | 29 | ); 30 | } 31 | 32 | export function AnimatedReactions() { 33 | return ( 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | withTiming, 5 | useAnimatedStyle, 6 | useAnimatedProps, 7 | useSharedValue, 8 | } from "react-native-reanimated"; 9 | import { Pressable } from "react-native"; 10 | 11 | import { CenterScreen } from "../components/CenterScreen"; 12 | 13 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 14 | 15 | function Heart() { 16 | const scale = useSharedValue(1); 17 | const color = useSharedValue("#aaa"); 18 | const styles = useAnimatedStyle(() => { 19 | return { 20 | transform: [{ scale: scale.value }], 21 | }; 22 | }); 23 | 24 | const props = useAnimatedProps(() => { 25 | return { 26 | color: color.value, 27 | }; 28 | }); 29 | 30 | return ( 31 | { 33 | scale.value = withTiming(1.5); 34 | color.value = withTiming("#ffaaa8"); 35 | }} 36 | > 37 | 43 | 44 | ); 45 | } 46 | 47 | export function AnimatedReactions() { 48 | return ( 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step3.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | withTiming, 5 | useAnimatedStyle, 6 | useAnimatedProps, 7 | } from "react-native-reanimated"; 8 | import { Pressable } from "react-native"; 9 | 10 | import { CenterScreen } from "../components/CenterScreen"; 11 | 12 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 13 | 14 | function Heart() { 15 | const [selected, setSelected] = useState(false); 16 | 17 | const styles = useAnimatedStyle(() => { 18 | return { 19 | transform: [{ scale: withTiming(selected ? 1.5 : 1) }], 20 | }; 21 | }); 22 | 23 | const props = useAnimatedProps(() => { 24 | return { 25 | color: withTiming(selected ? "#ffaaa8" : "#aaa"), 26 | }; 27 | }); 28 | 29 | return ( 30 | setSelected(!selected)}> 31 | 37 | 38 | ); 39 | } 40 | 41 | export function AnimatedReactions() { 42 | return ( 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step4.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | withTiming, 5 | useAnimatedStyle, 6 | useSharedValue, 7 | useAnimatedProps, 8 | useAnimatedReaction, 9 | withSequence, 10 | } from "react-native-reanimated"; 11 | import { Pressable } from "react-native"; 12 | 13 | import { CenterScreen } from "../components/CenterScreen"; 14 | 15 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 16 | 17 | function Heart() { 18 | const [selected, setSelected] = useState(false); 19 | const scale = useSharedValue(1); 20 | 21 | useAnimatedReaction( 22 | () => selected, 23 | (isSelected) => { 24 | if (isSelected) { 25 | scale.value = withSequence(withTiming(1.5), withTiming(1)); 26 | } 27 | } 28 | ); 29 | 30 | const styles = useAnimatedStyle(() => { 31 | return { 32 | transform: [{ scale: scale.value }], 33 | }; 34 | }); 35 | 36 | const props = useAnimatedProps(() => { 37 | return { 38 | color: withTiming(selected ? "#ffaaa8" : "#aaa"), 39 | }; 40 | }); 41 | 42 | return ( 43 | setSelected(!selected)}> 44 | 50 | 51 | ); 52 | } 53 | 54 | export function AnimatedReactions() { 55 | return ( 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step5.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { BounceIn, Keyframe, ZoomOut } from "react-native-reanimated"; 4 | import { Pressable } from "react-native"; 5 | 6 | import { CenterScreen } from "../components/CenterScreen"; 7 | 8 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 9 | 10 | const BetterBounce = new Keyframe({ 11 | 0: { transform: [{ scale: 1 }] }, 12 | 45: { transform: [{ scale: 2 }] }, 13 | 100: { transform: [{ scale: 1 }] }, 14 | }); 15 | 16 | function Heart() { 17 | const [selected, setSelected] = useState(false); 18 | 19 | return ( 20 | setSelected(!selected)}> 21 | 29 | 30 | ); 31 | } 32 | 33 | export function AnimatedReactions() { 34 | return ( 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step6.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | Easing, 5 | useAnimatedStyle, 6 | useSharedValue, 7 | withTiming, 8 | } from "react-native-reanimated"; 9 | 10 | import { CenterScreen } from "../components/CenterScreen"; 11 | 12 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 13 | 14 | function FlyingHeart() { 15 | const time = useSharedValue(0); 16 | const vx = 30; 17 | const vy = 50; 18 | const g = 15; 19 | const duration = 30; 20 | const styles = useAnimatedStyle(() => { 21 | const t = time.value / 1000; 22 | const x = vx * t; 23 | const y = vy * t + (-g * t * t) / 2; 24 | return { 25 | transform: [{ translateX: x }, { translateY: -y }], 26 | }; 27 | }); 28 | useEffect(() => { 29 | time.value = withTiming(duration * 1000, { 30 | duration: (duration * 1000) / 5, 31 | easing: Easing.linear, 32 | }); 33 | }, []); 34 | return ( 35 | 36 | ); 37 | } 38 | 39 | export function AnimatedReactions() { 40 | return ( 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/AnimatedReactions/steps/Step7.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | BounceIn, 5 | Easing, 6 | useAnimatedStyle, 7 | useSharedValue, 8 | withTiming, 9 | ZoomOut, 10 | } from "react-native-reanimated"; 11 | import { Pressable } from "react-native"; 12 | 13 | import { CenterScreen } from "../components/CenterScreen"; 14 | 15 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 16 | 17 | const VX_MAX = 35; 18 | const VY_MAX = 80; 19 | 20 | function randomSpeed() { 21 | return { 22 | vx: Math.random() * 2 * VX_MAX - VX_MAX, 23 | vy: Math.random() * VY_MAX, 24 | angular: Math.random() * Math.PI - Math.PI / 2, 25 | }; 26 | } 27 | 28 | function FlyingHeart() { 29 | const time = useSharedValue(0); 30 | const { vx, vy, angular } = useRef(randomSpeed()).current; 31 | const duration = 30; 32 | const g = 15; 33 | 34 | const styles = useAnimatedStyle(() => { 35 | const t = time.value / 1000; 36 | const x = vx * t; 37 | const y = vy * t + (-g * t * t) / 2; 38 | const angle = angular * t; 39 | return { 40 | transform: [ 41 | { translateX: x }, 42 | { translateY: -y }, 43 | { rotateZ: `${angle}rad` }, 44 | ], 45 | }; 46 | }); 47 | useEffect(() => { 48 | time.value = withTiming(duration * 1000, { 49 | duration: (duration * 1000) / 10, 50 | easing: Easing.linear, 51 | }); 52 | }, []); 53 | return ( 54 | 60 | ); 61 | } 62 | 63 | function ExplodingHearts({ count = 20 }) { 64 | return ( 65 | <> 66 | {Array.from({ length: count }).map((_, index) => { 67 | return ; 68 | })} 69 | 70 | ); 71 | } 72 | 73 | function Heart() { 74 | const [selected, setSelected] = useState(false); 75 | 76 | return ( 77 | <> 78 | setSelected(!selected)}> 79 | 87 | 88 | {selected && } 89 | 90 | ); 91 | } 92 | 93 | export function AnimatedReactions() { 94 | return ( 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/Drawings/Drawings.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | Image, 5 | Path, 6 | Skia, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions } from "react-native"; 12 | 13 | const zurich = require("../assets/zurich.jpg"); 14 | const { width, height } = Dimensions.get("window"); 15 | export const Drawings = () => { 16 | const path = useValue(Skia.Path.Make()); 17 | 18 | const image = useImage(zurich); 19 | if (!image) { 20 | return null; 21 | } 22 | return ( 23 | 24 | 32 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/Drawings/Readme.md: -------------------------------------------------------------------------------- 1 | # Drawings 2 | 3 | Draw with your finger 4 | 5 | ## Step 1 - Add a touch handler 6 | 7 | Add [a touch handler](https://shopify.github.io/react-native-skia/docs/animations/touch-events). 8 | Everytime the touch is active we draw a line to the current point. 9 | 10 | ## Step 2 - Handle the start of the touch 11 | 12 | When the gesture starts, we move to the position of the point to close the previously drawn shape 13 | 14 | ## Step 3 - Draw smooth lines 15 | 16 | Instead of drawing a straight line, we can use a simple formula to smooth a curve between two points. 17 | The last point is available `path.current.getLastPoint()`. 18 | 19 | ```tsx 20 | const xMid = (lastPt.x + x) / 2; 21 | const yMid = (lastPt.y + y) / 2; 22 | path.current.quadTo(lastPt.x, lastPt.y, xMid, yMid); 23 | ``` -------------------------------------------------------------------------------- /src/Drawings/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Drawings"; 2 | -------------------------------------------------------------------------------- /src/Drawings/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | Image, 5 | Path, 6 | Skia, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions } from "react-native"; 12 | 13 | const zurich = require("../assets/zurich.jpg"); 14 | const { width, height } = Dimensions.get("window"); 15 | export const Drawings = () => { 16 | // viewBox="0 0 544 450" 17 | const path = useValue(Skia.Path.Make()); 18 | const onTouch = useTouchHandler({ 19 | onStart: ({ x, y }) => { 20 | path.current.moveTo(x, y); 21 | }, 22 | onActive: ({ x, y }) => { 23 | const lastPt = path.current.getLastPt(); 24 | const xMid = (lastPt.x + x) / 2; 25 | const yMid = (lastPt.y + y) / 2; 26 | path.current.quadTo(lastPt.x, lastPt.y, xMid, yMid); 27 | }, 28 | }); 29 | 30 | const image = useImage(zurich); 31 | if (!image) { 32 | return null; 33 | } 34 | return ( 35 | 36 | 44 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/Drawings/steps/Start.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | Image, 5 | Path, 6 | Skia, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions } from "react-native"; 12 | 13 | const zurich = require("../../assets/zurich.jpg"); 14 | const { width, height } = Dimensions.get("window"); 15 | export const Drawings = () => { 16 | const path = useValue(Skia.Path.Make()); 17 | 18 | const image = useImage(zurich); 19 | if (!image) { 20 | return null; 21 | } 22 | return ( 23 | 24 | 32 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/Drawings/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | Image, 5 | Path, 6 | Skia, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions } from "react-native"; 12 | 13 | const zurich = require("../assets/zurich.jpg"); 14 | const { width, height } = Dimensions.get("window"); 15 | export const Drawings = () => { 16 | const path = useValue(Skia.Path.Make()); 17 | const onTouch = useTouchHandler({ 18 | onActive: ({ x, y }) => { 19 | path.current.lineTo(x, y); 20 | }, 21 | }); 22 | 23 | const image = useImage(zurich); 24 | if (!image) { 25 | return null; 26 | } 27 | return ( 28 | 29 | 37 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/Drawings/steps/Step2.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | Image, 5 | Path, 6 | Skia, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions } from "react-native"; 12 | 13 | const zurich = require("../../assets/zurich.jpg"); 14 | const { width, height } = Dimensions.get("window"); 15 | export const Drawings = () => { 16 | const path = useValue(Skia.Path.Make()); 17 | const onTouch = useTouchHandler({ 18 | onStart: ({ x, y }) => { 19 | path.current.moveTo(x, y); 20 | }, 21 | onActive: ({ x, y }) => { 22 | path.current.lineTo(x, y); 23 | }, 24 | }); 25 | 26 | const image = useImage(zurich); 27 | if (!image) { 28 | return null; 29 | } 30 | return ( 31 | 32 | 40 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/Examples.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | ScrollView, 4 | StyleSheet, 5 | Text, 6 | TouchableHighlight, 7 | View, 8 | } from 'react-native'; 9 | import type { StackNavigationProp } from '@react-navigation/stack'; 10 | import { useNavigation } from '@react-navigation/native'; 11 | 12 | import type { Routes } from './Routes'; 13 | 14 | export const examples = [ 15 | { 16 | screen: 'AnimatedReactions', 17 | title: '😲 Animated Reactions', 18 | }, 19 | { 20 | screen: 'GestureBasedPicker', 21 | title: '🤌 Gesture-based Picker', 22 | }, 23 | { 24 | screen: 'AllTheGestures', 25 | title: '😵‍💫 Drag and Drop (and Rotate, and Pinch)', 26 | }, 27 | { 28 | screen: 'ReactLogo', 29 | title: '⚛️ React Logo', 30 | }, 31 | { 32 | screen: 'SkiaLogo', 33 | title: '🎨 Skia Logo', 34 | }, 35 | { 36 | screen: 'PinchToZoom', 37 | title: '🔍 Pinch to Zoom', 38 | }, 39 | { 40 | screen: 'Drawings', 41 | title: '🖌 Drawings', 42 | }, 43 | { 44 | screen: 'PhotoEditor', 45 | title: '📷 Photo Editor', 46 | }, 47 | { 48 | screen: 'ShapeMorphing', 49 | title: '☺️ Shape Morphing', 50 | }, 51 | { 52 | screen: 'Stickers', 53 | title: '🤳 Stickers', 54 | }, 55 | ] as const; 56 | 57 | const styles = StyleSheet.create({ 58 | container: { 59 | backgroundColor: '#f2f2f2', 60 | }, 61 | content: { 62 | paddingBottom: 32, 63 | }, 64 | thumbnail: { 65 | backgroundColor: 'white', 66 | padding: 16, 67 | borderBottomWidth: 1, 68 | borderColor: '#f2f2f2', 69 | }, 70 | title: { 71 | fontSize: 17, 72 | lineHeight: 22, 73 | }, 74 | }); 75 | 76 | export const Examples = () => { 77 | const { navigate } = useNavigation>(); 78 | return ( 79 | 80 | {examples.map((thumbnail) => ( 81 | navigate(thumbnail.screen)}> 84 | 85 | {thumbnail.title} 86 | 87 | 88 | ))} 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/GestureBasedPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withDelay, 7 | withSpring, 8 | withTiming, 9 | } from "react-native-reanimated"; 10 | import { ColorValue, View } from "react-native"; 11 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 12 | 13 | import { CenterScreen } from "../components/CenterScreen"; 14 | 15 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 16 | 17 | const WIDTH = 50; 18 | 19 | function Toolbar() { 20 | return ( 21 | 27 | 28 | 33 | {/* 34 | 35 | 36 | */} 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export function GestureBasedPicker() { 44 | return ( 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/README.md: -------------------------------------------------------------------------------- 1 | # Gestrue-based Picker 2 | 3 | In this excercise we build a gesture-based horizontal picker. 4 | Along the way of building it, we explore the new gesture-handler v2 library API in details. 5 | 6 | ## Step 1 - Building a toolbar 7 | 8 | ![2 1 mp4](https://user-images.githubusercontent.com/726445/172513204-663a8c5e-1883-4fb1-bc24-6be3a73be06d.gif) 9 | 10 |
11 | [1] Add three more icons in a single row (use different icons, for example “grade”, “thumb-up”, “emoji-events”) 12 | 13 | We will reuse `Heart` component from the previous excercise but refactor it such that it takes icon name as a prop: 14 | 15 | ```js 16 | function Sticker({ iconName }) { 17 | const [selected, setSelected] = useState(false); 18 | 19 | return ( 20 | <> 21 | setSelected(!selected)}> 22 | 28 | 29 | 30 | ); 31 | } 32 | ``` 33 | 34 | Now, we create a new component called `Toolbar` that renders `GestureDetector` component that wraps a horizontally oriented view in which we render a bunch of `Sticker` instances: 35 | 36 | ```js 37 | function Toolbar() { 38 | return ( 39 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | ``` 60 | 61 |

62 | 63 |
64 | [2] Wrap the row in GestureDetector and create a pan gesture for sliding the icons row along the X axis (with translateX transform) 65 | 66 | We start by defining new shared value that will track the horizontal offset of the toolbar, and make animated style to map it to the appropriate transform 67 | 68 | ```js 69 | const offsetY = useSharedValue(0); 70 | const styles = useAnimatedStyle(() => { 71 | return { 72 | transform: [{ translateX: offsetY.value }], 73 | }; 74 | }); 75 | ``` 76 | 77 | Now, we define pan gesture logic. 78 | We update offset with the amount of movement provided by the gesture `onChange` handler. 79 | 80 | ```js 81 | const pan = Gesture.Pan().onChange((e) => { 82 | offsetY.value += e.changeX; 83 | }); 84 | ``` 85 | 86 | Finally, we need to configure our `GestureDetector` to process the defined pan gesture logic: 87 | 88 | ```js 89 | 90 | ``` 91 | 92 |

93 | 94 | ## Step 2 – Snapping 95 | 96 | ![2 2 mp4](https://user-images.githubusercontent.com/726445/172513221-0aa334c8-9162-47d1-9d84-e86ec8a55cce.gif) 97 | 98 |
99 | [1] Add an indicator outside of the sliding bar to point to the first icon (you can use “expand-less” icon for a chevron pointing up) 100 | 101 | The indicator component can look as follow – just put it under the sliding view: 102 | 103 | ```js 104 | 109 | ``` 110 | 111 |

112 | 113 |
114 | [2] Add snapping logic such that the the bar can only stop at the position where one of the icon is directly over the indicator (use withSpring animation in onEnd gesture callback) 115 | 116 | Let us first define a helper method that takes the current offset and returns the offset to which the picker should snap to. 117 | Since we will use the method on the UI thread (from gesture handler callbacks), it needs to be annotated with `'worklet'` directive: 118 | 119 | ```js 120 | const STICKERS_COUNT = 4; 121 | const WIDTH = 50; 122 | 123 | function snapPoint(x: number) { 124 | 'worklet'; 125 | const position = Math.max( 126 | -STICKERS_COUNT + 1, 127 | Math.min(0, Math.round(x / WIDTH)) 128 | ); 129 | return position * WIDTH; 130 | } 131 | ``` 132 | 133 | In the above method we rely on the known number of stickers and their fixed sizes. 134 | 135 | Next, we add an `onEnd` handler to the pan gesture in where we will calculate the snap point and initiate the animation. 136 | 137 | ```js 138 | const pan = Gesture.Pan() 139 | .onChange((e) => { 140 | offsetY.value += e.changeX; 141 | }) 142 | .onEnd((e) => { 143 | offsetY.value = withSpring(snapPoint(offsetY.value)); 144 | }); 145 | ``` 146 | 147 |

148 | 149 | ## Step 3 – Moar physics 150 | 151 | ![2 3 mp4](https://user-images.githubusercontent.com/726445/172513250-4375fbd7-95f5-4864-8306-b5aebc528520.gif) 152 | 153 |
154 | [1] Make fling gestures feel more natural by transfering the speed of the fling onto the snap animation 155 | 156 | In this step the only thing is to pass initial velocity as a parameter for spring animation. The velocity is provided as one of the fields of the end gesture. 157 | 158 | ```js 159 | .onEnd((e) => { 160 | offsetY.value = withSpring(snapPoint(offsetY.value), { 161 | velocity: e.velocityX, 162 | }); 163 | }); 164 | ``` 165 | 166 |

167 | 168 |
169 | [2] Implement toss effect (when you lift finger from swiping at speed) – use velocity from gesture event to “simulate” further movement for 100ms 170 | 171 | Let us update the previously implemented `snapPoint` method and add current velocity as a second parameter. 172 | Now, instead of just looking at offset position `x`, we "simulate" a movement with constant velocity for some short time duration: 173 | 174 | ```js 175 | function snapPoint(x: number, vx: number) { 176 | 'worklet'; 177 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 178 | const position = Math.max( 179 | -STICKERS_COUNT + 1, 180 | Math.min(0, Math.round(tossX / WIDTH)) 181 | ); 182 | return position * WIDTH; 183 | } 184 | ``` 185 | 186 | Update callsite of `snapPoint` method to pass the velocity: 187 | 188 | ```js 189 | .onEnd((e) => { 190 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 191 | velocity: e.velocityX, 192 | }); 193 | }); 194 | ``` 195 | 196 |

197 | 198 |
199 | [BONUS 1] Add friction when swiping – the bar moves slower the further you drag – this way to only allow swiping between adjacent icons 200 | 201 | In this step you should not only look at the pan event change but also on the distance from the central point. 202 | Then take that into account when updating the offset. 203 | Without a friction, pan change corresponds to the exact same change in the offset, with friction you want the offset to move less than pan and to slow down exponentially the further from the center the finger is. 204 | Note that friction should also be added when simulating "toss" as to avoid fast swipes to jump between stickers. 205 | 206 |

207 | 208 | ## Step 4 – Refactor to LongPress gesture 209 | 210 | ![2 4 mp4](https://user-images.githubusercontent.com/726445/172513280-eca51c77-3fc1-40dd-9a50-0c21b6886016.gif) 211 | 212 |
213 | [1] Remove icon’s state and all effects added previously 214 | 🙃 try not to use hints this often 215 |

216 | 217 |
218 | [2] Replace Pressable with GestureDetector and add LongPress gesture that makes the icon “grow” up to 3x scale, then go back to normal after finger is lifted 219 | 220 | We refactor `Sticker` component to now render `AnimatedIcon` wrapped with `GestureDetector` component. 221 | For `GestureDetector`, we prepare two separate gestures: tap and long press. 222 | We set up tap gesture to do nothing for the time being, and for long press gesture we want it to make the sticker "grow". 223 | For that purpose we start animating scale in `onStart` gesture, in order to "cancel" it when the finger is lifted, we 224 | 225 |

226 | 227 |
228 | [3] Make sure that sticker that is being long press is displayed over all the other stickers – use zIndex in useAnimatedStyle for this purpose 229 | 230 | We refactor `Sticker` component to now render `AnimatedIcon` wrapped with `GestureDetector` component. 231 | For `GestureDetector`, we prepare two separate gestures: tap and long press. 232 | We set up tap gesture to do nothing for the time being, and for long press gesture we want it to make the sticker "grow". 233 | For that purpose we start animating scale in `onStart` gesture, in order to "cancel" it when the finger is lifted, we start animation back to 1 from `onEnd` callback. 234 | 235 | ```js 236 | function Sticker({ iconName, color }: { iconName: string, color: ColorValue }) { 237 | const tap = Gesture.Tap().onEnd(() => { 238 | console.log('Do nothing yet'); 239 | }); 240 | const scale = useSharedValue(1); 241 | const longPress = Gesture.LongPress() 242 | .onStart(() => { 243 | scale.value = withTiming(3, { duration: 2000 }); 244 | }) 245 | .onEnd(() => { 246 | scale.value = withSpring(1); 247 | }); 248 | const styles = useAnimatedStyle(() => { 249 | return { 250 | transform: [{ scale: scale.value }], 251 | zIndex: scale.value > 1 ? 100 : 1, 252 | }; 253 | }); 254 | return ( 255 | 256 | 257 | 258 | ); 259 | } 260 | ``` 261 | 262 |

263 | 264 | ## Step 5 – Control gesture activation criteria 265 | 266 | ![2 5 mp4](https://user-images.githubusercontent.com/726445/172513305-cdae0d44-7905-412d-aa8a-809bbd6fba4f.gif) 267 | 268 |
269 | [1] Note what happens to the bar swiping when long press gesture is active? 270 | 271 | Long press captures the event stream and does not allow swipe gesture to activate. 272 | 273 |

274 | 275 |
276 | [2] Allow pan to activate after long pressing the sticker – the easiest approach is to use Tap instead of LongPress (checkout onBegin callback and maxDuration option) 277 | 278 | We refactor long press handler to use `Tap` gesture directly, this way we avoid `LongPress` gesture from activating (and therefore cancelling swipe gesture) when we hold the finger still for a few moments. 279 | However, because `Tap` will now no longer activate, we can't use `onStart` callback with it as it would only be triggered when we finish the tap (lift the finger up). 280 | For this reason we use `onBegin` which is called immediately when gesture handler starts receiving touch stream. 281 | Now, in order to not start the scale-up animation too soon, we add `withDelay` to prevent the situation when "just swiping" still initiates the scaling. 282 | Finally, we need to set `maxDuration` option to some big number, as otherwise the tap gesture would be cancelled shortly after holding the finger still: 283 | 284 | ```js 285 | const longPress = Gesture.Tap() 286 | .maxDuration(1e8) 287 | .onBegin(() => { 288 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 289 | }) 290 | .onFinalize(() => { 291 | scale.value = withSpring(1); 292 | }); 293 | ``` 294 | 295 |

296 | 297 | ## Next step 298 | 299 | **Go to [Drag and Drop (and Rotate, and Pinch)](../AllTheGestures)** 300 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GestureBasedPicker"; 2 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withDelay, 7 | withSpring, 8 | withTiming, 9 | } from "react-native-reanimated"; 10 | import type { ColorValue } from "react-native"; 11 | import { View } from "react-native"; 12 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 13 | 14 | import { CenterScreen } from "../components/CenterScreen"; 15 | 16 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 17 | 18 | const WIDTH = 50; 19 | 20 | function Sticker({ iconName, color }: { iconName: string; color: ColorValue }) { 21 | const tap = Gesture.Tap().onEnd(() => { 22 | console.log("Do nothing here"); 23 | }); 24 | const scale = useSharedValue(1); 25 | const longPress = Gesture.Tap() 26 | .maxDuration(1e8) 27 | .onBegin(() => { 28 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 29 | }) 30 | .onFinalize(() => { 31 | scale.value = withSpring(1); 32 | }); 33 | const styles = useAnimatedStyle(() => { 34 | return { 35 | transform: [{ scale: scale.value }], 36 | zIndex: scale.value > 1 ? 100 : 1, 37 | }; 38 | }); 39 | return ( 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | const STICKERS_COUNT = 4; 47 | 48 | function snapPoint(x: number, vx: number) { 49 | "worklet"; 50 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 51 | const position = Math.max( 52 | -STICKERS_COUNT + 1, 53 | Math.min(0, Math.round(tossX / WIDTH)) 54 | ); 55 | return position * WIDTH; 56 | } 57 | 58 | function Toolbar() { 59 | const offsetY = useSharedValue(0); 60 | const pan = Gesture.Pan() 61 | .onChange((e) => { 62 | offsetY.value += e.changeX; 63 | }) 64 | .onEnd((e) => { 65 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 66 | velocity: e.velocityX, 67 | }); 68 | }); 69 | const styles = useAnimatedStyle(() => { 70 | return { 71 | transform: [{ translateX: offsetY.value }], 72 | }; 73 | }); 74 | return ( 75 | 81 | 82 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 99 | 100 | ); 101 | } 102 | 103 | export function GestureBasedPicker() { 104 | return ( 105 | 106 | 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { Pressable, View } from "react-native"; 8 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 9 | 10 | import { CenterScreen } from "../components/CenterScreen"; 11 | 12 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 13 | 14 | const WIDTH = 50; 15 | 16 | function Sticker({ iconName }: { iconName: string }) { 17 | const [selected, setSelected] = useState(false); 18 | 19 | return ( 20 | <> 21 | setSelected(!selected)}> 22 | 28 | 29 | 30 | ); 31 | } 32 | 33 | function Toolbar() { 34 | const offsetY = useSharedValue(0); 35 | const pan = Gesture.Pan().onChange((e) => { 36 | offsetY.value += e.changeX; 37 | }); 38 | const styles = useAnimatedStyle(() => { 39 | return { 40 | transform: [{ translateX: offsetY.value }], 41 | }; 42 | }); 43 | return ( 44 | 50 | 51 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export function GestureBasedPicker() { 68 | return ( 69 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Step2.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withSpring, 7 | } from "react-native-reanimated"; 8 | import { Pressable, View } from "react-native"; 9 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 10 | 11 | import { CenterScreen } from "../components/CenterScreen"; 12 | 13 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 14 | 15 | const WIDTH = 50; 16 | 17 | function Sticker({ iconName }: { iconName: string }) { 18 | const [selected, setSelected] = useState(false); 19 | 20 | return ( 21 | <> 22 | setSelected(!selected)}> 23 | 29 | 30 | 31 | ); 32 | } 33 | 34 | const STICKERS_COUNT = 4; 35 | 36 | function snapPoint(x: number) { 37 | "worklet"; 38 | const position = Math.max( 39 | -STICKERS_COUNT + 1, 40 | Math.min(0, Math.round(x / WIDTH)) 41 | ); 42 | return position * WIDTH; 43 | } 44 | 45 | function Toolbar() { 46 | const offsetY = useSharedValue(0); 47 | const pan = Gesture.Pan() 48 | .onChange((e) => { 49 | offsetY.value += e.changeX; 50 | }) 51 | .onEnd((e) => { 52 | offsetY.value = withSpring(snapPoint(offsetY.value)); 53 | }); 54 | const styles = useAnimatedStyle(() => { 55 | return { 56 | transform: [{ translateX: offsetY.value }], 57 | }; 58 | }); 59 | return ( 60 | 66 | 67 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 84 | 85 | ); 86 | } 87 | 88 | export function GestureBasedPicker() { 89 | return ( 90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Step3.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withSpring, 7 | } from "react-native-reanimated"; 8 | import { Pressable, View } from "react-native"; 9 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 10 | 11 | import { CenterScreen } from "../components/CenterScreen"; 12 | 13 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 14 | 15 | const WIDTH = 50; 16 | 17 | function Sticker({ iconName }: { iconName: string }) { 18 | const [selected, setSelected] = useState(false); 19 | 20 | return ( 21 | <> 22 | setSelected(!selected)}> 23 | 29 | 30 | 31 | ); 32 | } 33 | 34 | const STICKERS_COUNT = 4; 35 | 36 | function snapPoint(x: number, vx: number) { 37 | "worklet"; 38 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 39 | const position = Math.max( 40 | -STICKERS_COUNT + 1, 41 | Math.min(0, Math.round(tossX / WIDTH)) 42 | ); 43 | return position * WIDTH; 44 | } 45 | 46 | function Toolbar() { 47 | const offsetY = useSharedValue(0); 48 | const pan = Gesture.Pan() 49 | .onChange((e) => { 50 | offsetY.value += e.changeX; 51 | }) 52 | .onEnd((e) => { 53 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 54 | velocity: e.velocityX, 55 | }); 56 | }); 57 | const styles = useAnimatedStyle(() => { 58 | return { 59 | transform: [{ translateX: offsetY.value }], 60 | }; 61 | }); 62 | return ( 63 | 69 | 70 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 87 | 88 | ); 89 | } 90 | 91 | export function GestureBasedPicker() { 92 | return ( 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Step4.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withSpring, 7 | withTiming, 8 | } from "react-native-reanimated"; 9 | import type { ColorValue } from "react-native"; 10 | import { View } from "react-native"; 11 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 12 | 13 | import { CenterScreen } from "../components/CenterScreen"; 14 | 15 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 16 | 17 | const WIDTH = 50; 18 | 19 | function Sticker({ iconName, color }: { iconName: string; color: ColorValue }) { 20 | const tap = Gesture.Tap().onEnd(() => { 21 | console.log("Do nothing yet"); 22 | }); 23 | const scale = useSharedValue(1); 24 | const longPress = Gesture.LongPress() 25 | .onStart(() => { 26 | scale.value = withTiming(3, { duration: 2000 }); 27 | }) 28 | .onEnd(() => { 29 | scale.value = withSpring(1); 30 | }); 31 | const styles = useAnimatedStyle(() => { 32 | return { 33 | transform: [{ scale: scale.value }], 34 | zIndex: scale.value > 1 ? 100 : 1, 35 | }; 36 | }); 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | const STICKERS_COUNT = 4; 45 | 46 | function snapPoint(x: number, vx: number) { 47 | "worklet"; 48 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 49 | const position = Math.max( 50 | -STICKERS_COUNT + 1, 51 | Math.min(0, Math.round(tossX / WIDTH)) 52 | ); 53 | return position * WIDTH; 54 | } 55 | 56 | function Toolbar() { 57 | const offsetY = useSharedValue(0); 58 | const pan = Gesture.Pan() 59 | .onChange((e) => { 60 | offsetY.value += e.changeX; 61 | }) 62 | .onEnd((e) => { 63 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 64 | velocity: e.velocityX, 65 | }); 66 | }); 67 | const styles = useAnimatedStyle(() => { 68 | return { 69 | transform: [{ translateX: offsetY.value }], 70 | }; 71 | }); 72 | return ( 73 | 79 | 80 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 97 | 98 | ); 99 | } 100 | 101 | export function GestureBasedPicker() { 102 | return ( 103 | 104 | 105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/GestureBasedPicker/steps/Step5.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Icon from "@expo/vector-icons/MaterialIcons"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | useSharedValue, 6 | withDelay, 7 | withSpring, 8 | withTiming, 9 | } from "react-native-reanimated"; 10 | import type { ColorValue } from "react-native"; 11 | import { View } from "react-native"; 12 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 13 | 14 | import { CenterScreen } from "../components/CenterScreen"; 15 | 16 | const AnimatedIcon = Animated.createAnimatedComponent(Icon); 17 | 18 | const WIDTH = 50; 19 | 20 | function Sticker({ iconName, color }: { iconName: string; color: ColorValue }) { 21 | const tap = Gesture.Tap().onEnd(() => { 22 | console.log("Do nothing here"); 23 | }); 24 | const scale = useSharedValue(1); 25 | const longPress = Gesture.Tap() 26 | .maxDuration(1e8) 27 | .onBegin(() => { 28 | scale.value = withDelay(50, withTiming(3, { duration: 2000 })); 29 | }) 30 | .onFinalize(() => { 31 | scale.value = withSpring(1); 32 | }); 33 | const styles = useAnimatedStyle(() => { 34 | return { 35 | transform: [{ scale: scale.value }], 36 | zIndex: scale.value > 1 ? 100 : 1, 37 | }; 38 | }); 39 | return ( 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | const STICKERS_COUNT = 4; 47 | 48 | function snapPoint(x: number, vx: number) { 49 | "worklet"; 50 | const tossX = x + vx * 0.1; // simulate movement for 0.1 second 51 | const position = Math.max( 52 | -STICKERS_COUNT + 1, 53 | Math.min(0, Math.round(tossX / WIDTH)) 54 | ); 55 | return position * WIDTH; 56 | } 57 | 58 | function Toolbar() { 59 | const offsetY = useSharedValue(0); 60 | const pan = Gesture.Pan() 61 | .onChange((e) => { 62 | offsetY.value += e.changeX; 63 | }) 64 | .onEnd((e) => { 65 | offsetY.value = withSpring(snapPoint(offsetY.value, e.velocityX), { 66 | velocity: e.velocityX, 67 | }); 68 | }); 69 | const styles = useAnimatedStyle(() => { 70 | return { 71 | transform: [{ translateX: offsetY.value }], 72 | }; 73 | }); 74 | return ( 75 | 81 | 82 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 99 | 100 | ); 101 | } 102 | 103 | export function GestureBasedPicker() { 104 | return ( 105 | 106 | 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/PhotoEditor/Filters.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | import type { 3 | SkImage, 4 | Vector, 5 | SkRect, 6 | SkiaMutableValue, 7 | } from "@shopify/react-native-skia"; 8 | import { 9 | ImageShader, 10 | RoundedRect, 11 | Shadow, 12 | rect, 13 | rrect, 14 | ColorMatrix, 15 | Group, 16 | } from "@shopify/react-native-skia"; 17 | import React from "react"; 18 | import { Dimensions } from "react-native"; 19 | 20 | import { contains } from "./Helpers"; 21 | 22 | const { width, height } = Dimensions.get("window"); 23 | const size = width / 4; 24 | 25 | const rects = new Array(4) 26 | .fill(0) 27 | .map((_, i) => rect(size * i, height - size - 125, size, size)); 28 | export const f1 = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; 29 | const f2 = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; 30 | const f3 = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; 31 | const f4 = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; 32 | export const filters = [f1, f2, f3, f4] as const; 33 | 34 | export const selectFilter = ( 35 | matrix: SkiaMutableValue, 36 | pt: Vector 37 | ) => { 38 | if (contains(pt, rects[0])) { 39 | matrix.current = filters[0]; 40 | } else if (contains(pt, rects[1])) { 41 | matrix.current = filters[1]; 42 | } else if (contains(pt, rects[2])) { 43 | matrix.current = filters[2]; 44 | } else if (contains(pt, rects[3])) { 45 | matrix.current = filters[3]; 46 | } 47 | }; 48 | 49 | interface FiltersProps { 50 | image: SkImage; 51 | } 52 | 53 | export const Filters = ({ image }: FiltersProps) => { 54 | return ( 55 | <> 56 | {filters.map((filter, i) => { 57 | return ( 58 | 65 | 66 | 67 | 75 | 76 | 77 | ); 78 | })} 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/PhotoEditor/Helpers.tsx: -------------------------------------------------------------------------------- 1 | import type { SkRect, Vector } from "@shopify/react-native-skia"; 2 | 3 | export const contains = (pt: Vector, rct: SkRect) => 4 | pt.x >= rct.x && 5 | pt.x <= rct.x + rct.width && 6 | pt.y >= rct.y && 7 | pt.y <= rct.y + rct.height; 8 | -------------------------------------------------------------------------------- /src/PhotoEditor/PhotoEditor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | import { 4 | Canvas, 5 | ColorMatrix, 6 | Group, 7 | Image, 8 | useImage, 9 | useTouchHandler, 10 | useValue, 11 | } from "@shopify/react-native-skia"; 12 | import React from "react"; 13 | import { Dimensions } from "react-native"; 14 | 15 | import { Filters, f1, selectFilter } from "./Filters"; 16 | 17 | const zurich = require("../assets/zurich.jpg"); 18 | const { width, height } = Dimensions.get("window"); 19 | //const center = vec(width / 2, height / 2); 20 | 21 | export const PhotoEditor = () => { 22 | const image = useImage(zurich); 23 | if (!image) { 24 | return null; 25 | } 26 | return ( 27 | 28 | 29 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/PhotoEditor/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./PhotoEditor"; 2 | -------------------------------------------------------------------------------- /src/PhotoEditor/steps/Final/Filters.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | import type { 3 | SkImage, 4 | Vector, 5 | SkRect, 6 | SkiaMutableValue, 7 | } from "@shopify/react-native-skia"; 8 | import { 9 | ImageShader, 10 | RoundedRect, 11 | Shadow, 12 | rect, 13 | rrect, 14 | ColorMatrix, 15 | Group, 16 | } from "@shopify/react-native-skia"; 17 | import React from "react"; 18 | import { Dimensions } from "react-native"; 19 | 20 | import { contains } from "../../Helpers"; 21 | 22 | const { width, height } = Dimensions.get("window"); 23 | const size = width / 4; 24 | 25 | const rects = new Array(4) 26 | .fill(0) 27 | .map((_, i) => rect(size * i, height - size - 125, size, size)); 28 | export const noFilter = [ 29 | 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 30 | ]; 31 | const blackAndWhite = [ 32 | 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 33 | ]; 34 | const milk = [0, 1.0, 0, 0, 0, 0, 1.0, 0, 0, 0, 0, 0.6, 1, 0, 0, 0, 0, 0, 1, 0]; 35 | const coldLife = [ 36 | 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, -0.2, 0.2, 0.1, 0.4, 0, 0, 0, 0, 1, 0, 37 | ]; 38 | export const filters = [noFilter, blackAndWhite, milk, coldLife] as const; 39 | 40 | export const selectFilter = ( 41 | matrix: SkiaMutableValue, 42 | pt: Vector 43 | ) => { 44 | if (contains(pt, rects[0])) { 45 | matrix.current = filters[0]; 46 | } else if (contains(pt, rects[1])) { 47 | matrix.current = filters[1]; 48 | } else if (contains(pt, rects[2])) { 49 | matrix.current = filters[2]; 50 | } else if (contains(pt, rects[3])) { 51 | matrix.current = filters[3]; 52 | } 53 | }; 54 | 55 | interface FiltersProps { 56 | image: SkImage; 57 | } 58 | 59 | export const Filters = ({ image }: FiltersProps) => { 60 | return ( 61 | <> 62 | {filters.map((filter, i) => { 63 | return ( 64 | 71 | 72 | 73 | 81 | 82 | 83 | ); 84 | })} 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /src/PhotoEditor/steps/Final/PhotoEditor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Canvas, 4 | ColorMatrix, 5 | Group, 6 | Image, 7 | useImage, 8 | useTouchHandler, 9 | useValue, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import { Dimensions } from "react-native"; 13 | 14 | import { Filters, noFilter, selectFilter } from "./Filters"; 15 | 16 | const zurich = require("../../../assets/zurich.jpg"); 17 | const { width, height } = Dimensions.get("window"); 18 | //const center = vec(width / 2, height / 2); 19 | 20 | export const PhotoEditor = () => { 21 | const onTouch = useTouchHandler({ 22 | onEnd: (pt) => { 23 | selectFilter(matrix, pt); 24 | }, 25 | }); 26 | const matrix = useValue(noFilter); 27 | const image = useImage(zurich); 28 | if (!image) { 29 | return null; 30 | } 31 | return ( 32 | 33 | 34 | 35 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/PhotoEditor/steps/Readme.md: -------------------------------------------------------------------------------- 1 | # Apply a photo filter 2 | 3 | ## Get Color matrices 4 | 5 | Add a color matrix to an image. 6 | 7 | You can get SVG color matrices [here](https://kazzkiq.github.io/svg-color-filter/). 8 | In Skia, we need to convert these from SVG strings to arrays of numbers. 9 | The order is the same. 10 | React Native Skia has a [ColorMatrix component](https://shopify.github.io/react-native-skia/docs/color-filters/#color-matrix) you can use for that. 11 | 12 | Finally, pick the four color matrices you would like to use in the demo in `Filters.tsx`. The variable names are `f1`, `f2`, `f3`, and `f4`. 13 | 14 | ## Select the color matrice 15 | 16 | Create a matrix Skia value that will hold the current color matrix. 17 | In `Filter.tsx`, there is a `selectFilter` function which given a point, will select the correct color matrix. 18 | 19 | Add a touch handler to select the filter when the touch ends. 20 | -------------------------------------------------------------------------------- /src/PinchToZoom/PinchToZoom.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import type { SkMatrix } from "@shopify/react-native-skia"; 3 | import { 4 | Canvas, 5 | useImage, 6 | Image, 7 | useSharedValueEffect, 8 | useValue, 9 | Group, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions, View } from "react-native"; 12 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 13 | import { useSharedValue } from "react-native-reanimated"; 14 | 15 | import { 16 | createIdentityMatrix, 17 | scale3d, 18 | toSkMatrix, 19 | } from "../components/matrixMath"; 20 | 21 | const zurich = require("../assets/zurich.jpg"); 22 | const { width, height } = Dimensions.get("window"); 23 | 24 | export const PinchToZoom = () => { 25 | const image = useImage(zurich); 26 | if (!image) { 27 | return null; 28 | } 29 | return ( 30 | 31 | 32 | 33 | 34 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/PinchToZoom/Readme.md: -------------------------------------------------------------------------------- 1 | # Pinch to Zoom 2 | 3 | Connect the `scale` shared value from Reanimated to Skia. 4 | This can be done via [useSharedValueEffect hook](https://shopify.github.io/react-native-skia/docs/animations/reanimated/). 5 | 6 | We will create a `transform` Skia value via `useValue()` and assign a new transform everytime `scale` is updated. 7 | Finally we will bind the `transform` Skia value to a group. -------------------------------------------------------------------------------- /src/PinchToZoom/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./PinchToZoom"; 2 | -------------------------------------------------------------------------------- /src/PinchToZoom/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import type { SkMatrix } from "@shopify/react-native-skia"; 3 | import { 4 | Canvas, 5 | useImage, 6 | Image, 7 | useSharedValueEffect, 8 | useValue, 9 | Group, 10 | } from "@shopify/react-native-skia"; 11 | import { Dimensions, View } from "react-native"; 12 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 13 | import { useSharedValue } from "react-native-reanimated"; 14 | 15 | import { createIdentityMatrix, scale3d, toSkMatrix } from "./matrixMath"; 16 | 17 | const zurich = require("../assets/zurich.jpg"); 18 | const { width, height } = Dimensions.get("window"); 19 | 20 | export const PinchToZoom = () => { 21 | const matrix = useSharedValue(createIdentityMatrix()); 22 | 23 | const skMatrix = useValue(toSkMatrix(createIdentityMatrix())); 24 | 25 | const scale = Gesture.Pinch().onChange((e) => { 26 | matrix.value = scale3d( 27 | matrix.value, 28 | e.scaleChange, 29 | e.scaleChange, 30 | 1, 31 | e.focalX, 32 | e.focalY, 33 | 0 34 | ); 35 | }); 36 | 37 | const image = useImage(zurich); 38 | useSharedValueEffect(() => { 39 | skMatrix.current = toSkMatrix(matrix.value); 40 | }, matrix); 41 | if (!image) { 42 | return null; 43 | } 44 | return ( 45 | 46 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/PinchToZoom/steps/Start.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import type { Transforms2d } from "@shopify/react-native-skia"; 3 | import { 4 | Path, 5 | useTiming, 6 | Skia, 7 | Canvas, 8 | useImage, 9 | Image, 10 | useSharedValueEffect, 11 | useValue, 12 | Group, 13 | } from "@shopify/react-native-skia"; 14 | import { Dimensions, View } from "react-native"; 15 | import { GestureDetector, Gesture } from "react-native-gesture-handler"; 16 | import { useSharedValue, withTiming } from "react-native-reanimated"; 17 | 18 | const zurich = require("../../assets/zurich.jpg"); 19 | const { width, height } = Dimensions.get("window"); 20 | //const center = vec(width / 2, height / 2); 21 | 22 | export const PinchToZoom = () => { 23 | const focalX = useSharedValue(0); 24 | const focalY = useSharedValue(0); 25 | const scale = useSharedValue(1); 26 | 27 | const pinch = Gesture.Pinch() 28 | .onStart((event) => { 29 | focalX.value = event.focalX; 30 | focalY.value = event.focalY; 31 | }) 32 | .onChange((event) => { 33 | scale.value = event.scale; 34 | }) 35 | .onEnd(() => { 36 | scale.value = withTiming(1); 37 | focalX.value = withTiming(0); 38 | focalY.value = withTiming(0); 39 | }); 40 | const gesture = pinch; 41 | const image = useImage(zurich); 42 | 43 | if (!image) { 44 | return null; 45 | } 46 | return ( 47 | 48 | 49 | 50 | 51 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/ReactLogo/ReactLogo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from "@shopify/react-native-skia"; 13 | import React from "react"; 14 | import { Dimensions } from "react-native"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const ellipsisAspectRatio = 180 / 470; 18 | const PADDING = 32; 19 | const rx = width / 2 - PADDING; 20 | const ry = rx * ellipsisAspectRatio; 21 | 22 | // Origin of the Logo 23 | const center = vec(width / 2, height / 2); 24 | // Radius of the middle circle 25 | const r = 0.75 * PADDING; 26 | // Rectangle to draw the oval in 27 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 28 | // Some colors 29 | const c1 = "#3884FF"; 30 | const c2 = "#51D3ED"; 31 | const strokeWidth = r; 32 | 33 | export const ReactLogo = () => { 34 | return ( 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/ReactLogo/Readme.md: -------------------------------------------------------------------------------- 1 | # React Logo 2 | 3 | Build the react logo. 4 | The center of the logo, radius of the middle circle, and bounding rectangle of the ellipsis is provided in `ReactLogo.tsx`. 5 | 6 | ## Step 1 - Draw the circle and the ellipisis 7 | 8 |
9 | Using the Circle and Oval component. 10 | 11 | ```jsx 12 | 13 | 14 | ``` 15 |
16 | 17 | ## Step 2 - Finish the logo 18 | 19 | Draw two more identical ellipisis. 20 | Rotate one to `60deg` (`PI/3`) and one to `-60deg` (`-PI/3`). 21 | The Transform API is identical to the one in React Native expect for one crucial difference: 22 | the default origin in Skia is the top left corner of the object. 23 | So we will need to specify our origin of transformation to be in the center. 24 | 25 |
26 | Using the Group component for the transformation 27 | 28 | 29 | ```jsx 30 | 31 | 32 | 33 | ``` 34 |
35 | 36 | ## Step 3 - Add Gradients 37 | 38 | We can add some gradients. 39 | For the circle a linear or a radial gradient should do ([see gradient documentation](https://shopify.github.io/react-native-skia/docs/shaders/gradients)). 40 | The colors are provided in the starting file. 41 | For the ellipsis, a linear gradient could work. 42 | A sweep gradient might also work wonderfully. 43 | 44 | 45 |
46 | Applying a Sweep gradient to a group 47 | 48 | 49 | ```jsx 50 | 51 | 52 | 53 | 54 | ``` 55 |
56 | 57 | 58 | ## Step 4 - Blur Mask 59 | 60 | We can make our logo even more fancy by adding a [blur mask effect](https://shopify.github.io/react-native-skia/docs/mask-filters/). 61 | And we can flip some of the ellipsis to alternate colors. 62 | 63 |
64 | Flip an elipsis horizontally 65 | 66 | ```jsx 67 | 68 | 69 | 70 | ``` 71 |
72 | 73 | -------------------------------------------------------------------------------- /src/ReactLogo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ReactLogo"; 2 | -------------------------------------------------------------------------------- /src/ReactLogo/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from "@shopify/react-native-skia"; 13 | import React from "react"; 14 | import { Dimensions } from "react-native"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const ellipsisAspectRatio = 180 / 470; 18 | const PADDING = 32; 19 | const rx = width / 2 - PADDING; 20 | const ry = rx * ellipsisAspectRatio; 21 | 22 | // Origin of the Logo 23 | const center = vec(width / 2, height / 2); 24 | // Radius of the middle circle 25 | const r = 0.75 * PADDING; 26 | // Rectangle to draw the oval in 27 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 28 | // Some colors 29 | const c1 = "#3884FF"; 30 | const c2 = "#51D3ED"; 31 | const strokeWidth = r; 32 | 33 | export const ReactLogo = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/ReactLogo/steps/Start.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from "@shopify/react-native-skia"; 13 | import React from "react"; 14 | import { Dimensions } from "react-native"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const ellipsisAspectRatio = 180 / 470; 18 | const PADDING = 32; 19 | const rx = width / 2 - PADDING; 20 | const ry = rx * ellipsisAspectRatio; 21 | 22 | // Origin of the Logo 23 | const center = vec(width / 2, height / 2); 24 | // Radius of the middle circle 25 | const r = 0.75 * PADDING; 26 | // Rectangle to draw the oval in 27 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 28 | // Some colors 29 | const c1 = "#3884FF"; 30 | const c2 = "#51D3ED"; 31 | const strokeWidth = r; 32 | 33 | export const ReactLogo = () => { 34 | return ( 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/ReactLogo/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from "@shopify/react-native-skia"; 13 | import React from "react"; 14 | import { Dimensions } from "react-native"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const ellipsisAspectRatio = 180 / 470; 18 | const PADDING = 32; 19 | const rx = width / 2 - PADDING; 20 | const ry = rx * ellipsisAspectRatio; 21 | 22 | // Origin of the Logo 23 | const center = vec(width / 2, height / 2); 24 | // Radius of the middle circle 25 | const r = 0.75 * PADDING; 26 | // Rectangle to draw the oval in 27 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 28 | // Stroke width of the oval 29 | const strokeWidth = r; 30 | // Some colors 31 | const c1 = "#3884FF"; 32 | const c2 = "#51D3ED"; 33 | 34 | export const ReactLogo = () => { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/ReactLogo/steps/Step2.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from "@shopify/react-native-skia"; 13 | import React from "react"; 14 | import { Dimensions } from "react-native"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const ellipsisAspectRatio = 180 / 470; 18 | const PADDING = 32; 19 | const rx = width / 2 - PADDING; 20 | const ry = rx * ellipsisAspectRatio; 21 | 22 | // Origin of the Logo 23 | const center = vec(width / 2, height / 2); 24 | // Radius of the middle circle 25 | const r = 0.75 * PADDING; 26 | // Rectangle to draw the oval in 27 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 28 | // Stroke width of the oval 29 | const strokeWidth = r; 30 | // Some colors 31 | const c1 = "#3884FF"; 32 | const c2 = "#51D3ED"; 33 | 34 | export const ReactLogo = () => { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/Routes.ts: -------------------------------------------------------------------------------- 1 | export type Routes = { 2 | Examples: undefined; 3 | AnimatedReactions: undefined; 4 | GestureBasedPicker: undefined; 5 | AllTheGestures: undefined; 6 | ReactLogo: undefined; 7 | SkiaLogo: undefined; 8 | ShapeMorphing: undefined; 9 | PinchToZoom: undefined; 10 | Drawings: undefined; 11 | PhotoEditor: undefined; 12 | Stickers: undefined; 13 | }; 14 | -------------------------------------------------------------------------------- /src/ShapeMorphing/Eye.tsx: -------------------------------------------------------------------------------- 1 | import type { SkiaValue } from "@shopify/react-native-skia"; 2 | import { 3 | interpolateVector, 4 | translate, 5 | useDerivedValue, 6 | Path, 7 | Skia, 8 | Group, 9 | Circle, 10 | center as getCenter, 11 | } from "@shopify/react-native-skia"; 12 | 13 | import { center, interpolatePaths, inputRange } from "./Helpers"; 14 | 15 | interface EyeProps { 16 | flip?: boolean; 17 | progress: SkiaValue; 18 | } 19 | 20 | const angryPath = Skia.Path.Make(); 21 | angryPath.moveTo(16, 25); 22 | angryPath.cubicTo(32.2, 27.09, 43.04, 28.2, 48.51, 28.34); 23 | angryPath.cubicTo(53.99, 28.48, 62.15, 27.78, 73, 26.25); 24 | angryPath.cubicTo(66.28, 53.93, 60.19, 69.81, 54.74, 73.88); 25 | angryPath.cubicTo(50.63, 76.96, 40.4, 74.65, 27.48, 54.51); 26 | angryPath.cubicTo(24.68, 50.15, 20.85, 40.32, 27.48, 54.51); 27 | angryPath.close(); 28 | 29 | const normalPath = Skia.Path.Make(); 30 | normalPath.moveTo(20.9, 30.94); 31 | normalPath.cubicTo(31.26, 31.66, 38.61, 32.2, 42.96, 32.56); 32 | normalPath.cubicTo(66.94, 34.53, 79.65, 36.45, 81.11, 38.32); 33 | normalPath.cubicTo(83.9, 41.9, 73.77, 56.6, 65.83, 59.52); 34 | normalPath.cubicTo(61.95, 60.95, 45.72, 58.91, 32.42, 49.7); 35 | normalPath.cubicTo(23.56, 43.56, 19.71, 37.3, 20.9, 30.94); 36 | normalPath.close(); 37 | 38 | const goodPath = Skia.Path.Make(); 39 | goodPath.moveTo(21, 45); 40 | goodPath.cubicTo(21, 36.78, 24.26, 29.42, 29.41, 24.47); 41 | goodPath.cubicTo(33.61, 20.43, 38.05, 18, 45, 18); 42 | goodPath.cubicTo(58.25, 18, 69, 30.09, 69, 45); 43 | goodPath.cubicTo(69, 59.91, 58.25, 72, 45, 72); 44 | goodPath.cubicTo(31.75, 72, 21, 59.91, 21, 45); 45 | goodPath.close(); 46 | 47 | const c1 = getCenter(angryPath.computeTightBounds()); 48 | const c2 = getCenter(normalPath.computeTightBounds()); 49 | const c3 = getCenter(angryPath.computeTightBounds()); 50 | 51 | export const Eye = ({ flip, progress }: EyeProps) => { 52 | const path = normalPath; 53 | const c = useDerivedValue( 54 | () => interpolateVector(progress.current, inputRange, [c1, c2, c3]), 55 | [progress] 56 | ); 57 | return ( 58 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/ShapeMorphing/Helpers.ts: -------------------------------------------------------------------------------- 1 | import type { SkPath } from "@shopify/react-native-skia"; 2 | import { vec } from "@shopify/react-native-skia"; 3 | import { Dimensions } from "react-native"; 4 | 5 | const { width, height } = Dimensions.get("window"); 6 | 7 | export const inputRange = [0, 0.5, 1]; 8 | 9 | export const center = vec(width / 2, height / 2); 10 | 11 | export const interpolatePaths = ( 12 | value: number, 13 | input: number[], 14 | outputRange: SkPath[] 15 | ) => { 16 | let i = 0; 17 | for (; i <= input.length - 1; i++) { 18 | if (value >= input[i] && value <= input[i + 1]) { 19 | break; 20 | } 21 | if (i === input.length - 1) { 22 | return outputRange[i]; 23 | } 24 | } 25 | const t = (value - input[i]) / (input[i + 1] - input[i]); 26 | return outputRange[i + 1].interpolate(outputRange[i], t); 27 | }; 28 | -------------------------------------------------------------------------------- /src/ShapeMorphing/Mouth.tsx: -------------------------------------------------------------------------------- 1 | import { Group, Path, Skia, useDerivedValue } from "@shopify/react-native-skia"; 2 | import type { SkiaValue } from "@shopify/react-native-skia"; 3 | 4 | import { center, inputRange, interpolatePaths } from "./Helpers"; 5 | 6 | const angryPath = Skia.Path.Make(); 7 | angryPath.moveTo(13, 36); 8 | angryPath.cubicTo(24.69, 16.57, 36.13, 10.09, 47.31, 16.57); 9 | angryPath.cubicTo(63.87, 6.88, 72.99, -10.46, 106, 24.58); 10 | 11 | const normalPath = Skia.Path.Make(); 12 | normalPath.moveTo(1, 5); 13 | normalPath.cubicTo( 14 | 21.3645524, 15 | 8.8631006, 16 | 36.1003168, 17 | 11.50936377, 18 | 45.2072933, 19 | 12.93878949 20 | ); 21 | normalPath.cubicTo( 22 | 74.3732915, 23 | 17.51666758, 24 | 98.6375271, 25 | 19.805606623, 26 | 118, 27 | 19.805606623 28 | ); 29 | 30 | const goodPath = Skia.Path.Make(); 31 | goodPath.moveTo(1, 2); 32 | goodPath.cubicTo( 33 | 17.8783339, 34 | 14.0562303, 35 | 30.157132, 36 | 21.942809699999998, 37 | 37.8363943, 38 | 25.6597381 39 | ); 40 | goodPath.cubicTo( 41 | 70.7689993, 42 | 41.59982822, 43 | 97.4902012, 44 | 38.64845107, 45 | 118.0, 46 | 16.8056066 47 | ); 48 | 49 | const bounds = normalPath.computeTightBounds(); 50 | 51 | interface MouthProps { 52 | progress: SkiaValue; 53 | } 54 | 55 | export const Mouth = ({ progress }: MouthProps) => { 56 | const path = useDerivedValue(() => normalPath, [progress]); 57 | return ( 58 | 64 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/ShapeMorphing/ShapeMorphing.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | useValue, 5 | interpolateColors, 6 | useDerivedValue, 7 | Skia, 8 | clamp, 9 | useTouchHandler, 10 | } from "@shopify/react-native-skia"; 11 | 12 | import { Eye } from "./Eye"; 13 | import { inputRange } from "./Helpers"; 14 | import { Mouth } from "./Mouth"; 15 | import { PADDING, Slider, CURSOR_SIZE, SLIDER_WIDTH } from "./Slider"; 16 | import { Title } from "./Title"; 17 | 18 | const outputRange = ["#FDBEEB", "#FDEEBE", "#BEFDE5"].map((c) => Skia.Color(c)); 19 | const start = PADDING; 20 | const end = PADDING + SLIDER_WIDTH - CURSOR_SIZE; 21 | 22 | export const ShapeMorphing = () => { 23 | const offset = useValue(0); 24 | const x = useValue(PADDING); 25 | 26 | const progress = useDerivedValue( 27 | () => (x.current - start) / (end - start), 28 | [x] 29 | ); 30 | const color = useDerivedValue( 31 | () => interpolateColors(progress.current, inputRange, outputRange), 32 | [progress] 33 | ); 34 | return ( 35 | 36 | 37 | 38 | <Slider x={x} /> 39 | <Eye progress={progress} /> 40 | <Eye progress={progress} flip /> 41 | <Mouth progress={progress} /> 42 | </Canvas> 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/ShapeMorphing/Slider.tsx: -------------------------------------------------------------------------------- 1 | import { Dimensions } from "react-native"; 2 | import type { SkiaValue } from "@shopify/react-native-skia"; 3 | import { 4 | rrect, 5 | rect, 6 | RoundedRect, 7 | Group, 8 | vec, 9 | Line, 10 | Circle, 11 | useDerivedValue, 12 | } from "@shopify/react-native-skia"; 13 | 14 | const { width, height } = Dimensions.get("window"); 15 | const center = vec(width / 2, height / 2); 16 | export const CURSOR_SIZE = 40; 17 | export const PADDING = 32; 18 | export const SLIDER_WIDTH = width - 2 * PADDING; 19 | 20 | interface SliderProps { 21 | x: SkiaValue<number>; 22 | } 23 | 24 | export const Slider = ({ x }: SliderProps) => { 25 | const transform = useDerivedValue(() => [{ translateX: x.current }], [x]); 26 | return ( 27 | <Group transform={[{ translateY: center.y + 100 }]}> 28 | <Line 29 | p1={vec(PADDING + CURSOR_SIZE / 2, CURSOR_SIZE / 2)} 30 | p2={vec(PADDING + SLIDER_WIDTH - CURSOR_SIZE / 2, CURSOR_SIZE / 2)} 31 | style="stroke" 32 | strokeWidth={1} 33 | color="rgba(50, 50, 50, 0.5)" 34 | /> 35 | <Group transform={transform}> 36 | <Circle c={vec(CURSOR_SIZE / 2, CURSOR_SIZE / 2)} r={5} color="black" /> 37 | <RoundedRect 38 | rect={rrect( 39 | rect(0, 0, CURSOR_SIZE, CURSOR_SIZE), 40 | CURSOR_SIZE * 0.3, 41 | CURSOR_SIZE * 0.3 42 | )} 43 | color="white" 44 | strokeWidth={3} 45 | style="stroke" 46 | /> 47 | </Group> 48 | </Group> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/ShapeMorphing/Title.tsx: -------------------------------------------------------------------------------- 1 | import type { SkiaValue } from "@shopify/react-native-skia"; 2 | import { 3 | runTiming, 4 | useValueEffect, 5 | Group, 6 | Text, 7 | useFont, 8 | useValue, 9 | interpolate, 10 | useDerivedValue, 11 | } from "@shopify/react-native-skia"; 12 | import { Dimensions } from "react-native"; 13 | 14 | const offset = 100; 15 | const titleSize = 42; 16 | const labelSize = 24; 17 | const { width } = Dimensions.get("window"); 18 | 19 | enum State { 20 | BAD, 21 | OK, 22 | GOOD, 23 | } 24 | 25 | interface TitleProps { 26 | progress: SkiaValue<number>; 27 | } 28 | 29 | export const Title = ({ progress }: TitleProps) => { 30 | const x = useValue(-width / 2); 31 | const state = useDerivedValue(() => { 32 | if (progress.current <= 0.33) { 33 | return State.BAD; 34 | } else if (progress.current <= 0.66) { 35 | return State.OK; 36 | } else { 37 | return State.GOOD; 38 | } 39 | }, [progress]); 40 | const transform = useDerivedValue(() => [{ translateX: x.current }], [x]); 41 | const titleFont = useFont( 42 | // eslint-disable-next-line @typescript-eslint/no-var-requires 43 | require("./assets/SF-Pro-Display-Medium.otf"), 44 | titleSize 45 | ); 46 | const labelFont = useFont( 47 | // eslint-disable-next-line @typescript-eslint/no-var-requires 48 | require("./assets/SF-Pro-Display-Regular.otf"), 49 | labelSize 50 | ); 51 | const o1 = useDerivedValue( 52 | () => 53 | interpolate( 54 | x.current, 55 | [width / 2 - 50, width / 2, width / 2 + 50], 56 | [0, 1, 0] 57 | ), 58 | [x] 59 | ); 60 | const o2 = useDerivedValue( 61 | () => interpolate(x.current, [-50, 0, 50], [0, 1, 0]), 62 | [x] 63 | ); 64 | const o3 = useDerivedValue( 65 | () => 66 | interpolate( 67 | x.current, 68 | [-width / 2 - 50, -width / 2, -width / 2 + 50], 69 | [0, 1, 0] 70 | ), 71 | [x] 72 | ); 73 | useValueEffect(state, (s) => { 74 | if (s === State.BAD) { 75 | runTiming(x, { from: x.current, to: width / 2 }, { duration: 250 }); 76 | } else if (s === State.OK) { 77 | runTiming(x, { from: x.current, to: 0 }, { duration: 250 }); 78 | } else { 79 | runTiming(x, { from: x.current, to: -width / 2 }, { duration: 250 }); 80 | } 81 | }); 82 | if (!titleFont || !labelFont) { 83 | return null; 84 | } 85 | const t1 = "How was"; 86 | const t2 = "your ride?"; 87 | const p1 = titleFont.measureText(t1); 88 | const p2 = titleFont.measureText(t2); 89 | const l1 = "Hideous"; 90 | const l2 = "Ok"; 91 | const l3 = "Good"; 92 | const l1p = labelFont.measureText(l1); 93 | const l2p = labelFont.measureText(l2); 94 | const l3p = labelFont.measureText(l3); 95 | const y = offset + titleSize * 2 + labelSize; 96 | return ( 97 | <> 98 | <Text x={(width - p1.width) / 2} y={offset} text={t1} font={titleFont} /> 99 | <Text 100 | x={(width - p2.width) / 2} 101 | y={offset + titleSize} 102 | text={t2} 103 | font={titleFont} 104 | /> 105 | <Group transform={transform}> 106 | <Text 107 | x={-l1p.width / 2} 108 | y={y} 109 | font={labelFont} 110 | text={l1} 111 | opacity={o1} 112 | /> 113 | <Text 114 | x={(width - l2p.width) / 2} 115 | y={y} 116 | font={labelFont} 117 | text={l2} 118 | opacity={o2} 119 | /> 120 | <Text 121 | x={width - l3p.width / 2} 122 | y={y} 123 | font={labelFont} 124 | text={l3} 125 | opacity={o3} 126 | /> 127 | </Group> 128 | </> 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/ShapeMorphing/assets/SF-Pro-Display-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/ShapeMorphing/assets/SF-Pro-Display-Bold.otf -------------------------------------------------------------------------------- /src/ShapeMorphing/assets/SF-Pro-Display-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/ShapeMorphing/assets/SF-Pro-Display-Medium.otf -------------------------------------------------------------------------------- /src/ShapeMorphing/assets/SF-Pro-Display-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/ShapeMorphing/assets/SF-Pro-Display-Regular.otf -------------------------------------------------------------------------------- /src/ShapeMorphing/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ShapeMorphing"; 2 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/Eye.tsx: -------------------------------------------------------------------------------- 1 | import type { SkiaValue } from "@shopify/react-native-skia"; 2 | import { 3 | interpolateVector, 4 | translate, 5 | useDerivedValue, 6 | Path, 7 | Skia, 8 | Group, 9 | Circle, 10 | center as getCenter, 11 | } from "@shopify/react-native-skia"; 12 | 13 | import { center, interpolatePaths, inputRange } from "./Helpers"; 14 | 15 | interface EyeProps { 16 | flip?: boolean; 17 | progress: SkiaValue<number>; 18 | } 19 | 20 | const angryPath = Skia.Path.Make(); 21 | angryPath.moveTo(16, 25); 22 | angryPath.cubicTo(32.2, 27.09, 43.04, 28.2, 48.51, 28.34); 23 | angryPath.cubicTo(53.99, 28.48, 62.15, 27.78, 73, 26.25); 24 | angryPath.cubicTo(66.28, 53.93, 60.19, 69.81, 54.74, 73.88); 25 | angryPath.cubicTo(50.63, 76.96, 40.4, 74.65, 27.48, 54.51); 26 | angryPath.cubicTo(24.68, 50.15, 20.85, 40.32, 27.48, 54.51); 27 | angryPath.close(); 28 | 29 | const normalPath = Skia.Path.Make(); 30 | normalPath.moveTo(20.9, 30.94); 31 | normalPath.cubicTo(31.26, 31.66, 38.61, 32.2, 42.96, 32.56); 32 | normalPath.cubicTo(66.94, 34.53, 79.65, 36.45, 81.11, 38.32); 33 | normalPath.cubicTo(83.9, 41.9, 73.77, 56.6, 65.83, 59.52); 34 | normalPath.cubicTo(61.95, 60.95, 45.72, 58.91, 32.42, 49.7); 35 | normalPath.cubicTo(23.56, 43.56, 19.71, 37.3, 20.9, 30.94); 36 | normalPath.close(); 37 | 38 | const goodPath = Skia.Path.Make(); 39 | goodPath.moveTo(21, 45); 40 | goodPath.cubicTo(21, 36.78, 24.26, 29.42, 29.41, 24.47); 41 | goodPath.cubicTo(33.61, 20.43, 38.05, 18, 45, 18); 42 | goodPath.cubicTo(58.25, 18, 69, 30.09, 69, 45); 43 | goodPath.cubicTo(69, 59.91, 58.25, 72, 45, 72); 44 | goodPath.cubicTo(31.75, 72, 21, 59.91, 21, 45); 45 | goodPath.close(); 46 | 47 | const c1 = getCenter(angryPath.computeTightBounds()); 48 | const c2 = getCenter(normalPath.computeTightBounds()); 49 | const c3 = getCenter(angryPath.computeTightBounds()); 50 | 51 | export const Eye = ({ flip, progress }: EyeProps) => { 52 | const path = useDerivedValue( 53 | () => 54 | interpolatePaths(progress.current, inputRange, [ 55 | angryPath, 56 | normalPath, 57 | goodPath, 58 | ]), 59 | [progress] 60 | ); 61 | const c = useDerivedValue( 62 | () => interpolateVector(progress.current, inputRange, [c1, c2, c3]), 63 | [progress] 64 | ); 65 | return ( 66 | <Group 67 | transform={[ 68 | ...translate(center), 69 | { translateY: -125 }, 70 | { scaleX: flip ? -1 : 1 }, 71 | ]} 72 | > 73 | <Path path={path} color="black" style="stroke" strokeWidth={4} /> 74 | <Circle c={c} r={5} color="black" /> 75 | </Group> 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/Helpers.ts: -------------------------------------------------------------------------------- 1 | import type { SkPath } from "@shopify/react-native-skia"; 2 | import { vec } from "@shopify/react-native-skia"; 3 | import { Dimensions } from "react-native"; 4 | 5 | const { width, height } = Dimensions.get("window"); 6 | 7 | export const inputRange = [0, 0.5, 1]; 8 | 9 | export const center = vec(width / 2, height / 2); 10 | 11 | export const interpolatePaths = ( 12 | value: number, 13 | input: number[], 14 | outputRange: SkPath[] 15 | ) => { 16 | let i = 0; 17 | for (; i <= input.length - 1; i++) { 18 | if (value >= input[i] && value <= input[i + 1]) { 19 | break; 20 | } 21 | if (i === input.length - 1) { 22 | return outputRange[i]; 23 | } 24 | } 25 | const t = (value - input[i]) / (input[i + 1] - input[i]); 26 | return outputRange[i + 1].interpolate(outputRange[i], t); 27 | }; 28 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/Mouth.tsx: -------------------------------------------------------------------------------- 1 | import { Group, Path, Skia, useDerivedValue } from "@shopify/react-native-skia"; 2 | import type { SkiaValue } from "@shopify/react-native-skia"; 3 | 4 | import { center, inputRange, interpolatePaths } from "./Helpers"; 5 | 6 | const angryPath = Skia.Path.Make(); 7 | angryPath.moveTo(13, 36); 8 | angryPath.cubicTo(24.69, 16.57, 36.13, 10.09, 47.31, 16.57); 9 | angryPath.cubicTo(63.87, 6.88, 72.99, -10.46, 106, 24.58); 10 | 11 | const normalPath = Skia.Path.Make(); 12 | normalPath.moveTo(1, 5); 13 | normalPath.cubicTo( 14 | 21.3645524, 15 | 8.8631006, 16 | 36.1003168, 17 | 11.50936377, 18 | 45.2072933, 19 | 12.93878949 20 | ); 21 | normalPath.cubicTo( 22 | 74.3732915, 23 | 17.51666758, 24 | 98.6375271, 25 | 19.805606623, 26 | 118, 27 | 19.805606623 28 | ); 29 | 30 | const goodPath = Skia.Path.Make(); 31 | goodPath.moveTo(1, 2); 32 | goodPath.cubicTo( 33 | 17.8783339, 34 | 14.0562303, 35 | 30.157132, 36 | 21.942809699999998, 37 | 37.8363943, 38 | 25.6597381 39 | ); 40 | goodPath.cubicTo( 41 | 70.7689993, 42 | 41.59982822, 43 | 97.4902012, 44 | 38.64845107, 45 | 118.0, 46 | 16.8056066 47 | ); 48 | 49 | const bounds = normalPath.computeTightBounds(); 50 | 51 | interface MouthProps { 52 | progress: SkiaValue<number>; 53 | } 54 | 55 | export const Mouth = ({ progress }: MouthProps) => { 56 | const path = useDerivedValue( 57 | () => 58 | interpolatePaths(progress.current, inputRange, [ 59 | angryPath, 60 | normalPath, 61 | goodPath, 62 | ]), 63 | [progress] 64 | ); 65 | return ( 66 | <Group 67 | transform={[ 68 | { translateX: center.x - bounds.width / 2 }, 69 | { translateY: center.y - bounds.height / 2 }, 70 | ]} 71 | > 72 | <Path path={path} color="black" style="stroke" strokeWidth={4} /> 73 | </Group> 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/ShapeMorphing.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | useValue, 5 | interpolateColors, 6 | useDerivedValue, 7 | Skia, 8 | clamp, 9 | useTouchHandler, 10 | } from "@shopify/react-native-skia"; 11 | 12 | import { Eye } from "./Eye"; 13 | import { inputRange } from "./Helpers"; 14 | import { Mouth } from "./Mouth"; 15 | import { PADDING, Slider, CURSOR_SIZE, SLIDER_WIDTH } from "./Slider"; 16 | import { Title } from "./Title"; 17 | 18 | const outputRange = ["#FDBEEB", "#FDEEBE", "#BEFDE5"].map((c) => Skia.Color(c)); 19 | const start = PADDING; 20 | const end = PADDING + SLIDER_WIDTH - CURSOR_SIZE; 21 | 22 | export const ShapeMorphing = () => { 23 | const offset = useValue(0); 24 | const x = useValue(PADDING); 25 | const onTouch = useTouchHandler({ 26 | onStart: (pt) => { 27 | offset.current = x.current - pt.x; 28 | }, 29 | onActive: (pt) => { 30 | x.current = clamp(offset.current + pt.x, start, end); 31 | }, 32 | }); 33 | const progress = useDerivedValue( 34 | () => (x.current - start) / (end - start), 35 | [x] 36 | ); 37 | const color = useDerivedValue( 38 | () => interpolateColors(progress.current, inputRange, outputRange), 39 | [progress] 40 | ); 41 | return ( 42 | <Canvas onTouch={onTouch} style={{ flex: 1 }}> 43 | <Fill color={color} /> 44 | <Title progress={progress} /> 45 | <Slider x={x} /> 46 | <Eye progress={progress} /> 47 | <Eye progress={progress} flip /> 48 | <Mouth progress={progress} /> 49 | </Canvas> 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/Slider.tsx: -------------------------------------------------------------------------------- 1 | import { Dimensions } from "react-native"; 2 | import type { SkiaValue } from "@shopify/react-native-skia"; 3 | import { 4 | rrect, 5 | rect, 6 | RoundedRect, 7 | Group, 8 | vec, 9 | Line, 10 | Circle, 11 | useDerivedValue, 12 | } from "@shopify/react-native-skia"; 13 | 14 | const { width, height } = Dimensions.get("window"); 15 | const center = vec(width / 2, height / 2); 16 | export const CURSOR_SIZE = 40; 17 | export const PADDING = 32; 18 | export const SLIDER_WIDTH = width - 2 * PADDING; 19 | 20 | interface SliderProps { 21 | x: SkiaValue<number>; 22 | } 23 | 24 | export const Slider = ({ x }: SliderProps) => { 25 | const transform = useDerivedValue(() => [{ translateX: x.current }], [x]); 26 | return ( 27 | <Group transform={[{ translateY: center.y + 100 }]}> 28 | <Line 29 | p1={vec(PADDING + CURSOR_SIZE / 2, CURSOR_SIZE / 2)} 30 | p2={vec(PADDING + SLIDER_WIDTH - CURSOR_SIZE / 2, CURSOR_SIZE / 2)} 31 | style="stroke" 32 | strokeWidth={1} 33 | color="rgba(50, 50, 50, 0.5)" 34 | /> 35 | <Group transform={transform}> 36 | <Circle c={vec(CURSOR_SIZE / 2, CURSOR_SIZE / 2)} r={5} color="black" /> 37 | <RoundedRect 38 | rect={rrect( 39 | rect(0, 0, CURSOR_SIZE, CURSOR_SIZE), 40 | CURSOR_SIZE * 0.3, 41 | CURSOR_SIZE * 0.3 42 | )} 43 | color="white" 44 | strokeWidth={3} 45 | style="stroke" 46 | /> 47 | </Group> 48 | </Group> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Final/Title.tsx: -------------------------------------------------------------------------------- 1 | import type { SkiaValue } from "@shopify/react-native-skia"; 2 | import { 3 | runTiming, 4 | useValueEffect, 5 | Group, 6 | Text, 7 | useFont, 8 | useValue, 9 | interpolate, 10 | useDerivedValue, 11 | } from "@shopify/react-native-skia"; 12 | import { Dimensions } from "react-native"; 13 | 14 | const offset = 100; 15 | const titleSize = 42; 16 | const labelSize = 24; 17 | const { width } = Dimensions.get("window"); 18 | 19 | enum State { 20 | BAD, 21 | OK, 22 | GOOD, 23 | } 24 | 25 | interface TitleProps { 26 | progress: SkiaValue<number>; 27 | } 28 | 29 | export const Title = ({ progress }: TitleProps) => { 30 | const x = useValue(-width / 2); 31 | const state = useDerivedValue(() => { 32 | if (progress.current <= 0.33) { 33 | return State.BAD; 34 | } else if (progress.current <= 0.66) { 35 | return State.OK; 36 | } else { 37 | return State.GOOD; 38 | } 39 | }, [progress]); 40 | const transform = useDerivedValue(() => [{ translateX: x.current }], [x]); 41 | const titleFont = useFont( 42 | // eslint-disable-next-line @typescript-eslint/no-var-requires 43 | require("./assets/SF-Pro-Display-Medium.otf"), 44 | titleSize 45 | ); 46 | const labelFont = useFont( 47 | // eslint-disable-next-line @typescript-eslint/no-var-requires 48 | require("./assets/SF-Pro-Display-Regular.otf"), 49 | labelSize 50 | ); 51 | const o1 = useDerivedValue( 52 | () => 53 | interpolate( 54 | x.current, 55 | [width / 2 - 50, width / 2, width / 2 + 50], 56 | [0, 1, 0] 57 | ), 58 | [x] 59 | ); 60 | const o2 = useDerivedValue( 61 | () => interpolate(x.current, [-50, 0, 50], [0, 1, 0]), 62 | [x] 63 | ); 64 | const o3 = useDerivedValue( 65 | () => 66 | interpolate( 67 | x.current, 68 | [-width / 2 - 50, -width / 2, -width / 2 + 50], 69 | [0, 1, 0] 70 | ), 71 | [x] 72 | ); 73 | useValueEffect(state, (s) => { 74 | if (s === State.BAD) { 75 | runTiming(x, { from: x.current, to: width / 2 }, { duration: 250 }); 76 | } else if (s === State.OK) { 77 | runTiming(x, { from: x.current, to: 0 }, { duration: 250 }); 78 | } else { 79 | runTiming(x, { from: x.current, to: -width / 2 }, { duration: 250 }); 80 | } 81 | }); 82 | if (!titleFont || !labelFont) { 83 | return null; 84 | } 85 | const t1 = "How was"; 86 | const t2 = "your ride?"; 87 | const p1 = titleFont.measureText(t1); 88 | const p2 = titleFont.measureText(t2); 89 | const l1 = "Hideous"; 90 | const l2 = "Ok"; 91 | const l3 = "Good"; 92 | const l1p = labelFont.measureText(l1); 93 | const l2p = labelFont.measureText(l2); 94 | const l3p = labelFont.measureText(l3); 95 | const y = offset + titleSize * 2 + labelSize; 96 | return ( 97 | <> 98 | <Text x={(width - p1.width) / 2} y={offset} text={t1} font={titleFont} /> 99 | <Text 100 | x={(width - p2.width) / 2} 101 | y={offset + titleSize} 102 | text={t2} 103 | font={titleFont} 104 | /> 105 | <Group transform={transform}> 106 | <Text 107 | x={-l1p.width / 2} 108 | y={y} 109 | font={labelFont} 110 | text={l1} 111 | opacity={o1} 112 | /> 113 | <Text 114 | x={(width - l2p.width) / 2} 115 | y={y} 116 | font={labelFont} 117 | text={l2} 118 | opacity={o2} 119 | /> 120 | <Text 121 | x={width - l3p.width / 2} 122 | y={y} 123 | font={labelFont} 124 | text={l3} 125 | opacity={o3} 126 | /> 127 | </Group> 128 | </> 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/ShapeMorphing/steps/Readme.md: -------------------------------------------------------------------------------- 1 | # Feedback Slider 2 | 3 | ## Add a touch handler 4 | 5 | Add a touch handler to move the slider around. 6 | We need to create an offset value to remember where we were when the gesture started. 7 | We also need to clamp the value between `start` and `end`. 8 | 9 | ## Interpolate paths 10 | 11 | In the `Eye` and `Mouth` component, we can use the `progress` property to interpolate the path. 12 | There are always three paths: `angryPath`, `normalPath`, and `goodPath`. 13 | 14 | Two paths can be interpolated with each other using `path.interpolate()`. 15 | Two or more paths can be interpolated using `interpolatePaths` -------------------------------------------------------------------------------- /src/SkiaLogo/Background.tsx: -------------------------------------------------------------------------------- 1 | import type { SkiaValue } from "@shopify/react-native-skia"; 2 | import { 3 | Fill, 4 | RadialGradient, 5 | vec, 6 | useDerivedValue, 7 | mixColors, 8 | } from "@shopify/react-native-skia"; 9 | import React from "react"; 10 | import { Dimensions } from "react-native"; 11 | 12 | const { width, height } = Dimensions.get("window"); 13 | const c = vec(width / 2, height / 2); 14 | const r = width / 2; 15 | 16 | interface BackgroundProps { 17 | progress?: SkiaValue<number>; 18 | } 19 | 20 | export const Background = ({ progress }: BackgroundProps) => { 21 | const colors = useDerivedValue( 22 | () => [mixColors(progress?.current ?? 1, "#040404", "#303030"), "#040404"], 23 | [progress] 24 | ); 25 | return ( 26 | <Fill> 27 | <RadialGradient c={c} r={r} colors={colors} /> 28 | </Fill> 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/SkiaLogo/PathGradient.tsx: -------------------------------------------------------------------------------- 1 | import type { SkPath, SkiaValue, SkPaint } from "@shopify/react-native-skia"; 2 | import { 3 | interpolateColors, 4 | dist, 5 | StrokeCap, 6 | PaintStyle, 7 | Skia, 8 | Drawing, 9 | PathVerb, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import bezier from "adaptive-bezier-curve"; 13 | 14 | import { getPointAtLength } from "../components/Math"; 15 | 16 | const toVec = ([x, y]: [number, number]) => Skia.Point(x, y); 17 | 18 | interface Line { 19 | from: [number, number]; 20 | to: [number, number]; 21 | paint: SkPaint; 22 | startLength: number; 23 | endLength: number; 24 | } 25 | 26 | interface PathGradientProps { 27 | path: SkPath; 28 | colors: string[]; 29 | progress: SkiaValue<number>; 30 | strokeWidth: number; 31 | } 32 | 33 | export const PathGradient = ({ 34 | path, 35 | colors, 36 | progress, 37 | strokeWidth, 38 | }: PathGradientProps) => { 39 | const paint = Skia.Paint(); 40 | paint.setAntiAlias(true); 41 | paint.setStyle(PaintStyle.Stroke); 42 | paint.setStrokeWidth(strokeWidth); 43 | paint.setStrokeCap(StrokeCap.Round); 44 | const points = path 45 | .toCmds() 46 | .map(([verb, sx, sy, c1x, c1y, c2X, c2Y, ex, ey]) => { 47 | if (verb === PathVerb.Cubic) { 48 | return bezier([sx, sy], [c1x, c1y], [c2X, c2Y], [ex, ey]); 49 | } 50 | return null; 51 | }) 52 | .flat(); 53 | const LENGTH = points.reduce((acc, point, i) => { 54 | const prev = points[i - 1]; 55 | if (i === 0 || point === null || prev === null) { 56 | return acc; 57 | } 58 | return acc + dist(toVec(prev), toVec(point)); 59 | }, 0); 60 | const delta = LENGTH / colors.length; 61 | const inputRange = colors.map((_, j) => j * delta); 62 | const outputRange = colors.map((cl) => Skia.Color(cl)); 63 | const lines: Line[] = []; 64 | points.forEach((point, i) => { 65 | if (point === null) { 66 | return; 67 | } 68 | const prev = points[i - 1]; 69 | if (!prev) { 70 | return; 71 | } 72 | const from = toVec(prev); 73 | const to = toVec(point); 74 | const length = dist(from, to); 75 | const startLength = lines[lines.length - 1] 76 | ? lines[lines.length - 1].endLength 77 | : 0; 78 | const endLength = startLength + length; 79 | // const c1 = interpolateColors(prevLength, inputRange, outputRange); 80 | // const c2 = interpolateColors(totalLength, inputRange, outputRange); 81 | const p = paint.copy(); 82 | p.setColor(interpolateColors(startLength, inputRange, outputRange)); 83 | 84 | lines.push({ 85 | from: prev, 86 | to: point, 87 | paint: p, 88 | startLength, 89 | endLength, 90 | }); 91 | }); 92 | 93 | return ( 94 | <Drawing 95 | drawing={({ canvas }) => { 96 | const t = progress.current * LENGTH; 97 | lines.forEach( 98 | ({ 99 | from: [x1, y1], 100 | to: [x2, y2], 101 | paint: p, 102 | startLength, 103 | endLength, 104 | }) => { 105 | if (endLength <= t) { 106 | canvas.drawLine(x1, y1, x2, y2, p); 107 | } else if (startLength <= t) { 108 | const u = t - startLength; 109 | const { x: x3, y: y3 } = getPointAtLength( 110 | u, 111 | { x: x1, y: y1 }, 112 | { x: x2, y: y2 } 113 | ); 114 | canvas.drawLine(x1, y1, x3, y3, p); 115 | } 116 | } 117 | ); 118 | }} 119 | /> 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/SkiaLogo/Readme.md: -------------------------------------------------------------------------------- 1 | # Skia Logo 2 | 3 | Draw and animate the Skia logo. 4 | 5 | ## Step 1 - Scale the path 6 | 7 | Draw and scale the path. 8 | The path can be scaled using the [Fitbox component](https://shopify.github.io/react-native-skia/docs/group/#fitbox). 9 | The source and destination rectangle are available via the starting file. 10 | 11 | 12 | ## Step 2 - Add a linear Gradient 13 | 14 | You can add [a linear gradient](https://shopify.github.io/react-native-skia/docs/shaders/gradients/#linear-gradient) to the path. 15 | We suggest the colors below. 16 | 17 | ```ts 18 | const colors = ["#3FCEBC", "#3CBCEB", "#5F96E7", "#816FE3", "#9F5EE2", "#DE589F", "#FF645E", "#FDA859", "#FAEC54", "#9EE671", "#41E08D"]; 19 | ``` 20 | 21 | ## Step 3 - Animate the path 22 | 23 | We can create an animation value to animated the stroke of the path. 24 | 25 | ```tsx 26 | const progress = useTiming( 27 | { to: 1, loop: true }, 28 | { duration: 3000, easing: Easing.bezier(0.65, 0, 0.35, 1) } 29 | ); 30 | ``` 31 | 32 | The stroke can be updated via the `start` and `end` property. 33 | In the example below we trim the path 25% at the beginning and 25% at the end. 34 | 35 | ```jsx 36 | <Path path={path} start={0.25} end={0.75} style="stroke" strokeWidth={10} /> 37 | ``` 38 | 39 | ## Bonus 40 | 41 | You can draw the gradient along the path. 42 | To do so, you can use the `adaptive-bezier-curve` module to transform your path into a series of lines. 43 | In the example below we convert the path into a series of command and convert bezier curves into lines. 44 | 45 | ```tsx 46 | import bezier from "adaptive-bezier-curve"; 47 | 48 | const points = path 49 | .toCmds() 50 | .map(([verb, sx, sy, c1x, c1y, c2X, c2Y, ex, ey]) => { 51 | if (verb === PathVerb.Cubic) { 52 | return bezier([sx, sy], [c1x, c1y], [c2X, c2Y], [ex, ey]); 53 | } 54 | return null; 55 | }) 56 | .flat(); 57 | ``` 58 | -------------------------------------------------------------------------------- /src/SkiaLogo/SkiaLogo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import { Dimensions } from "react-native"; 13 | 14 | import { Background } from "./Background"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const path = Skia.Path.MakeFromSVGString( 18 | // eslint-disable-next-line max-len 19 | "M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432" 20 | )!; 21 | 22 | const PADDING = 32; 23 | const src = rect(0, 0, 2139, 928); 24 | const dst = rect(PADDING, PADDING, width - PADDING * 2, height - PADDING * 2); 25 | 26 | export const SkiaLogo = () => { 27 | return ( 28 | <Canvas style={{ flex: 1 }}> 29 | <Background /> 30 | <Path 31 | path={path} 32 | style="stroke" 33 | strokeWidth={116} 34 | strokeCap="round" 35 | strokeJoin="round" 36 | color="white" 37 | /> 38 | </Canvas> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/SkiaLogo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./SkiaLogo"; 2 | -------------------------------------------------------------------------------- /src/SkiaLogo/steps/Bonus.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | } from "@shopify/react-native-skia"; 9 | import React from "react"; 10 | import { Dimensions } from "react-native"; 11 | 12 | import { Background } from "../Background"; 13 | import { PathGradient } from "../PathGradient"; 14 | 15 | const { width, height } = Dimensions.get("window"); 16 | const path = Skia.Path.MakeFromSVGString( 17 | // eslint-disable-next-line max-len 18 | "M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432" 19 | )!; 20 | 21 | const PADDING = 32; 22 | 23 | export const SkiaLogo = () => { 24 | const progress = useTiming( 25 | { to: 1, loop: true }, 26 | { duration: 3000, easing: Easing.bezier(0.65, 0, 0.35, 1) } 27 | ); 28 | 29 | return ( 30 | <Canvas style={{ flex: 1 }} mode="continuous"> 31 | <Background /> 32 | <FitBox 33 | src={rect(0, 0, 2139, 928)} 34 | dst={rect(PADDING, PADDING, width - PADDING * 2, height - PADDING * 2)} 35 | > 36 | <PathGradient 37 | path={path} 38 | colors={[ 39 | "#3FCEBC", 40 | "#3CBCEB", 41 | "#5F96E7", 42 | "#816FE3", 43 | "#9F5EE2", 44 | "#DE589F", 45 | "#FF645E", 46 | "#FDA859", 47 | "#FAEC54", 48 | "#9EE671", 49 | "#41E08D", 50 | ]} 51 | progress={progress} 52 | strokeWidth={116} 53 | /> 54 | </FitBox> 55 | </Canvas> 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/SkiaLogo/steps/Final.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import { Dimensions } from "react-native"; 13 | 14 | import { Background } from "../Background"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const path = Skia.Path.MakeFromSVGString( 18 | // eslint-disable-next-line max-len 19 | "M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432" 20 | )!; 21 | 22 | const PADDING = 32; 23 | 24 | export const SkiaLogo = () => { 25 | const progress = useTiming( 26 | { to: 1, loop: true }, 27 | { duration: 3000, easing: Easing.bezier(0.65, 0, 0.35, 1) } 28 | ); 29 | return ( 30 | <Canvas style={{ flex: 1 }}> 31 | <Background /> 32 | <FitBox 33 | src={rect(0, 0, 2139, 928)} 34 | dst={rect(PADDING, PADDING, width - PADDING * 2, height - PADDING * 2)} 35 | > 36 | <LinearGradient 37 | start={path.getPoint(0)} 38 | end={path.getLastPt()} 39 | colors={[ 40 | "#3FCEBC", 41 | "#3CBCEB", 42 | "#5F96E7", 43 | "#816FE3", 44 | "#9F5EE2", 45 | "#DE589F", 46 | "#FF645E", 47 | "#FDA859", 48 | "#FAEC54", 49 | "#9EE671", 50 | "#41E08D", 51 | ]} 52 | /> 53 | <Path 54 | path={path} 55 | end={progress} 56 | style="stroke" 57 | strokeWidth={116} 58 | strokeCap="round" 59 | strokeJoin="round" 60 | /> 61 | </FitBox> 62 | </Canvas> 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/SkiaLogo/steps/Start.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import { Dimensions } from "react-native"; 13 | 14 | import { Background } from "../Background"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const path = Skia.Path.MakeFromSVGString( 18 | // eslint-disable-next-line max-len 19 | "M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432" 20 | )!; 21 | 22 | const PADDING = 32; 23 | const src = rect(0, 0, 2139, 928); 24 | const dst = rect(PADDING, PADDING, width - PADDING * 2, height - PADDING * 2); 25 | 26 | export const SkiaLogo = () => { 27 | return ( 28 | <Canvas style={{ flex: 1 }}> 29 | <Background /> 30 | <Path 31 | path={path} 32 | style="stroke" 33 | strokeWidth={116} 34 | strokeCap="round" 35 | strokeJoin="round" 36 | color="white" 37 | /> 38 | </Canvas> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/SkiaLogo/steps/Step1.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from "@shopify/react-native-skia"; 11 | import React from "react"; 12 | import { Dimensions } from "react-native"; 13 | 14 | import { Background } from "../Background"; 15 | 16 | const { width, height } = Dimensions.get("window"); 17 | const path = Skia.Path.MakeFromSVGString( 18 | // eslint-disable-next-line max-len 19 | "M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432" 20 | )!; 21 | 22 | const PADDING = 32; 23 | 24 | export const SkiaLogo = () => { 25 | return ( 26 | <Canvas style={{ flex: 1 }}> 27 | <Background /> 28 | <FitBox 29 | src={rect(0, 0, 2139, 928)} 30 | dst={rect(PADDING, PADDING, width - PADDING * 2, height - PADDING * 2)} 31 | > 32 | <LinearGradient 33 | start={path.getPoint(0)} 34 | end={path.getLastPt()} 35 | colors={[ 36 | "#3FCEBC", 37 | "#3CBCEB", 38 | "#5F96E7", 39 | "#816FE3", 40 | "#9F5EE2", 41 | "#DE589F", 42 | "#FF645E", 43 | "#FDA859", 44 | "#FAEC54", 45 | "#9EE671", 46 | "#41E08D", 47 | ]} 48 | /> 49 | <Path 50 | path={path} 51 | style="stroke" 52 | strokeWidth={116} 53 | strokeCap="round" 54 | strokeJoin="round" 55 | /> 56 | </FitBox> 57 | </Canvas> 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/Stickers/AppjsLogo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from '@shopify/react-native-skia'; 11 | import React from 'react'; 12 | import { Dimensions } from 'react-native'; 13 | 14 | const s = Skia.Path.MakeFromSVGString( 15 | 'M259.492,39.77a9.87,9.87,0,0,1-3.846-.7,6.784,6.784,0,0,1-2.653-1.989,5.711,5.711,0,0,1-1.193-2.918h4.277a2.846,2.846,0,0,0,1.061,1.658,3.743,3.743,0,0,0,2.288.663,2.988,2.988,0,0,0,2.022-.564,1.665,1.665,0,0,0,.663-1.293,1.394,1.394,0,0,0-.929-1.426,15.036,15.036,0,0,0-2.586-.763q-1.061-.232-2.155-.564a11.072,11.072,0,0,1-2.022-.829,4.688,4.688,0,0,1-1.459-1.326A3.512,3.512,0,0,1,252.4,27.7a4.68,4.68,0,0,1,1.724-3.68,8.712,8.712,0,0,1,9.549-.133,5.485,5.485,0,0,1,2.089,3.747h-4.012q-.365-1.824-2.752-1.824a3.2,3.2,0,0,0-1.857.464,1.394,1.394,0,0,0-.63,1.16q0,.729.961,1.16a14.124,14.124,0,0,0,2.553.8,30.2,30.2,0,0,1,3.15.9,5.122,5.122,0,0,1,2.321,1.426,3.808,3.808,0,0,1,.862,2.686,4.615,4.615,0,0,1-.8,2.752,5.5,5.5,0,0,1-2.387,1.923A8.96,8.96,0,0,1,259.492,39.77Z' 16 | )!; 17 | let M = Skia.Matrix(); 18 | M.preTranslate(-173.163, -15.499); 19 | s.transform(M); 20 | 21 | const w = Skia.Path.MakeFromSVGString( 22 | 'M199.731,66.216a2.671,2.671,0,0,1-1.923-.729,2.4,2.4,0,0,1-.729-1.757,2.435,2.435,0,0,1,.729-1.79,2.671,2.671,0,0,1,1.923-.729,2.557,2.557,0,0,1,1.89.729,2.382,2.382,0,0,1,.763,1.79,2.345,2.345,0,0,1-.763,1.757A2.557,2.557,0,0,1,199.731,66.216Z' 23 | )!; 24 | M = Skia.Matrix(); 25 | M.preTranslate(-135.515, -42.112); 26 | w.transform(M); 27 | 28 | const m = Skia.Path.MakeFromSVGString( 29 | 'M132.354,46.666V22.926h3.78l.464,2.354a7.639,7.639,0,0,1,2.089-1.923,6.354,6.354,0,0,1,3.415-.829,7.72,7.72,0,0,1,4.144,1.127,8.077,8.077,0,0,1,2.885,3.084,9.168,9.168,0,0,1,1.061,4.443,9.168,9.168,0,0,1-1.061,4.443,8.144,8.144,0,0,1-2.885,3.05A7.9,7.9,0,0,1,142.1,39.77a7.18,7.18,0,0,1-3.25-.7,5.858,5.858,0,0,1-2.255-1.956v9.549Zm8.853-10.61a4.481,4.481,0,0,0,3.349-1.359,4.82,4.82,0,0,0,1.326-3.515,4.936,4.936,0,0,0-1.326-3.548,4.763,4.763,0,0,0-6.731,0,4.89,4.89,0,0,0-1.293,3.515,5.008,5.008,0,0,0,1.293,3.548A4.522,4.522,0,0,0,141.207,36.056Z' 30 | )!; 31 | M = Skia.Matrix(); 32 | M.preTranslate(-90.985, -15.499); 33 | m.transform(M); 34 | 35 | const x = Skia.Path.MakeFromSVGString( 36 | 'M63.032,46.666V22.926h3.78l.464,2.354a7.635,7.635,0,0,1,2.089-1.923,6.353,6.353,0,0,1,3.415-.829,7.721,7.721,0,0,1,4.145,1.127,8.077,8.077,0,0,1,2.885,3.084,9.168,9.168,0,0,1,1.061,4.443,9.168,9.168,0,0,1-1.061,4.443,8.144,8.144,0,0,1-2.885,3.05A7.9,7.9,0,0,1,72.78,39.77a7.179,7.179,0,0,1-3.249-.7,5.856,5.856,0,0,1-2.255-1.956v9.549Zm8.853-10.61A4.482,4.482,0,0,0,75.233,34.7a4.82,4.82,0,0,0,1.326-3.515,4.936,4.936,0,0,0-1.326-3.548,4.763,4.763,0,0,0-6.731,0,4.89,4.89,0,0,0-1.293,3.515A5.008,5.008,0,0,0,68.5,34.7,4.522,4.522,0,0,0,71.885,36.056Z' 37 | )!; 38 | M = Skia.Matrix(); 39 | M.preTranslate(-43.292, -15.499); 40 | x.transform(M); 41 | 42 | const d = Skia.Path.MakeFromSVGString( 43 | 'M6.273,39.77a7.947,7.947,0,0,1-3.481-.663A4.806,4.806,0,0,1,.769,37.283,4.814,4.814,0,0,1,.106,34.8,4.525,4.525,0,0,1,1.9,31.083a8.514,8.514,0,0,1,5.371-1.426h4.178v-.4a3.045,3.045,0,0,0-.962-2.487,3.619,3.619,0,0,0-2.387-.8,4.02,4.02,0,0,0-2.255.63A2.584,2.584,0,0,0,4.649,28.4H.5A5.855,5.855,0,0,1,1.7,25.28a6.769,6.769,0,0,1,2.719-2.022,9.126,9.126,0,0,1,3.713-.729,8.158,8.158,0,0,1,5.537,1.757,6.235,6.235,0,0,1,2.023,4.973V39.372H12.076l-.4-2.652a6.08,6.08,0,0,1-2.056,2.188A5.929,5.929,0,0,1,6.273,39.77Zm.962-3.316a3.464,3.464,0,0,0,2.818-1.194,5.723,5.723,0,0,0,1.293-2.951H7.732a3.7,3.7,0,0,0-2.42.63,1.852,1.852,0,0,0-.729,1.492,1.738,1.738,0,0,0,.729,1.492A3.2,3.2,0,0,0,7.235,36.454Z' 44 | )!; 45 | M = Skia.Matrix(); 46 | M.preTranslate(0, -15.499); 47 | d.transform(M); 48 | 49 | const p2 = Skia.Path.MakeFromSVGString( 50 | 'M221.716,4.874a2.743,2.743,0,0,1-1.923-.7,2.326,2.326,0,0,1-.729-1.757A2.221,2.221,0,0,1,219.793.7a2.743,2.743,0,0,1,1.923-.7,2.624,2.624,0,0,1,1.89.7,2.177,2.177,0,0,1,.763,1.724,2.277,2.277,0,0,1-.763,1.757A2.624,2.624,0,0,1,221.716,4.874Zm-5.471,26.293V27.553h1.293a2.264,2.264,0,0,0,1.558-.431,1.924,1.924,0,0,0,.464-1.459V7.427H223.8V25.663q0,2.984-1.525,4.244a6.123,6.123,0,0,1-4.078,1.26Z' 51 | )!; 52 | M = Skia.Matrix(); 53 | M.preTranslate(-148.702, 0); 54 | p2.transform(M); 55 | 56 | export const AppjsLogo = ({ width, height }) => { 57 | console.log(JSON.stringify(p2.computeTightBounds())); 58 | 59 | return ( 60 | <Canvas style={{ width, height }}> 61 | <FitBox src={rect(0, 0, 93, 31)} dst={rect(0, 0, width, height)}> 62 | <LinearGradient 63 | start={s.getPoint(0)} 64 | end={s.getLastPt()} 65 | colors={['rgb(0,51,204)']} 66 | /> 67 | <Path path={s} strokeCap="round" strokeJoin="round" /> 68 | <Path path={w} strokeCap="round" strokeJoin="round" /> 69 | <Path path={m} strokeCap="round" strokeJoin="round" /> 70 | <Path path={x} strokeCap="round" strokeJoin="round" /> 71 | <Path path={d} strokeCap="round" strokeJoin="round" /> 72 | <Path path={p2} strokeCap="round" strokeJoin="round" /> 73 | </FitBox> 74 | </Canvas> 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/Stickers/ReactLogo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | Circle, 5 | Oval, 6 | rect, 7 | vec, 8 | Group, 9 | SweepGradient, 10 | RadialGradient, 11 | BlurMask, 12 | } from '@shopify/react-native-skia'; 13 | import React from 'react'; 14 | import { Dimensions } from 'react-native'; 15 | 16 | export const ReactLogo = ({ width, height }) => { 17 | const ellipsisAspectRatio = 180 / 470; 18 | const rx = width / 2 - width * 0.05; 19 | const ry = rx * ellipsisAspectRatio; 20 | 21 | // Origin of the Logo 22 | const center = vec(width / 2, height / 2); 23 | // Radius of the middle circle 24 | const r = 0.08 * width; 25 | // Rectangle to draw the oval in 26 | const rct = rect(center.x - rx, center.y - ry, rx * 2, ry * 2); 27 | // Some colors 28 | const c1 = '#3884FF'; 29 | const c2 = '#51D3ED'; 30 | const strokeWidth = r; 31 | return ( 32 | <Canvas style={{ width, height }}> 33 | <Group> 34 | <BlurMask blur={2} style="inner" /> 35 | <Circle c={center} color="lightblue" r={r}> 36 | <RadialGradient 37 | c={vec(center.x + r / 2, center.y + r / 2)} 38 | colors={[c1, c2]} 39 | r={2 * r} 40 | /> 41 | </Circle> 42 | <Group> 43 | <SweepGradient c={center} colors={[c1, c2, c1]} /> 44 | <Group transform={[{ scaleX: -1 }]} origin={center}> 45 | <Oval rect={rct} style="stroke" strokeWidth={strokeWidth} /> 46 | </Group> 47 | <Group transform={[{ rotate: Math.PI / 3 }]} origin={center}> 48 | <Oval rect={rct} style="stroke" strokeWidth={strokeWidth} /> 49 | </Group> 50 | <Group transform={[{ rotate: -Math.PI / 3 }]} origin={center}> 51 | <Oval rect={rct} style="stroke" strokeWidth={strokeWidth} /> 52 | </Group> 53 | </Group> 54 | </Group> 55 | </Canvas> 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/Stickers/SkiaLogo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | FitBox, 4 | rect, 5 | Skia, 6 | useTiming, 7 | Easing, 8 | Path, 9 | LinearGradient, 10 | } from '@shopify/react-native-skia'; 11 | import React from 'react'; 12 | import { Dimensions } from 'react-native'; 13 | 14 | const path = Skia.Path.MakeFromSVGString( 15 | // eslint-disable-next-line max-len 16 | 'M512.213 204.005C500.312 185.697 406.758 105.581 332.94 105.581C259.122 105.581 219.088 132 204.638 149.85C157.952 207.52 141.933 264.275 156.579 320.115C175.803 387.854 228.896 449.644 315.859 505.483C415.638 562.238 479.716 626.774 508.093 699.091C518.163 731.13 519.536 762.711 512.213 793.835C504.889 824.959 490.243 853.336 468.273 878.967C449.965 903.683 425.707 921.534 395.499 932.518C365.291 942.588 328.675 950.369 285.651 955.861C182.21 964.1 97.9935 948.538 33 909.176M595.972 733.419C710.397 564.985 795.529 424.47 851.369 311.876C865.1 279.837 875.169 255.579 881.577 239.102C887.985 221.709 894.393 198.824 900.801 170.447C907.208 142.069 909.497 115.98 907.666 92.1797C904.92 68.3793 893.02 51.9021 871.965 40.0019C850.911 28.1016 835.5 31.3101 811.549 44.1212C772.187 65.1754 745.64 101.334 731.909 152.596C723.67 174.566 715.432 200.197 707.193 229.49C699.87 258.783 694.378 281.21 690.716 296.772C687.97 312.334 682.935 340.711 675.612 381.904C668.289 422.182 663.712 445.982 661.881 453.306C643.573 567.731 621.603 733.876 595.972 951.742C624.349 852.878 656.846 774.154 693.462 715.568C706.278 689.937 717.263 669.798 726.417 655.152C735.571 640.505 748.844 624.486 766.237 607.093C784.545 589.701 803.768 576.885 823.907 568.646C892.562 543.015 941.994 545.304 972.202 575.512C990.51 594.735 999.664 618.078 999.664 645.54C1000.58 673.002 990.052 694.514 968.083 710.076C925.059 733.876 859.608 741.657 771.729 733.419C786.375 737.996 797.36 742.115 804.683 745.776C812.922 748.523 822.992 753.1 834.892 759.508C847.707 765.915 857.319 773.696 863.727 782.85C871.05 792.004 875.627 802.531 877.458 814.432C878.373 819.009 879.746 827.705 881.577 840.521C884.323 853.336 886.612 862.948 888.443 869.356C890.273 875.763 892.562 884.002 895.308 894.072C898.97 904.141 903.089 912.837 907.666 920.16C913.159 926.568 919.566 932.976 926.89 939.384C949.775 961.354 987.764 958.607 1040.86 931.145C1056.42 923.822 1070.61 914.668 1083.42 903.683C1097.15 892.698 1109.97 879.425 1121.87 863.863C1134.69 847.386 1144.76 834.113 1152.08 824.043C1159.4 813.058 1169.47 797.039 1182.29 775.985C1195.1 754.931 1204.26 740.742 1209.75 733.419C1239.04 674.833 1268.33 616.247 1297.63 557.661C1252.77 670.256 1223.94 756.304 1211.12 815.805C1205.63 833.197 1203.34 853.336 1204.26 876.221C1205.17 899.106 1212.04 917.414 1224.85 931.145C1234.01 942.13 1245.45 949.453 1259.18 953.115C1273.83 956.777 1287.56 956.319 1300.37 951.742C1356.21 935.265 1401.53 903.226 1436.31 855.625C1456.45 828.163 1483.45 787.427 1517.32 733.419M1360.79 390.143C1347.97 390.143 1340.19 384.193 1337.45 372.293C1335.62 359.477 1336.99 348.492 1341.57 339.338C1345.24 332 1357.13 333.846 1369.03 333.846C1380.93 333.846 1390.5 340.5 1391 348.95M1925.13 697.718C1902.25 633.64 1874.33 593.82 1841.38 578.258C1810.25 559.95 1775.47 551.254 1737.02 552.169C1698.57 552.169 1664.25 562.238 1634.04 582.377C1605.66 598.855 1581.4 620.824 1561.26 648.286C1541.12 674.833 1527.39 704.126 1520.07 736.165C1513.66 767.288 1514.58 798.87 1522.82 830.909C1531.97 862.032 1547.53 888.579 1569.5 910.549C1604.29 939.842 1646.4 954.488 1695.83 954.488C1745.26 954.488 1787.82 939.842 1823.53 910.549C1838.17 895.902 1848.7 885.375 1855.11 878.967C1861.51 872.56 1868.84 863.406 1877.08 851.505C1886.23 839.605 1893.55 827.247 1899.05 814.432M1958.09 556.288C1933.37 657.898 1916.9 746.234 1908.66 821.297C1900.42 878.967 1911.4 918.787 1941.61 940.757C1964.5 959.065 2000.2 956.319 2048.71 932.518C2090.82 912.38 2131.1 873.017 2169.55 814.432' 17 | )!; 18 | 19 | export const SkiaLogo = ({ width, height }) => { 20 | return ( 21 | <Canvas style={{ width, height }}> 22 | <FitBox src={rect(0, 0, 2139, 928)} dst={rect(0, 0, width, height)}> 23 | <LinearGradient 24 | start={path.getPoint(0)} 25 | end={path.getLastPt()} 26 | colors={[ 27 | '#3FCEBC', 28 | '#3CBCEB', 29 | '#5F96E7', 30 | '#816FE3', 31 | '#9F5EE2', 32 | '#DE589F', 33 | '#FF645E', 34 | '#FDA859', 35 | '#FAEC54', 36 | '#9EE671', 37 | '#41E08D', 38 | ]} 39 | /> 40 | <Path 41 | path={path} 42 | style="stroke" 43 | strokeWidth={116} 44 | strokeCap="round" 45 | strokeJoin="round" 46 | /> 47 | </FitBox> 48 | </Canvas> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/Stickers/Stickers.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | Path, 4 | Skia, 5 | SkMatrix, 6 | useTouchHandler, 7 | } from '@shopify/react-native-skia'; 8 | import { 9 | Canvas, 10 | useImage, 11 | Image, 12 | useSharedValueEffect, 13 | useValue, 14 | Group, 15 | } from '@shopify/react-native-skia'; 16 | import { Button, Dimensions, Pressable, View } from 'react-native'; 17 | import { GestureDetector, Gesture } from 'react-native-gesture-handler'; 18 | import Animated, { 19 | useSharedValue, 20 | useAnimatedStyle, 21 | useAnimatedRef, 22 | measure, 23 | runOnJS, 24 | withTiming, 25 | withSpring, 26 | } from 'react-native-reanimated'; 27 | import Icon from '@expo/vector-icons/MaterialIcons'; 28 | import { cloneElement, ReactNode, useLayoutEffect, useState } from 'react'; 29 | import * as ImagePicker from 'expo-image-picker'; 30 | 31 | import { 32 | createIdentityMatrix, 33 | rotateZ, 34 | scale3d, 35 | toSkMatrix, 36 | translate3d, 37 | } from '../components/matrixMath'; 38 | import { ReactLogo } from './ReactLogo'; 39 | import { SkiaLogo } from './SkiaLogo'; 40 | import { AppjsLogo } from './AppjsLogo'; 41 | 42 | const zurich = require('../assets/zurich.jpg'); 43 | const { width, height } = Dimensions.get('window'); 44 | 45 | function Movable({ children }: { children: ReactNode }) { 46 | const ref = useAnimatedRef(); 47 | const matrix = useSharedValue(createIdentityMatrix()); 48 | const styles = useAnimatedStyle(() => { 49 | return { 50 | transform: [{ matrix: matrix.value }], 51 | }; 52 | }); 53 | 54 | const pan = Gesture.Pan().onChange((e) => { 55 | matrix.value = translate3d(matrix.value, e.changeX, e.changeY, 0); 56 | }); 57 | 58 | const rotate = Gesture.Rotation().onChange((e) => { 59 | matrix.value = rotateZ(matrix.value, e.rotationChange, 0, 0, 0); 60 | }); 61 | 62 | const scale = Gesture.Pinch().onChange((e) => { 63 | matrix.value = scale3d( 64 | matrix.value, 65 | e.scaleChange, 66 | e.scaleChange, 67 | 1, 68 | 0, 69 | 0, 70 | 0 71 | ); 72 | }); 73 | 74 | return ( 75 | <GestureDetector gesture={Gesture.Simultaneous(rotate, scale, pan)}> 76 | <Animated.View> 77 | <Animated.View style={[{ position: 'absolute' }, styles]} ref={ref}> 78 | {children} 79 | </Animated.View> 80 | </Animated.View> 81 | </GestureDetector> 82 | ); 83 | } 84 | 85 | function PickerItem({ addItem, children }) { 86 | return ( 87 | <Pressable 88 | onPress={() => 89 | addItem(cloneElement(children, { width: 200, height: 200 })) 90 | }> 91 | <View style={{ marginRight: 10 }}>{children}</View> 92 | </Pressable> 93 | ); 94 | } 95 | 96 | const WIDTH = 50; 97 | const ICONS_COUNT = 3; 98 | const TOSS_TIME_SEC = 0.1; 99 | 100 | function snapPoint(x, vx) { 101 | 'worklet'; 102 | const WIDTH = 50 + 10; 103 | x = x + vx * TOSS_TIME_SEC; 104 | const position = Math.max( 105 | -ICONS_COUNT + 1, 106 | Math.min(0, Math.round(x / WIDTH)) 107 | ); 108 | console.log('POSITION', position); 109 | return position * WIDTH; 110 | } 111 | 112 | function Toolbar({ addItem }) { 113 | const filterOffset = useSharedValue(0); 114 | const pan = Gesture.Pan() 115 | .onChange((e) => { 116 | filterOffset.value += e.changeX; 117 | }) 118 | .onEnd((e) => { 119 | filterOffset.value = withSpring( 120 | snapPoint(filterOffset.value, e.velocityX), 121 | { 122 | velocity: e.velocityX, 123 | } 124 | ); 125 | }); 126 | const styles = useAnimatedStyle(() => { 127 | return { 128 | transform: [{ translateX: filterOffset.value }], 129 | }; 130 | }); 131 | return ( 132 | <View 133 | style={{ 134 | overflow: 'visible', 135 | position: 'absolute', 136 | bottom: 50, 137 | width: 0, 138 | }}> 139 | <GestureDetector gesture={pan}> 140 | <Animated.View 141 | style={[ 142 | { 143 | flexDirection: 'row', 144 | width: WIDTH * ICONS_COUNT, 145 | marginLeft: -WIDTH / 2, 146 | }, 147 | styles, 148 | ]}> 149 | <PickerItem addItem={addItem}> 150 | <ReactLogo width={WIDTH} height={WIDTH} /> 151 | </PickerItem> 152 | <PickerItem addItem={addItem}> 153 | <SkiaLogo width={WIDTH} height={WIDTH} /> 154 | </PickerItem> 155 | <PickerItem addItem={addItem}> 156 | <AppjsLogo width={WIDTH} height={WIDTH} /> 157 | </PickerItem> 158 | </Animated.View> 159 | </GestureDetector> 160 | <Icon 161 | style={{ position: 'absolute', bottom: -20, left: -10 }} 162 | name="expand-less" 163 | size={20} 164 | /> 165 | </View> 166 | ); 167 | } 168 | 169 | export const Stickers = ({ navigation }) => { 170 | const [items, setItems] = useState([] as ReactNode[]); 171 | 172 | const addItem = (item: ReactNode) => { 173 | setItems([...items, item]); 174 | }; 175 | 176 | const path = useValue(Skia.Path.Make()); 177 | const onTouch = useTouchHandler({ 178 | onStart: ({ x, y }) => { 179 | path.current.moveTo(x, y); 180 | }, 181 | onActive: ({ x, y }) => { 182 | const lastPt = path.current.getLastPt(); 183 | const xMid = (lastPt.x + x) / 2; 184 | const yMid = (lastPt.y + y) / 2; 185 | path.current.quadTo(lastPt.x, lastPt.y, xMid, yMid); 186 | }, 187 | }); 188 | 189 | const [imageUri, setImage] = useState(null); 190 | 191 | const pickImage = async () => { 192 | // No permissions request is necessary for launching the image library 193 | let result = await ImagePicker.launchImageLibraryAsync({ 194 | mediaTypes: ImagePicker.MediaTypeOptions.All, 195 | quality: 1, 196 | }); 197 | 198 | console.log(result); 199 | 200 | if (!result.cancelled) { 201 | setImage(result.uri); 202 | } 203 | }; 204 | useLayoutEffect(() => { 205 | navigation.setOptions({ 206 | headerRight: () => <Button title="Photo" onPress={pickImage} />, 207 | }); 208 | }, [navigation]); 209 | 210 | const image = useImage(imageUri || zurich); 211 | if (!image) { 212 | return null; 213 | } 214 | return ( 215 | <View style={{ flex: 1 }}> 216 | <Canvas style={{ flex: 1 }} onTouch={onTouch}> 217 | <Image 218 | x={0} 219 | y={0} 220 | width={width} 221 | height={height} 222 | image={image} 223 | fit="cover" 224 | /> 225 | <Path 226 | path={path} 227 | style="stroke" 228 | strokeWidth={8} 229 | color="lightblue" 230 | strokeJoin="round" 231 | strokeCap="round" 232 | /> 233 | </Canvas> 234 | <View 235 | style={{ width: '100%', height: '100%', position: 'absolute' }} 236 | pointerEvents="box-none"> 237 | {items.map((item, index) => ( 238 | <Movable key={index}>{item}</Movable> 239 | ))} 240 | </View> 241 | <View 242 | style={{ 243 | position: 'absolute', 244 | bottom: 0, 245 | width: '100%', 246 | height: 120, 247 | alignItems: 'center', 248 | backgroundColor: '#ffffff55', 249 | }}> 250 | <Toolbar addItem={addItem} /> 251 | </View> 252 | </View> 253 | ); 254 | }; 255 | -------------------------------------------------------------------------------- /src/Stickers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Stickers"; 2 | -------------------------------------------------------------------------------- /src/assets/zurich.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/assets/zurich.jpg -------------------------------------------------------------------------------- /src/assets/zurich2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/assets/zurich2.jpg -------------------------------------------------------------------------------- /src/assets/zurich3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/software-mansion-labs/drawings-and-animations-workshop/41edffd157381e7717ac72912e1fd22ee3959797/src/assets/zurich3.jpg -------------------------------------------------------------------------------- /src/components/CenterScreen.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { SafeAreaView } from "react-native"; 3 | 4 | export function CenterScreen({ children }: { children: ReactNode }) { 5 | return ( 6 | <SafeAreaView 7 | style={{ 8 | justifyContent: "center", 9 | alignItems: "center", 10 | flex: 1, 11 | backgroundColor: "white", 12 | }} 13 | > 14 | {children} 15 | </SafeAreaView> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/LoadAssets.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from "react"; 2 | import React, { useCallback, useEffect, useState } from "react"; 3 | import { Asset } from "expo-asset"; 4 | import type { InitialState } from "@react-navigation/native"; 5 | import { NavigationContainer } from "@react-navigation/native"; 6 | import { StatusBar } from "expo-status-bar"; 7 | import * as Font from "expo-font"; 8 | import AsyncStorage from "@react-native-async-storage/async-storage"; 9 | import { View } from "react-native"; 10 | 11 | const NAVIGATION_STATE_KEY = "NAVIGATION_STATE_KEY"; 12 | 13 | export type FontSource = Parameters<typeof Font.loadAsync>[0]; 14 | const usePromiseAll = ( 15 | promises: Promise<void | void[] | Asset[]>[], 16 | cb: () => void 17 | ) => 18 | useEffect(() => { 19 | (async () => { 20 | await Promise.all(promises); 21 | cb(); 22 | })(); 23 | }); 24 | 25 | const useLoadAssets = (assets: number[], fonts: FontSource): boolean => { 26 | const [ready, setReady] = useState(false); 27 | usePromiseAll( 28 | [Font.loadAsync(fonts), ...assets.map((asset) => Asset.loadAsync(asset))], 29 | () => setReady(true) 30 | ); 31 | return ready; 32 | }; 33 | 34 | interface LoadAssetsProps { 35 | fonts?: FontSource; 36 | assets?: number[]; 37 | children: ReactElement | ReactElement[]; 38 | } 39 | 40 | export const LoadAssets = ({ children }: LoadAssetsProps) => { 41 | const [isNavigationReady, setIsNavigationReady] = useState(!__DEV__); 42 | const [initialState, setInitialState] = useState<InitialState | undefined>(); 43 | // const ready = useLoadAssets(assets || [], fonts || {}); 44 | useEffect(() => { 45 | const restoreState = async () => { 46 | try { 47 | const savedStateString = await AsyncStorage.getItem( 48 | NAVIGATION_STATE_KEY 49 | ); 50 | const state = savedStateString 51 | ? JSON.parse(savedStateString) 52 | : undefined; 53 | setInitialState(state); 54 | } finally { 55 | setIsNavigationReady(true); 56 | } 57 | }; 58 | if (!isNavigationReady) { 59 | restoreState(); 60 | } 61 | }, [isNavigationReady]); 62 | const onStateChange = useCallback( 63 | (state) => 64 | AsyncStorage.setItem(NAVIGATION_STATE_KEY, JSON.stringify(state)), 65 | [] 66 | ); 67 | 68 | // if (!ready) { 69 | // return <View style={{ flex: 1, backgroundColor: "black" }} />; 70 | // } 71 | 72 | return ( 73 | <NavigationContainer {...{ onStateChange, initialState }}> 74 | <StatusBar style="light" /> 75 | {children} 76 | </NavigationContainer> 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/Math.ts: -------------------------------------------------------------------------------- 1 | import type { Vector } from "@shopify/react-native-skia"; 2 | import { vec } from "@shopify/react-native-skia"; 3 | 4 | export const getPointAtLength = (length: number, from: Vector, to: Vector) => { 5 | const angle = Math.atan2(to.y - from.y, to.x - from.x); 6 | const x = from.x + length * Math.cos(angle); 7 | const y = from.y + length * Math.sin(angle); 8 | return vec(x, y); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/matrixMath.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | import type { SkMatrix } from "@shopify/react-native-skia"; 3 | 4 | export type Affine3DMatrix = [ 5 | number, 6 | number, 7 | number, 8 | number, 9 | number, 10 | number, 11 | number, 12 | number, 13 | number, 14 | number, 15 | number, 16 | number, 17 | number, 18 | number, 19 | number, 20 | number 21 | ]; 22 | 23 | export function createIdentityMatrix(): Affine3DMatrix { 24 | "worklet"; 25 | return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; 26 | } 27 | 28 | export function multiplyInto( 29 | out: Affine3DMatrix, 30 | a: Affine3DMatrix, 31 | b: Affine3DMatrix 32 | ) { 33 | "worklet"; 34 | const a00 = a[0], 35 | a01 = a[1], 36 | a02 = a[2], 37 | a03 = a[3], 38 | a10 = a[4], 39 | a11 = a[5], 40 | a12 = a[6], 41 | a13 = a[7], 42 | a20 = a[8], 43 | a21 = a[9], 44 | a22 = a[10], 45 | a23 = a[11], 46 | a30 = a[12], 47 | a31 = a[13], 48 | a32 = a[14], 49 | a33 = a[15]; 50 | 51 | let b0 = b[0], 52 | b1 = b[1], 53 | b2 = b[2], 54 | b3 = b[3]; 55 | out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; 56 | out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; 57 | out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; 58 | out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; 59 | 60 | b0 = b[4]; 61 | b1 = b[5]; 62 | b2 = b[6]; 63 | b3 = b[7]; 64 | out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; 65 | out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; 66 | out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; 67 | out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; 68 | 69 | b0 = b[8]; 70 | b1 = b[9]; 71 | b2 = b[10]; 72 | b3 = b[11]; 73 | out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; 74 | out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; 75 | out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; 76 | out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; 77 | 78 | b0 = b[12]; 79 | b1 = b[13]; 80 | b2 = b[14]; 81 | b3 = b[15]; 82 | out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; 83 | out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; 84 | out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; 85 | out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; 86 | } 87 | 88 | export function translate3d( 89 | matrix: Affine3DMatrix, 90 | x: number, 91 | y: number, 92 | z: number 93 | ): Affine3DMatrix { 94 | "worklet"; 95 | const change = createIdentityMatrix(); 96 | change[12] = x; 97 | change[13] = y; 98 | change[14] = z; 99 | multiplyInto(change, change, matrix); 100 | return change; 101 | } 102 | 103 | export function rotateZ( 104 | matrix: Affine3DMatrix, 105 | radians: number, 106 | x: number, 107 | y: number, 108 | z: number 109 | ): Affine3DMatrix { 110 | "worklet"; 111 | const change = createIdentityMatrix(); 112 | change[0] = Math.cos(radians); 113 | change[1] = Math.sin(radians); 114 | change[4] = -Math.sin(radians); 115 | change[5] = Math.cos(radians); 116 | 117 | let combined = createIdentityMatrix(); 118 | combined = translate3d(combined, -x, -y, -z); 119 | multiplyInto(combined, change, combined); 120 | combined = translate3d(combined, x, y, z); 121 | multiplyInto(combined, matrix, combined); 122 | return combined; 123 | } 124 | 125 | export function scale3d( 126 | matrix: Affine3DMatrix, 127 | xScale: number, 128 | yScale: number, 129 | zScale: number, 130 | x: number, 131 | y: number, 132 | z: number 133 | ): Affine3DMatrix { 134 | "worklet"; 135 | const change = createIdentityMatrix(); 136 | change[0] = xScale; 137 | change[5] = yScale; 138 | change[10] = zScale; 139 | 140 | let combined = createIdentityMatrix(); 141 | combined = translate3d(combined, -x, -y, -z); 142 | multiplyInto(combined, change, combined); 143 | combined = translate3d(combined, x, y, z); 144 | multiplyInto(combined, matrix, combined); 145 | return combined; 146 | } 147 | 148 | export function toSkMatrix(m: Affine3DMatrix): SkMatrix { 149 | return [m[0], m[4], m[12], m[1], m[5], m[13], m[3], m[7], m[15]] as any; 150 | } 151 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type Vec2 = [number, number]; 2 | declare module "adaptive-bezier-curve" { 3 | // eslint-disable-next-line import/no-default-export 4 | export default function ( 5 | start: Vec2, 6 | c1: Vec2, 7 | c2: Vec2, 8 | end: Vec2, 9 | scale?: number 10 | ): Vec2[]; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-react-native-wcandillon/tsconfig.base", 3 | "compilerOptions": { 4 | "noUncheckedIndexedAccess": false, 5 | "noUnusedLocals": false /* Report errors on unused locals. */, 6 | "noUnusedParameters": false /* Report errors on unused parameters. */, 7 | "noImplicitReturns": false /* Report error when not all code paths in function return a value. */, 8 | "noFallthroughCasesInSwitch": false /* Report errors for fallthrough cases in switch statement. */, 9 | "allowUnreachableCode": true, 10 | "allowUnusedLabels": true, 11 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 12 | } 13 | } 14 | --------------------------------------------------------------------------------