├── .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 | --------------------------------------------------------------------------------