├── .expo-shared
└── assets.json
├── .gitignore
├── App.js
├── HingeInput.js
├── README.md
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── package.json
└── yarn.lock
/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | npm-debug.*
4 | *.jks
5 | *.p8
6 | *.p12
7 | *.key
8 | *.mobileprovision
9 | *.orig.*
10 | web-build/
11 |
12 | # macOS
13 | .DS_Store
14 |
--------------------------------------------------------------------------------
/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { StyleSheet, View, Text } from "react-native";
3 | import Animated from "react-native-reanimated";
4 | import HingeInput from "./HingeInput";
5 |
6 | const marginHorizontal = 40;
7 | const inputHeight = 60;
8 |
9 | export default function App() {
10 | const [fullName, setFullName] = useState("");
11 | const [password, setPassword] = useState("");
12 | const [fullNameZIndex, setFullNameZindex] = useState(1);
13 | const [passwordZIndex, setPasswordZindex] = useState(1);
14 |
15 | return (
16 |
17 | {
28 | setFullNameZindex(2);
29 | setPasswordZindex(1);
30 | }}
31 | />
32 | {
43 | setPasswordZindex(2);
44 | setFullNameZindex(1);
45 | }}
46 | />
47 |
48 | Login
49 |
50 |
51 | );
52 | }
53 |
54 | const styles = StyleSheet.create({
55 | container: {
56 | flex: 1,
57 | justifyContent: "center",
58 | backgroundColor: "#e0e0e0",
59 | },
60 | inputContainer: {
61 | flexDirection: "row",
62 | marginHorizontal,
63 | backgroundColor: "white",
64 | borderRadius: 10,
65 | marginVertical: 20,
66 | shadowColor: "#000",
67 | shadowOffset: {
68 | width: 0,
69 | height: 2,
70 | },
71 | shadowOpacity: 0.25,
72 | shadowRadius: 3.84,
73 |
74 | elevation: 5,
75 | },
76 | input: {
77 | fontSize: 18,
78 | height: inputHeight,
79 | },
80 | inputPreviewContainer: {
81 | position: "absolute",
82 | height: "100%",
83 | width: "100%",
84 | alignItems: "center",
85 | flexDirection: "row",
86 | paddingHorizontal: 12,
87 | },
88 | inputPreview: {
89 | fontSize: 18,
90 | },
91 | loginButton: {
92 | marginTop: 100,
93 | height: inputHeight,
94 | borderRadius: 30,
95 | backgroundColor: "#fc0345",
96 | marginHorizontal: 70,
97 | alignItems: "center",
98 | justifyContent: "center",
99 | },
100 | loginLabel: {
101 | color: "white",
102 | fontSize: 20,
103 | },
104 | });
105 |
--------------------------------------------------------------------------------
/HingeInput.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { TextInput } from "react-native";
3 | import Animated, {
4 | useAnimatedStyle,
5 | useSharedValue,
6 | withSpring,
7 | withTiming,
8 | interpolate,
9 | } from "react-native-reanimated";
10 |
11 | const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
12 |
13 | export default function HingeInput({
14 | style,
15 | value,
16 | onChangeText,
17 | placeholder,
18 | zIndex,
19 | inputStyle,
20 | startRotation = 0,
21 | hingePosition,
22 | onFocus,
23 | paddingHorizontal,
24 | }) {
25 | const width = useSharedValue(0);
26 | const height = useSharedValue(0);
27 | const ogY = useSharedValue(0);
28 | const inputWidth = useSharedValue(0);
29 |
30 | const rotation = useSharedValue(startRotation);
31 | const slideAnimation = useSharedValue(0);
32 | const [dummy, setDummy] = useState(false);
33 |
34 | const direction = hingePosition === "right" ? -1 : 1;
35 |
36 | useEffect(() => {
37 | //rotate by 1 degree on every letter
38 | if (value.length < 10) {
39 | rotation.value = withSpring(-value.length * direction + startRotation);
40 | } else if (value.length < 20) {
41 | rotation.value = withSpring(-20 * direction);
42 | } else {
43 | rotation.value = withSpring(-90 * direction);
44 | }
45 |
46 | //start slide in
47 | if (hingePosition === "right" && value.length) {
48 | slideAnimation.value = withTiming(1, { duration: 3000 });
49 | }
50 | }, [value]);
51 |
52 | useEffect(() => {
53 | setDummy(!dummy);
54 | }, []);
55 |
56 | function getRotationWithAnchor(width, height, rotation) {
57 | "worklet";
58 | return [
59 | { translateX: (width / 2) * direction },
60 | // { translateY: -height / 2 },
61 | { rotate: rotation + "deg" },
62 | // { translateY: height / 2 },
63 | { translateX: -(width / 2) * direction },
64 | ];
65 | }
66 |
67 | const rotationStyle = useAnimatedStyle(() => {
68 | // this not working in reanimated-rc-0 atm
69 | // const inputWidth = measure(aRef).width;
70 | return {
71 | transform: getRotationWithAnchor(width.value, 0, rotation.value),
72 | };
73 | });
74 |
75 | const animatedInputStyle = useAnimatedStyle(() => {
76 | return {
77 | transform: [
78 | {
79 | //interpolate before reaching end of the input
80 | translateX: interpolate(
81 | slideAnimation.value,
82 | [0, 1],
83 | [0, width.value - inputWidth.value - paddingHorizontal * 2]
84 | ),
85 | },
86 | ],
87 | };
88 | });
89 |
90 | return (
91 | {
93 | if (!width.value && layout.width) {
94 | width.value = layout.width;
95 | height.value = layout.height;
96 | ogY.value = layout.y;
97 | }
98 | }}
99 | style={[{ zIndex, paddingHorizontal }, style, rotationStyle]}
100 | >
101 | {
114 | inputWidth.value = width;
115 | }}
116 | />
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Broken UI animation implemented with Reanimated 2
2 |
3 | ### Here's the result
4 |
5 |
6 | https://github.com/alexandrius/react-native-broken-ui/assets/5978212/04662d9a-4cc5-44db-ad33-90e53ea90336
7 |
8 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "TimeScape",
4 | "slug": "TimeScape",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "splash": {
9 | "image": "./assets/splash.png",
10 | "resizeMode": "contain",
11 | "backgroundColor": "#ffffff"
12 | },
13 | "updates": {
14 | "fallbackToCacheTimeout": 0
15 | },
16 | "assetBundlePatterns": [
17 | "**/*"
18 | ],
19 | "ios": {
20 | "supportsTablet": true
21 | },
22 | "android": {
23 | "adaptiveIcon": {
24 | "foregroundImage": "./assets/adaptive-icon.png",
25 | "backgroundColor": "#FFFFFF"
26 | }
27 | },
28 | "web": {
29 | "favicon": "./assets/favicon.png"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandrius/react-native-broken-ui/d457c31c7bde2224a6638c027849ca735799e9f5/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandrius/react-native-broken-ui/d457c31c7bde2224a6638c027849ca735799e9f5/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandrius/react-native-broken-ui/d457c31c7bde2224a6638c027849ca735799e9f5/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandrius/react-native-broken-ui/d457c31c7bde2224a6638c027849ca735799e9f5/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web"
8 | },
9 | "dependencies": {
10 | "expo": "~49.0.16",
11 | "react": "18.2.0",
12 | "react-dom": "18.2.0",
13 | "react-native": "0.72.6",
14 | "react-native-reanimated": "~3.3.0",
15 | "react-native-web": "~0.19.6"
16 | },
17 | "devDependencies": {
18 | "@babel/core": "^7.20.0"
19 | },
20 | "private": true
21 | }
22 |
--------------------------------------------------------------------------------