├── .gitignore ├── README.md ├── example ├── .gitignore ├── index.html ├── index.tsx ├── package.json └── tsconfig.json ├── package.json ├── src └── index.tsx ├── test └── blah.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shared-element-rn 2 | 3 | Shared element transitions built around [`react-native-shared-element`](https://github.com/IjzerenHein/react-native-shared-element) 4 | 5 | This component somewhat simplifys the usage for other libraries or custom screens in your app. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | yarn add shared-element-rn react-native-shared-element 11 | cd ios && pod install 12 | ``` 13 | 14 | ## API 15 | 16 | `` will trigger a transition when its `activeIndex` prop changes. 17 | 18 | It will look for any `` children on the next screen and if there is a matching id (or ids) it will trigger the shared element transition. 19 | 20 | ```javascript 21 | import { SharedElements, SharedElement } from 'shared-element-rn'; 22 | 23 | function MySharedElements() { 24 | const [activeIndex, setActiveIndex] = React.useState(0); 25 | 26 | return ( 27 | 28 | 29 | setActiveIndex(1)} /> 30 | setActiveIndex(0)} /> 31 | 32 | 33 | ); 34 | } 35 | 36 | function Screen1({ onTransition }) { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | function Screen2({ onTransition }) { 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | ``` 60 | 61 | ## Other stuff 62 | 63 | Often you'll want to add some custom animations to non-shared elements, for example a fade in or slide in effect. 64 | 65 | This library exports a `useInterpolation()` hook that can be used to animate non-shared elements in sync with the transitions: 66 | 67 | ```javascript 68 | import { useInterpolation } from 'shared-element-rn'; 69 | 70 | const fadeIn = { 71 | opacity: { 72 | inputRange: [-1, 0, 1], 73 | outputRange: [0, 1, 0], 74 | }, 75 | }; 76 | 77 | function FadeIn({ children }) { 78 | const styles = useInterpolation(fadeIn); 79 | return {children}; 80 | } 81 | 82 | const transitionBottom = { 83 | transform: [ 84 | { 85 | translateY: { 86 | inputRange: [-1, 0, 1], 87 | outputRange: [500, 0, 500], 88 | }, 89 | }, 90 | ], 91 | }; 92 | 93 | function TransitionBottom({ children }) { 94 | const styles = useInterpolation(transitionBottom); 95 | 96 | return {children}; 97 | } 98 | ``` 99 | 100 | ## Roadmap 101 | 102 | - gesture handling to dismiss views on swipe 103 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { Thing } from '../.'; 5 | 6 | const App = () => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | ReactDOM.render(, document.getElementById('root')); 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.8.15", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared-element-rn", 3 | "version": "0.1.0", 4 | "main": "dist/index.js", 5 | "module": "dist/shared-element-rn.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "start": "tsdx watch", 12 | "build": "tsdx build", 13 | "test": "tsdx test --env=jsdom", 14 | "lint": "tsdx lint" 15 | }, 16 | "peerDependencies": { 17 | "react": ">=16", 18 | "react-native": "^0.62.0", 19 | "react-native-shared-element": "^0.5.6" 20 | }, 21 | "husky": { 22 | "hooks": { 23 | "pre-commit": "pretty-quick --staged" 24 | } 25 | }, 26 | "prettier": { 27 | "printWidth": 80, 28 | "semi": true, 29 | "singleQuote": true, 30 | "trailingComma": "es5" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^25.1.4", 34 | "@types/react": "^16.9.26", 35 | "@types/react-dom": "^16.9.5", 36 | "@types/react-native": "^0.61.23", 37 | "husky": "^4.2.3", 38 | "prettier": "^2.0.2", 39 | "pretty-quick": "^2.0.1", 40 | "react": "^16.13.1", 41 | "react-dom": "^16.13.1", 42 | "react-native": "^0.62.0", 43 | "react-native-shared-element": "^0.5.6", 44 | "tsdx": "^0.13.0", 45 | "tslib": "^1.11.1", 46 | "typescript": "^3.8.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | SharedElement as RNSharedElement, 5 | SharedElementTransition, 6 | nodeFromRef, 7 | SharedElementAlign, 8 | SharedElementAnimation, 9 | SharedElementResize, 10 | SharedElementNode, 11 | } from 'react-native-shared-element'; 12 | 13 | import { StyleSheet, Animated, View, ViewStyle } from 'react-native'; 14 | 15 | const IndexContext = React.createContext(-1); 16 | const AnimatedIndexContext = React.createContext(new Animated.Value(0)); 17 | const TransitionContext = React.createContext({}); 18 | 19 | interface ISharedElementConfig { 20 | align?: SharedElementAlign; 21 | resize?: SharedElementResize; 22 | animated?: SharedElementAnimation; 23 | debug?: boolean; 24 | } 25 | 26 | const DEFAULT_ELEMENT_CONFIG: ISharedElementConfig = { 27 | align: 'auto', 28 | resize: 'auto', 29 | animated: 'move', 30 | debug: false, 31 | }; 32 | 33 | interface ITransitionContextProvider { 34 | children: React.ReactNode; 35 | } 36 | 37 | function TransitionContextProvider({ children }: ITransitionContextProvider) { 38 | const ancestors = React.useRef([]); 39 | const transitions = React.useRef>({}); 40 | const configs = React.useRef>({}); 41 | const childNodes = React.useRef>({}); 42 | 43 | function registerAncestor(ref: any, index: number) { 44 | const node = nodeFromRef(ref); 45 | // @ts-ignore 46 | ancestors.current[index] = node; 47 | } 48 | 49 | function registerElement( 50 | node: any, 51 | index: number, 52 | id: string, 53 | child: any, 54 | config?: ISharedElementConfig 55 | ) { 56 | if (!transitions.current[id]) { 57 | transitions.current[id] = []; 58 | } 59 | 60 | transitions.current[id][index] = node; 61 | 62 | if (!childNodes.current[id]) { 63 | childNodes.current[id] = []; 64 | } 65 | 66 | childNodes.current[id][index] = child; 67 | 68 | if (!configs.current[id]) { 69 | configs.current[id] = []; 70 | } 71 | 72 | configs.current[id][index] = { 73 | ...DEFAULT_ELEMENT_CONFIG, 74 | ...config, 75 | }; 76 | } 77 | 78 | return ( 79 | 89 | {children} 90 | 91 | ); 92 | } 93 | 94 | interface ISharedElements { 95 | activeIndex: number; 96 | animatedIndex: Animated.Value; 97 | transitionConfig?: ITransitionConfig; 98 | children: React.ReactNode; 99 | } 100 | 101 | function SharedElementsImpl({ 102 | activeIndex, 103 | animatedIndex, 104 | transitionConfig = DEFAULT_TRANSITION_CONFIG, 105 | children, 106 | }: ISharedElements) { 107 | const transitionMethod = 108 | // @ts-ignore 109 | transitionConfig['duration'] !== undefined ? 'timing' : 'spring'; 110 | 111 | const { transitions, ancestors, configs } = React.useContext( 112 | TransitionContext 113 | ); 114 | 115 | const position = React.useRef(new Animated.Value(0)); 116 | 117 | const [currentIndex, setCurrentIndex] = React.useState(activeIndex); 118 | const [nextIndex, setNextIndex] = React.useState(activeIndex); 119 | 120 | React.useEffect(() => { 121 | setNextIndex(activeIndex); 122 | }, [activeIndex]); 123 | 124 | const previousIndex = usePrevious(activeIndex); 125 | 126 | const transitioning = 127 | nextIndex !== currentIndex || previousIndex !== activeIndex; 128 | 129 | function renderTransitions() { 130 | if (currentIndex === nextIndex) { 131 | return null; 132 | } 133 | 134 | position.current.setValue(0); 135 | 136 | const nodes = Object.keys(transitions) 137 | .map((transitionId) => { 138 | const transition = transitions[transitionId]; 139 | const startNode = transition[currentIndex]; 140 | const endNode = transition[nextIndex]; 141 | 142 | if (startNode && endNode) { 143 | const startAncestor = ancestors[currentIndex]; 144 | const endAncestor = ancestors[nextIndex]; 145 | 146 | const elementConfigs = configs[transitionId]; 147 | let elementConfig; 148 | 149 | if (elementConfigs) { 150 | elementConfig = elementConfigs[currentIndex]; 151 | } 152 | 153 | elementConfig = elementConfig || DEFAULT_ELEMENT_CONFIG; 154 | 155 | if (startAncestor && endAncestor) { 156 | return ( 157 | 163 | 175 | 176 | ); 177 | } 178 | } 179 | 180 | return null; 181 | }) 182 | .filter(Boolean); 183 | 184 | if (nodes.length > 0) { 185 | Animated.parallel([ 186 | Animated[transitionMethod](position.current, { 187 | ...transitionConfig, 188 | toValue: 1, 189 | }), 190 | Animated[transitionMethod](animatedIndex, { 191 | ...transitionConfig, 192 | toValue: nextIndex, 193 | }), 194 | ]).start(() => { 195 | setCurrentIndex(nextIndex); 196 | }); 197 | } 198 | 199 | return nodes; 200 | } 201 | 202 | return ( 203 | 204 | 205 | {React.Children.map(children, (child: any, index: number) => { 206 | if (!transitioning) { 207 | if (index > activeIndex) { 208 | return React.cloneElement(child, { children: null }); 209 | } 210 | } 211 | 212 | return ( 213 | 217 | 218 | 219 | {child} 220 | 221 | 222 | 223 | ); 224 | })} 225 | 226 | {renderTransitions()} 227 | 228 | 229 | ); 230 | } 231 | 232 | interface ISharedElementScreen { 233 | children: React.ReactNode; 234 | currentIndex: number; 235 | } 236 | 237 | function SharedElementScreen({ children, currentIndex }: ISharedElementScreen) { 238 | const index = React.useContext(IndexContext); 239 | const { registerAncestor } = React.useContext(TransitionContext); 240 | 241 | const animatedValue = React.useRef(new Animated.Value(0)); 242 | 243 | // prevent initial flash on mount 244 | React.useEffect(() => { 245 | Animated.timing(animatedValue.current, { 246 | toValue: 1, 247 | duration: 100, 248 | }).start(); 249 | }, [currentIndex]); 250 | 251 | return ( 252 | 256 | registerAncestor(ref, index)} 260 | > 261 | {children} 262 | 263 | 264 | ); 265 | } 266 | 267 | interface ISharedElement { 268 | children: React.ReactNode; 269 | id: string; 270 | config?: ISharedElementConfig; 271 | } 272 | 273 | function SharedElement({ children, id, config }: ISharedElement) { 274 | const index = React.useContext(IndexContext); 275 | 276 | const { registerElement } = React.useContext(TransitionContext); 277 | 278 | return ( 279 | registerElement(node, index, id, children, config)} 281 | > 282 | {children} 283 | 284 | ); 285 | } 286 | 287 | type ITransitionConfig = 288 | | Partial 289 | | Partial; 290 | 291 | const DEFAULT_TRANSITION_CONFIG: ITransitionConfig = { 292 | stiffness: 1000, 293 | damping: 500, 294 | mass: 3, 295 | overshootClamping: false, 296 | restDisplacementThreshold: 0.01, 297 | restSpeedThreshold: 0.01, 298 | useNativeDriver: true, 299 | }; 300 | 301 | interface ISharedElements { 302 | activeIndex: number; 303 | children: React.ReactNode; 304 | animatedValue?: Animated.Value; 305 | transitionConfig?: ITransitionConfig; 306 | } 307 | 308 | function SharedElements({ 309 | children, 310 | activeIndex, 311 | animatedValue, 312 | transitionConfig, 313 | }: ISharedElements) { 314 | const animatedIndex = React.useRef( 315 | animatedValue || new Animated.Value(activeIndex) 316 | ); 317 | 318 | return ( 319 | 320 | 325 | {children} 326 | 327 | 328 | ); 329 | } 330 | 331 | export { SharedElements, SharedElement, useInterpolation }; 332 | 333 | function usePrevious(value: any) { 334 | // The ref object is a generic container whose current property is mutable ... 335 | // ... and can hold any value, similar to an instance property on a class 336 | const ref = React.useRef(value); 337 | 338 | // Store current value in ref 339 | React.useEffect(() => { 340 | ref.current = value; 341 | }, [value]); // Only re-run if value changes 342 | 343 | // Return previous value (happens before update in useEffect above) 344 | return ref.current; 345 | } 346 | 347 | function useInterpolation(interpolation: any) { 348 | const index = React.useContext(IndexContext); 349 | const animatedIndex = React.useContext(AnimatedIndexContext); 350 | 351 | const offset = Animated.subtract(index, animatedIndex); 352 | 353 | const styles = React.useMemo(() => { 354 | return interpolateWithConfig(offset, interpolation); 355 | }, [interpolation, offset]); 356 | 357 | return styles; 358 | } 359 | 360 | function interpolateWithConfig( 361 | offset: Animated.AnimatedSubtraction, 362 | pageInterpolation?: any 363 | ): ViewStyle { 364 | if (!pageInterpolation) { 365 | return {}; 366 | } 367 | 368 | return Object.keys(pageInterpolation).reduce((styles: any, key: any) => { 369 | const currentStyle = pageInterpolation[key]; 370 | 371 | if (Array.isArray(currentStyle)) { 372 | const _style = currentStyle.map((interpolationConfig: any) => 373 | interpolateWithConfig(offset, interpolationConfig) 374 | ); 375 | 376 | styles[key] = _style; 377 | return styles; 378 | } 379 | 380 | if (typeof currentStyle === 'object') { 381 | styles[key] = offset.interpolate(currentStyle); 382 | return styles; 383 | } 384 | 385 | return styles; 386 | }, {}); 387 | } 388 | -------------------------------------------------------------------------------- /test/blah.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Thing } from '../src'; 4 | 5 | describe('it', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------