├── .gitignore
├── App.tsx
├── LICENSE
├── README.md
├── app.json
├── assets
├── adaptive-icon.png
├── bubble.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── package.json
├── src
├── assets
│ ├── background.png
│ ├── bubble.png
│ ├── tree1.png
│ ├── tree3.png
│ └── tree4.png
├── components
│ ├── BubblesContainer.tsx
│ ├── GradientButton.tsx
│ ├── Logo.tsx
│ └── index.ts
├── config.ts
├── drawing
│ ├── Bubble.tsx
│ ├── ParallaxImage.tsx
│ ├── Wall.tsx
│ └── index.ts
├── hooks
│ ├── index.ts
│ └── useGravity.ts
├── navigation.ts
├── screens
│ ├── PhysicsScreen.tsx
│ ├── SensorsScreen.tsx
│ └── index.ts
└── utils.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | # Temporary files created by Metro to check the health of the file watcher
17 | .metro-health-check*
18 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationContainer } from "@react-navigation/native";
2 | import { RootStack } from "./src/navigation";
3 | import { PhysicsScreen, SensorsScreen } from "./src/screens";
4 |
5 | export default function App() {
6 | return (
7 |
8 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Aymeric Chauvin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-native-gyroscopic-physics
2 |
3 | This repository showcases the possibilities of mixing a physics engine, Skia, sensors, and UI elements, to create a fully interactive experience in a React Native application.
4 |
5 | https://github.com/halftheopposite/react-native-gyroscopic-physics/assets/5473864/34b64175-655c-4084-9ab3-81acfbd7c286
6 |
7 | ## Architecture
8 |
9 | The architecture of the app is layered as a classic React Native application.
10 |
11 | TODO
12 |
13 | ## Libraries
14 |
15 | I've tried to use as few libraries as possible and avoid Reanimated for compatibility purposes:
16 |
17 | - Skia: https://github.com/Shopify/react-native-skia
18 | - Matter.js: https://github.com/liabru/matter-js
19 | - Expo-Sensors: https://github.com/expo/expo/tree/sdk-48/packages/expo-sensors
20 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "react-native-gyroscopic-physics",
4 | "slug": "react-native-gyroscopic-physics",
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 | "assetBundlePatterns": ["**/*"],
15 | "ios": {
16 | "supportsTablet": true
17 | },
18 | "android": {
19 | "adaptiveIcon": {
20 | "foregroundImage": "./assets/adaptive-icon.png",
21 | "backgroundColor": "#ffffff"
22 | }
23 | },
24 | "web": {
25 | "favicon": "./assets/favicon.png"
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/bubble.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/bubble.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-gyroscopic-physics",
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 | },
11 | "dependencies": {
12 | "@react-navigation/native": "^6.1.7",
13 | "@react-navigation/native-stack": "^6.9.13",
14 | "@shopify/react-native-skia": "0.1.172",
15 | "expo": "~48.0.18",
16 | "expo-sensors": "^12.1.1",
17 | "expo-status-bar": "~1.4.4",
18 | "matter-js": "^0.19.0",
19 | "react": "18.2.0",
20 | "react-native": "0.71.8",
21 | "react-native-safe-area-context": "4.5.0",
22 | "react-native-screens": "~3.20.0"
23 | },
24 | "devDependencies": {
25 | "@babel/core": "^7.20.0",
26 | "@types/matter-js": "^0.18.5",
27 | "@types/react": "~18.0.27",
28 | "typescript": "^4.9.4"
29 | },
30 | "private": true
31 | }
32 |
--------------------------------------------------------------------------------
/src/assets/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/background.png
--------------------------------------------------------------------------------
/src/assets/bubble.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/bubble.png
--------------------------------------------------------------------------------
/src/assets/tree1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree1.png
--------------------------------------------------------------------------------
/src/assets/tree3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree3.png
--------------------------------------------------------------------------------
/src/assets/tree4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree4.png
--------------------------------------------------------------------------------
/src/components/BubblesContainer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Canvas,
3 | Fill,
4 | SweepGradient,
5 | useImage,
6 | useTouchHandler,
7 | vec,
8 | } from "@shopify/react-native-skia";
9 | import { Bodies, Body, Composite, Engine, Query } from "matter-js";
10 | import React, { useEffect, useRef, useState } from "react";
11 | import { StyleSheet, View } from "react-native";
12 | import {
13 | BUBBLES_COUNT,
14 | BUBBLE_RADIUS,
15 | BUBBLE_SPAWN_X,
16 | BUBBLE_SPAWN_Y,
17 | PHYSICS_TIMESTEP,
18 | SCREEN_HEIGHT,
19 | SCREEN_WIDTH,
20 | TOP_WALL_OFFSET,
21 | UI_TIMESTEP,
22 | WALL_WIDTH,
23 | } from "../config";
24 | import { Bubble, Wall } from "../drawing";
25 | import { useGravity } from "../hooks";
26 | import { roundDigitsLength } from "../utils";
27 |
28 | const BUBBLE_IMAGE_PATH = require("../assets/bubble.png");
29 |
30 | export type Placeholder = {
31 | id: number;
32 | x: number;
33 | y: number;
34 | width: number;
35 | height: number;
36 | };
37 |
38 | export function BubblesContainer(props: {
39 | placeholders?: Placeholder[];
40 | children: React.ReactNode;
41 | }) {
42 | const { placeholders, children } = props;
43 | const image = useImage(BUBBLE_IMAGE_PATH);
44 |
45 | const [wallBodies, setWallBodies] = useState
([]);
46 | const [bubbleBodies, setBubbleBodies] = useState([]);
47 | const [placeholderBodies, setPlaceholdersBodies] = useState([]);
48 |
49 | const [, setTick] = useState([]);
50 |
51 | //
52 | // Initialize engine and update loop
53 | //
54 | const engine = useRef(
55 | Engine.create({
56 | positionIterations: 6,
57 | gravity: {
58 | scale: 0.002,
59 | },
60 | })
61 | ).current;
62 |
63 | useEffect(() => {
64 | // Create walls
65 | const wallBodies = createWalls();
66 | setWallBodies(wallBodies);
67 |
68 | // Create bubbles
69 | const bubbleBodies = createBubbles();
70 | setBubbleBodies(bubbleBodies);
71 |
72 | // Add all bodies into the world
73 | Composite.add(engine.world, [...wallBodies, ...bubbleBodies]);
74 |
75 | // Launch physics update loop
76 | const update = () => {
77 | Engine.update(engine, PHYSICS_TIMESTEP);
78 | requestAnimationFrame(update);
79 | };
80 |
81 | update();
82 |
83 | // Launch UI refresh loop
84 | setInterval(() => {
85 | setTick([]);
86 | }, UI_TIMESTEP);
87 | }, []);
88 |
89 | //
90 | // Listen to placeholder changes
91 | //
92 | useEffect(() => {
93 | if (!placeholders || placeholders.length === 0) {
94 | return;
95 | }
96 |
97 | const bodies: Body[] = [];
98 |
99 | placeholders.forEach((placeholder) => {
100 | const existingBody = Composite.allBodies(engine.world).find(
101 | (body) => body.id === placeholder.id
102 | );
103 |
104 | // If body already exist we do not add it to the list
105 | if (!!existingBody) {
106 | return;
107 | }
108 |
109 | bodies.push(
110 | createWall(
111 | placeholder.x,
112 | placeholder.y,
113 | placeholder.width,
114 | placeholder.height,
115 | placeholder.id
116 | )
117 | );
118 | });
119 |
120 | setPlaceholdersBodies(bodies);
121 | Composite.add(engine.world, bodies);
122 | }, [placeholders]);
123 |
124 | //
125 | // Listen to mobile orientation changes and update
126 | // the artifical gravity of the simulation
127 | //
128 | useGravity((gravity) => {
129 | engine.gravity.x = gravity.x;
130 | engine.gravity.y = gravity.y;
131 | });
132 |
133 | //
134 | // Handle touch events to move bubbles arounds
135 | //
136 | const [draggingBody, setDraggingBody] = useState(null);
137 |
138 | const touchHandler = useTouchHandler(
139 | {
140 | onStart: ({ x, y }) => {
141 | if (draggingBody) {
142 | return;
143 | }
144 |
145 | const bodies = Query.point(Composite.allBodies(engine.world), {
146 | x,
147 | y,
148 | });
149 |
150 | if (!bodies || bodies.length === 0) {
151 | return;
152 | }
153 |
154 | const firstBody = bodies[0];
155 | if (firstBody.isStatic) {
156 | return;
157 | }
158 |
159 | Body.setStatic(firstBody, true);
160 |
161 | setDraggingBody(firstBody);
162 | },
163 | onActive: (info) => {
164 | if (!draggingBody) {
165 | return;
166 | }
167 |
168 | Body.setPosition(draggingBody, {
169 | x: info.x,
170 | y: info.y,
171 | });
172 | },
173 | onEnd: (info) => {
174 | if (!draggingBody) {
175 | return;
176 | }
177 |
178 | Body.setStatic(draggingBody, false);
179 |
180 | // Apply the current force to the bubble to keep momentum
181 | Body.applyForce(
182 | draggingBody,
183 | { x: info.x, y: info.y },
184 | { x: info.velocityX / 10000, y: info.velocityY / 10000 }
185 | );
186 |
187 | setDraggingBody(null);
188 | },
189 | },
190 | [draggingBody]
191 | );
192 |
193 | if (!image) {
194 | return null;
195 | }
196 |
197 | return (
198 |
199 | {/* Canvas */}
200 |
244 |
245 | {/* UI overlay */}
246 |
247 | {children}
248 |
249 |
250 | );
251 | }
252 |
253 | const styles = StyleSheet.create({
254 | container: {
255 | flex: 1,
256 | },
257 | canvas: {
258 | flex: 1,
259 | },
260 | contentContainer: {
261 | ...StyleSheet.absoluteFillObject,
262 | },
263 | });
264 |
265 | //
266 | // Utils
267 | //
268 | function createWalls(): Body[] {
269 | const totalWidth = SCREEN_WIDTH;
270 | const totalHeight = SCREEN_HEIGHT + TOP_WALL_OFFSET;
271 |
272 | const topWall = createWall(0, -TOP_WALL_OFFSET, totalWidth, WALL_WIDTH);
273 | const leftWall = createWall(
274 | -WALL_WIDTH,
275 | -TOP_WALL_OFFSET,
276 | WALL_WIDTH,
277 | totalHeight
278 | );
279 | const rightWall = createWall(
280 | totalWidth,
281 | -TOP_WALL_OFFSET,
282 | WALL_WIDTH,
283 | totalHeight
284 | );
285 | const bottomWall = createWall(0, SCREEN_HEIGHT, totalWidth, WALL_WIDTH);
286 |
287 | return [topWall, leftWall, rightWall, bottomWall];
288 | }
289 |
290 | function createWall(
291 | x: number,
292 | y: number,
293 | width: number,
294 | height: number,
295 | id?: number
296 | ): Body {
297 | return Bodies.rectangle(x + width / 2, y + height / 2, width, height, {
298 | isStatic: true,
299 | ...(!!id ? { id } : {}),
300 | });
301 | }
302 |
303 | function createBubbles(): Body[] {
304 | let bodies: Body[] = [];
305 |
306 | for (let i = 0; i < BUBBLES_COUNT; i++) {
307 | bodies.push(
308 | Bodies.circle(BUBBLE_SPAWN_X, BUBBLE_SPAWN_Y, BUBBLE_RADIUS, {
309 | isStatic: false,
310 | restitution: 0.6,
311 | })
312 | );
313 | }
314 |
315 | return bodies;
316 | }
317 |
--------------------------------------------------------------------------------
/src/components/GradientButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Canvas,
3 | LinearGradient,
4 | RoundedRect,
5 | Shadow,
6 | vec,
7 | } from "@shopify/react-native-skia";
8 | import { useRef, useState } from "react";
9 | import {
10 | Animated,
11 | LayoutRectangle,
12 | Pressable,
13 | StyleProp,
14 | StyleSheet,
15 | Text,
16 | View,
17 | ViewStyle,
18 | } from "react-native";
19 |
20 | const AnimatedTouchable = Animated.createAnimatedComponent(Pressable);
21 |
22 | export function GradientButton(props: {
23 | title: string;
24 | style?: StyleProp;
25 | onLayout?: (layout: LayoutRectangle) => void;
26 | onPress: () => void;
27 | }) {
28 | const { title, style, onLayout, onPress } = props;
29 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
30 |
31 | const scaleValue = useRef(new Animated.Value(1)).current;
32 |
33 | const pressInAnimation = () => {
34 | Animated.spring(scaleValue, {
35 | toValue: 0.93,
36 | useNativeDriver: true,
37 | }).start();
38 | };
39 |
40 | const pressOutAnimation = () => {
41 | Animated.spring(scaleValue, {
42 | toValue: 1,
43 | useNativeDriver: true,
44 | }).start();
45 | };
46 |
47 | return (
48 | {
61 | if (onLayout) {
62 | onLayout(event.nativeEvent.layout);
63 | }
64 |
65 | setDimensions({
66 | width: event.nativeEvent.layout.width,
67 | height: event.nativeEvent.layout.height,
68 | });
69 | }}
70 | onPressIn={pressInAnimation}
71 | onPressOut={pressOutAnimation}
72 | onPress={onPress}
73 | >
74 | <>
75 | {/* Background */}
76 |
99 |
100 | {/* Content */}
101 |
102 | {title}
103 |
104 | >
105 |
106 | );
107 | }
108 |
109 | const styles = StyleSheet.create({
110 | container: {
111 | flex: 1,
112 | },
113 | canvas: {
114 | flex: 1,
115 | },
116 | contentContainer: {
117 | ...StyleSheet.absoluteFillObject,
118 | alignItems: "center",
119 | justifyContent: "center",
120 | },
121 | text: {
122 | fontSize: 18,
123 | fontWeight: "bold",
124 | color: "white",
125 | },
126 | });
127 |
--------------------------------------------------------------------------------
/src/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | StyleProp,
3 | Text,
4 | ViewStyle,
5 | StyleSheet,
6 | LayoutRectangle,
7 | } from "react-native";
8 |
9 | export function Logo(props: {
10 | style?: StyleProp;
11 | onLayout: (layout: LayoutRectangle) => void;
12 | }) {
13 | const { style, onLayout } = props;
14 |
15 | return (
16 | onLayout(event.nativeEvent.layout)}
19 | >
20 | Physics
21 |
22 | );
23 | }
24 |
25 | const styles = StyleSheet.create({
26 | text: {
27 | fontSize: 40,
28 | color: "white",
29 | fontWeight: "bold",
30 | lineHeight: 40,
31 | textAlign: "center",
32 | textTransform: "uppercase",
33 | textShadowOffset: { width: 0, height: 4 },
34 | textShadowRadius: 4,
35 | textShadowColor: "rgba(0,0,0,0.3)",
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./BubblesContainer";
2 | export * from "./GradientButton";
3 | export * from "./Logo";
4 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { Dimensions } from "react-native";
2 |
3 | export const SCREEN_WIDTH = Dimensions.get("window").width;
4 | export const SCREEN_HEIGHT = Dimensions.get("window").height;
5 |
6 | export const UI_TIMESTEP = 1000 / 45;
7 | export const PHYSICS_TIMESTEP = 1000 / 60;
8 |
9 | export const TOP_WALL_OFFSET = 300;
10 | export const WALL_WIDTH = 100;
11 |
12 | export const BUBBLES_COUNT = 35;
13 | export const BUBBLE_RADIUS = 30;
14 | export const BUBBLE_SPAWN_X = SCREEN_WIDTH / 2 - BUBBLE_RADIUS;
15 | export const BUBBLE_SPAWN_Y = -150;
16 |
--------------------------------------------------------------------------------
/src/drawing/Bubble.tsx:
--------------------------------------------------------------------------------
1 | import { Image, SkImage } from "@shopify/react-native-skia";
2 | import { memo } from "react";
3 |
4 | /** The padding used to create some overlap between bubbles. */
5 | const BUBBLE_PADDING = 5;
6 |
7 | export const Bubble = memo(
8 | (props: {
9 | x: number;
10 | y: number;
11 | radius: number;
12 | angle: number;
13 | image: SkImage;
14 | }) => {
15 | const { x, y, radius, angle, image } = props;
16 |
17 | const size = radius * 2 + BUBBLE_PADDING;
18 |
19 | return (
20 |
29 | );
30 | }
31 | );
32 |
--------------------------------------------------------------------------------
/src/drawing/ParallaxImage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DataSourceParam,
3 | Fit,
4 | Image,
5 | SkiaMutableValue,
6 | interpolate,
7 | useImage,
8 | useValue,
9 | useValueEffect,
10 | } from "@shopify/react-native-skia";
11 |
12 | export function ParallaxImage(props: {
13 | x: number;
14 | y: number;
15 | width: number;
16 | height: number;
17 | ratioX: SkiaMutableValue;
18 | ratioY: SkiaMutableValue;
19 | amplitude: [number, number];
20 | source: DataSourceParam;
21 | fit?: Fit;
22 | }) {
23 | const { x, y, width, height, ratioX, ratioY, amplitude, source, fit } = props;
24 |
25 | const localX = useValue(x);
26 | const localY = useValue(y);
27 |
28 | useValueEffect(ratioX, () => {
29 | localX.current = interpolate(
30 | ratioX.current,
31 | [0, 1],
32 | [x + amplitude[0], x + amplitude[1]]
33 | );
34 | });
35 |
36 | useValueEffect(ratioY, () => {
37 | localY.current = interpolate(
38 | ratioY.current,
39 | [0, 1],
40 | [y + amplitude[0], y + amplitude[1]]
41 | );
42 | });
43 |
44 | const image = useImage(source);
45 | if (!image) {
46 | return null;
47 | }
48 |
49 | return (
50 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/drawing/Wall.tsx:
--------------------------------------------------------------------------------
1 | import { Rect } from "@shopify/react-native-skia";
2 | import { memo } from "react";
3 |
4 | export const Wall = memo(
5 | (props: {
6 | x: number;
7 | y: number;
8 | width: number;
9 | height: number;
10 | color?: string;
11 | }) => {
12 | const { x, y, width, height, color = "transparent" } = props;
13 |
14 | return ;
15 | }
16 | );
17 |
--------------------------------------------------------------------------------
/src/drawing/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Bubble";
2 | export * from "./ParallaxImage";
3 | export * from "./Wall";
4 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useGravity";
2 |
--------------------------------------------------------------------------------
/src/hooks/useGravity.ts:
--------------------------------------------------------------------------------
1 | import { Vector, clamp } from "@shopify/react-native-skia";
2 | import { DeviceMotion } from "expo-sensors";
3 | import { useEffect, useState } from "react";
4 | import { roundDigitsLength } from "../utils";
5 |
6 | /**
7 | * A hook to listen to the device's orientation changes and convert them to a vectorized gravity.
8 | */
9 | export function useGravity(
10 | onUpdate: (gravity: { x: number; y: number }) => void
11 | ) {
12 | console.log("useGravity");
13 | const update = () => {
14 | DeviceMotion.setUpdateInterval(200);
15 | };
16 |
17 | const subscribe = () => {
18 | DeviceMotion.addListener((devicemotionData) => {
19 | onUpdate({
20 | x: devicemotionData.rotation.gamma,
21 | y: devicemotionData.rotation.beta,
22 | });
23 | });
24 |
25 | update();
26 | };
27 |
28 | const unsubscribe = () => {
29 | DeviceMotion.removeAllListeners();
30 | };
31 |
32 | useEffect(() => {
33 | subscribe();
34 |
35 | return () => {
36 | unsubscribe();
37 | };
38 | }, []);
39 | }
40 |
41 | const GRAVITY_DISTANCE = 3;
42 |
43 | /** A hook returning gravity as a normalized vector. */
44 | export function useNormalizedGravity(): Vector {
45 | const [value, setValue] = useState({ x: 0, y: 0 });
46 |
47 | useGravity((updated) => {
48 | // Make the value fit between 0 and 3
49 | const clampedX = clamp(
50 | updated.x + GRAVITY_DISTANCE / 2,
51 | 0,
52 | GRAVITY_DISTANCE
53 | );
54 |
55 | const clampedY = clamp(
56 | updated.y + GRAVITY_DISTANCE / 2,
57 | 0,
58 | GRAVITY_DISTANCE
59 | );
60 |
61 | // Normalize the value between 0 and 1
62 | const normalizedX = roundDigitsLength(clampedX / GRAVITY_DISTANCE, 2);
63 | const normalizedY = roundDigitsLength(clampedY / GRAVITY_DISTANCE, 2);
64 |
65 | if (value.x !== normalizedX || value.y !== normalizedY) {
66 | setValue({ x: normalizedX, y: normalizedY });
67 | }
68 | });
69 |
70 | return value;
71 | }
72 |
--------------------------------------------------------------------------------
/src/navigation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | NativeStackScreenProps,
3 | createNativeStackNavigator,
4 | } from "@react-navigation/native-stack";
5 |
6 | type RootStackParamList = {
7 | Physics: undefined;
8 | Sensors: undefined;
9 | };
10 |
11 | export type PhysicsProps = NativeStackScreenProps<
12 | RootStackParamList,
13 | "Physics"
14 | >;
15 | export type SensorsProps = NativeStackScreenProps<
16 | RootStackParamList,
17 | "Sensors"
18 | >;
19 |
20 | export const RootStack = createNativeStackNavigator();
21 |
--------------------------------------------------------------------------------
/src/screens/PhysicsScreen.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { useState } from "react";
3 | import { LayoutRectangle, StyleSheet, View } from "react-native";
4 | import {
5 | BubblesContainer,
6 | GradientButton,
7 | Logo,
8 | Placeholder,
9 | } from "../components";
10 | import { SCREEN_HEIGHT } from "../config";
11 | import { PhysicsProps } from "../navigation";
12 |
13 | export function PhysicsScreen(props: PhysicsProps) {
14 | const { navigation } = props;
15 |
16 | // States
17 | const [placeholders, setPlaceholders] = useState([]);
18 |
19 | // Hanlders
20 | const handleOnLayout = (id: number, layout: LayoutRectangle) => {
21 | setPlaceholders((previous) => {
22 | const updated = [...previous];
23 |
24 | const index = updated.findIndex((placeholder) => placeholder.id === id);
25 | if (index === -1) {
26 | updated.push({
27 | id,
28 | ...layout,
29 | });
30 | } else {
31 | updated[index] = {
32 | ...placeholders[index],
33 | ...layout,
34 | };
35 | }
36 |
37 | return updated;
38 | });
39 | };
40 |
41 | return (
42 |
43 |
44 |
45 |
46 | handleOnLayout(123, layout)}
49 | />
50 | handleOnLayout(456, layout)}
54 | onPress={() => navigation.navigate("Sensors")}
55 | />
56 |
57 |
58 | );
59 | }
60 |
61 | const styles = StyleSheet.create({
62 | container: {
63 | flex: 1,
64 | },
65 | logo: {
66 | position: "absolute",
67 | height: 50,
68 | top: 120,
69 | left: 100,
70 | right: 100,
71 | },
72 | button: {
73 | position: "absolute",
74 | height: 50,
75 | top: SCREEN_HEIGHT - 150,
76 | left: 100,
77 | right: 100,
78 | },
79 | });
80 |
--------------------------------------------------------------------------------
/src/screens/SensorsScreen.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Canvas,
3 | useImage,
4 | useTiming,
5 | useTouchHandler,
6 | useValue,
7 | useValueEffect,
8 | } from "@shopify/react-native-skia";
9 | import { StyleSheet, View } from "react-native";
10 | import { GradientButton } from "../components";
11 | import { SCREEN_HEIGHT, SCREEN_WIDTH } from "../config";
12 | import { useNormalizedGravity } from "../hooks";
13 | import { SensorsProps } from "../navigation";
14 | import { ParallaxImage } from "../drawing";
15 | import { roundDigitsLength } from "../utils";
16 | import { useEffect } from "react";
17 |
18 | const BACKGROUND_PATH = require("../assets/background.png");
19 | const BACKGROUND_PADDING = 100;
20 |
21 | const TREE_1_PATH = require("../assets/tree1.png");
22 | const TREE_2_PATH = require("../assets/tree2.png");
23 | const TREE_3_PATH = require("../assets/tree3.png");
24 |
25 | export function SensorsScreen(props: SensorsProps) {
26 | const { navigation } = props;
27 | const ratioX = useValue(0.5);
28 | const ratioY = useValue(0.5);
29 |
30 | const gravity = { x: 0.5, y: 0.5 };
31 | // const gravity = useNormalizedGravity();
32 |
33 | const touchHandler = useTouchHandler({
34 | onActive: ({ x, y }) => {
35 | // ratioX.current = roundDigitsLength(x / SCREEN_WIDTH, 2);
36 | // ratioY.current = roundDigitsLength(y / SCREEN_HEIGHT, 2);
37 | // console.log(`x: ${ratioX.current} y: ${ratioY.current}`);
38 | },
39 | });
40 |
41 | console.log(gravity);
42 |
43 | useEffect(() => {
44 | ratioX.current = gravity.x;
45 | ratioY.current = gravity.y;
46 | }, [gravity.x, gravity.y]);
47 |
48 | return (
49 |
50 |
107 |
108 | navigation.navigate("Physics")}
112 | />
113 |
114 | );
115 | }
116 |
117 | const styles = StyleSheet.create({
118 | container: {
119 | flex: 1,
120 | alignItems: "center",
121 | },
122 | canvas: {
123 | ...StyleSheet.absoluteFillObject,
124 | },
125 | button: {
126 | position: "absolute",
127 | height: 50,
128 | top: SCREEN_HEIGHT - 150,
129 | left: 100,
130 | right: 100,
131 | },
132 | });
133 |
--------------------------------------------------------------------------------
/src/screens/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./PhysicsScreen";
2 | export * from "./SensorsScreen";
3 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export function roundDigitsLength(value: number, length: number): number {
2 | return parseFloat(value.toFixed(length));
3 | }
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {},
3 | "extends": "expo/tsconfig.base"
4 | }
5 |
--------------------------------------------------------------------------------