Skip to content

Commit 4ac8033

Browse files
authored
Feat/add transition animator (#1479)
* Add TransitionAnimator * Better version and a screen * Types * Move const and rename props * Remove unused code * Switch to array.includes * Remove animationEnded * Refactor - move and rename * Rename HiddenLocation and Direction * Rename HiddenLocation (2) * Add useAnimatedTransition * Rename to useAnimatedTranslator * Add useAnimationEndNotifier * Add useAnimatedTransition * Change TransitionAnimator to TransitionView * Change order of params * TransitionAnimationEndType to TransitionViewAnimationType
1 parent 8b177c1 commit 4ac8033

16 files changed

+415
-2
lines changed

demo/src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ module.exports = {
219219
get PanViewScreen() {
220220
return require('./screens/incubatorScreens/PanViewScreen').default;
221221
},
222+
get TransitionViewScreen() {
223+
return require('./screens/incubatorScreens/TransitionViewScreen').default;
224+
},
222225
// realExamples
223226
get AppleMusic() {
224227
return require('./screens/realExamples/AppleMusic').default;

demo/src/screens/MenuStructure.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ export const navigationData = {
156156
{title: '(New) TextField', tags: 'text field input', screen: 'unicorn.components.IncubatorTextFieldScreen'},
157157
{title: 'ExpandableOverlay', tags: 'text field expandable input picker', screen: 'unicorn.components.IncubatorExpandableOverlayScreen'},
158158
{title: 'WheelPicker (Incubator)', tags: 'wheel picker spinner experimental', screen: 'unicorn.incubator.WheelPickerScreen'},
159-
{title: 'Pan View', tags: 'pan swipe drag', screen: 'unicorn.incubator.PanViewScreen'}
159+
{title: 'Pan View', tags: 'pan swipe drag', screen: 'unicorn.incubator.PanViewScreen'},
160+
{title: 'Transition View', tags: 'transition animation enter exit', screen: 'unicorn.incubator.TransitionViewScreen'}
160161
]
161162
},
162163
Inspirations: {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, {Component} from 'react';
2+
import {View, Button, Incubator} from 'react-native-ui-lib';
3+
const {TransitionView} = Incubator;
4+
// @ts-ignore
5+
import {renderRadioGroup} from '../ExampleScreenPresenter';
6+
7+
interface State {
8+
enterDirection: Incubator.Direction;
9+
exitDirection: Incubator.Direction;
10+
key: number;
11+
}
12+
13+
export default class TransitionViewScreen extends Component<{}, State> {
14+
private ref = React.createRef<typeof TransitionView>();
15+
state = {
16+
enterDirection: 'left' as Incubator.Direction,
17+
exitDirection: 'bottom' as Incubator.Direction,
18+
key: 1
19+
};
20+
21+
onPress = () => {
22+
this.ref.current?.animateOut();
23+
};
24+
25+
// onAnimationEnd = (type: Incubator.TransitionViewAnimationType) => {
26+
// console.warn('Animation complete', type);
27+
// };
28+
29+
render() {
30+
const {key, enterDirection, exitDirection} = this.state;
31+
return (
32+
<View padding-20 bg-grey80 flex>
33+
{renderRadioGroup.call(this,
34+
'Enter direction',
35+
'enterDirection',
36+
{top: 'top', bottom: 'bottom', left: 'left', right: 'right'},
37+
{isRow: true})}
38+
{renderRadioGroup.call(this,
39+
'Exit direction',
40+
'exitDirection',
41+
{top: 'top', bottom: 'bottom', left: 'left', right: 'right'},
42+
{isRow: true})}
43+
<Button label="Refresh" onPress={() => this.setState({key: key + 1})}/>
44+
<View flex center>
45+
<TransitionView
46+
key={`${key}`}
47+
// @ts-expect-error
48+
ref={this.ref}
49+
enterFrom={enterDirection}
50+
exitTo={exitDirection}
51+
// onAnimationEnd={this.onAnimationEnd}
52+
>
53+
<Button label="Press to remove" onPress={this.onPress}/>
54+
</TransitionView>
55+
</View>
56+
</View>
57+
);
58+
}
59+
}

demo/src/screens/incubatorScreens/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export function registerScreens(registrar) {
55
gestureHandlerRootHOC(require('./TouchableOpacityScreen').default));
66
registrar('unicorn.components.IncubatorExpandableOverlayScreen', () => require('./IncubatorExpandableOverlayScreen').default);
77
registrar('unicorn.components.IncubatorTextFieldScreen', () => require('./IncubatorTextFieldScreen').default);
8-
registrar('unicorn.incubator.WheelPickerScreen', () => gestureHandlerRootHOC(require('./WheelPickerScreen').default));
98
registrar('unicorn.incubator.PanViewScreen', () => require('./PanViewScreen').default);
9+
registrar('unicorn.incubator.TransitionViewScreen', () => require('./TransitionViewScreen').default);
10+
registrar('unicorn.incubator.WheelPickerScreen', () => gestureHandlerRootHOC(require('./WheelPickerScreen').default));
1011
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React, { PropsWithChildren } from 'react';
2+
import { ViewProps } from '../../components/view';
3+
import { ForwardRefInjectedProps } from '../../commons/new';
4+
import { Direction } from './useHiddenLocation';
5+
import { TransitionViewAnimationType } from './useAnimationEndNotifier';
6+
import { AnimatedTransitionProps } from './useAnimatedTransition';
7+
export { Direction, TransitionViewAnimationType };
8+
export declare type TransitionViewProps = AnimatedTransitionProps & ViewProps;
9+
declare type Props = PropsWithChildren<TransitionViewProps> & ForwardRefInjectedProps;
10+
interface Statics {
11+
animateOut: () => void;
12+
}
13+
declare const _default: React.ComponentType<Props> & Statics;
14+
export default _default;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Direction, HiddenLocation } from './useHiddenLocation';
2+
import { AnimationNotifierEndProps } from './useAnimationEndNotifier';
3+
export interface AnimatedTransitionProps extends AnimationNotifierEndProps {
4+
/**
5+
* If this is given there will be an enter animation from this direction.
6+
*/
7+
enterFrom?: Direction;
8+
/**
9+
* If this is given there will be an exit animation to this direction.
10+
*/
11+
exitTo?: Direction;
12+
}
13+
declare type Props = AnimatedTransitionProps & {
14+
hiddenLocation: HiddenLocation;
15+
};
16+
export default function useAnimatedTransition(props: Props): {
17+
exit: () => void;
18+
animatedStyle: {
19+
transform: ({
20+
translateX: number;
21+
translateY?: undefined;
22+
} | {
23+
translateY: number;
24+
translateX?: undefined;
25+
})[];
26+
opacity: number;
27+
};
28+
};
29+
export {};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Direction } from './useHiddenLocation';
2+
export interface TranslatorProps {
3+
initialVisibility: boolean;
4+
}
5+
export default function useAnimatedTranslator(props: TranslatorProps): {
6+
init: (to: {
7+
x: number;
8+
y: number;
9+
}, animationDirection: Direction, callback: (isFinished: boolean) => void) => void;
10+
animate: (to: {
11+
x: number;
12+
y: number;
13+
}, animationDirection: Direction, callback: (isFinished: boolean) => void) => void;
14+
animatedStyle: {
15+
transform: ({
16+
translateX: number;
17+
translateY?: undefined;
18+
} | {
19+
translateY: number;
20+
translateX?: undefined;
21+
})[];
22+
opacity: number;
23+
};
24+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export declare type TransitionViewAnimationType = 'enter' | 'exit';
2+
export interface AnimationNotifierEndProps {
3+
/**
4+
* Callback to the animation end.
5+
*/
6+
onAnimationEnd?: (animationType: TransitionViewAnimationType) => void;
7+
}
8+
export default function useAnimationEndNotifier(props: AnimationNotifierEndProps): {
9+
onEnterAnimationEnd: (isFinished: boolean) => void;
10+
onExitAnimationEnd: (isFinished: boolean) => void;
11+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { RefObject } from 'react';
2+
import { View, LayoutChangeEvent } from 'react-native';
3+
export declare type Direction = 'top' | 'bottom' | 'left' | 'right';
4+
export interface HiddenLocation {
5+
isDefault: boolean;
6+
top: number;
7+
bottom: number;
8+
left: number;
9+
right: number;
10+
}
11+
export interface HiddenLocationProps<T extends View> {
12+
containerRef: RefObject<T>;
13+
}
14+
export default function useHiddenLocation<T extends View>(props: HiddenLocationProps<T>): {
15+
onLayout: (event: LayoutChangeEvent) => void;
16+
hiddenLocation: HiddenLocation;
17+
};

generatedTypes/src/incubator/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { default as TouchableOpacity, TouchableOpacityProps } from './TouchableO
44
export { default as TouchableOpacity2 } from './TouchableOpacity2';
55
export { default as WheelPicker, WheelPickerProps } from './WheelPicker';
66
export { default as PanView, PanViewProps, PanViewDirections, PanViewDismissThreshold } from './panView';
7+
export { default as TransitionView, TransitionViewProps, Direction, TransitionViewAnimationType } from './TransitionView';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, {PropsWithChildren, useCallback, useImperativeHandle} from 'react';
2+
import {View as RNView, LayoutChangeEvent} from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
import View, {ViewProps} from '../../components/view';
5+
import {forwardRef, ForwardRefInjectedProps} from '../../commons/new';
6+
import useHiddenLocation, {Direction} from './useHiddenLocation';
7+
import {TransitionViewAnimationType} from './useAnimationEndNotifier';
8+
import useAnimatedTransition, {AnimatedTransitionProps} from './useAnimatedTransition';
9+
const AnimatedView = Animated.createAnimatedComponent(View);
10+
export {Direction, TransitionViewAnimationType};
11+
12+
// TODO: might need to create a file for types and create a fake component for docs
13+
export type TransitionViewProps = AnimatedTransitionProps & ViewProps;
14+
15+
type Props = PropsWithChildren<TransitionViewProps> & ForwardRefInjectedProps;
16+
interface Statics {
17+
animateOut: () => void;
18+
}
19+
20+
const TransitionView = (props: Props) => {
21+
const {
22+
onAnimationEnd,
23+
enterFrom,
24+
exitTo,
25+
forwardedRef,
26+
style: propsStyle,
27+
onLayout: propsOnLayout,
28+
...others
29+
} = props;
30+
const containerRef = React.createRef<RNView>();
31+
const {onLayout: hiddenLocationOnLayout, hiddenLocation} = useHiddenLocation({containerRef});
32+
const {exit, animatedStyle} = useAnimatedTransition({hiddenLocation, enterFrom, exitTo, onAnimationEnd});
33+
34+
useImperativeHandle(forwardedRef,
35+
() => ({
36+
animateOut: exit // TODO: should this be renamed as well?
37+
}),
38+
[exit]);
39+
40+
const onLayout = useCallback((event: LayoutChangeEvent) => {
41+
hiddenLocationOnLayout(event);
42+
propsOnLayout?.(event);
43+
// eslint-disable-next-line react-hooks/exhaustive-deps
44+
}, []);
45+
46+
return <AnimatedView {...others} onLayout={onLayout} style={[propsStyle, animatedStyle]} ref={containerRef}/>;
47+
};
48+
49+
export default forwardRef<Props, Statics>(TransitionView);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* eslint-disable react-hooks/exhaustive-deps */
2+
import {useEffect, useCallback} from 'react';
3+
import {Direction, HiddenLocation} from './useHiddenLocation';
4+
import useAnimatedTranslator from './useAnimatedTranslator';
5+
import useAnimationEndNotifier, {AnimationNotifierEndProps} from './useAnimationEndNotifier';
6+
7+
export interface AnimatedTransitionProps extends AnimationNotifierEndProps {
8+
/**
9+
* If this is given there will be an enter animation from this direction.
10+
*/
11+
enterFrom?: Direction;
12+
/**
13+
* If this is given there will be an exit animation to this direction.
14+
*/
15+
exitTo?: Direction;
16+
}
17+
18+
type Props = AnimatedTransitionProps & {
19+
hiddenLocation: HiddenLocation;
20+
};
21+
22+
export default function useAnimatedTransition(props: Props) {
23+
const {hiddenLocation, enterFrom, exitTo, onAnimationEnd} = props;
24+
25+
const {init, animate, animatedStyle} = useAnimatedTranslator({initialVisibility: !enterFrom});
26+
const {onEnterAnimationEnd, onExitAnimationEnd} = useAnimationEndNotifier({onAnimationEnd});
27+
28+
const getLocation = (direction?: Direction) => {
29+
return {
30+
x: direction && ['left', 'right'].includes(direction) ? hiddenLocation[direction] : 0,
31+
y: direction && ['top', 'bottom'].includes(direction) ? hiddenLocation[direction] : 0
32+
};
33+
};
34+
35+
useEffect(() => {
36+
if (!hiddenLocation.isDefault && enterFrom) {
37+
const location = getLocation(enterFrom);
38+
init(location, enterFrom, enter);
39+
}
40+
}, [hiddenLocation.isDefault]);
41+
42+
const enter = useCallback(() => {
43+
'worklet';
44+
if (enterFrom) {
45+
animate({x: 0, y: 0}, enterFrom, onEnterAnimationEnd);
46+
}
47+
}, [onEnterAnimationEnd]);
48+
49+
const exit = useCallback(() => {
50+
'worklet';
51+
if (exitTo) {
52+
animate(getLocation(exitTo), exitTo, onExitAnimationEnd);
53+
}
54+
}, [hiddenLocation, exitTo, onExitAnimationEnd]);
55+
56+
return {exit, animatedStyle};
57+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {useCallback} from 'react';
2+
import {useSharedValue, useAnimatedStyle, withSpring, withTiming} from 'react-native-reanimated';
3+
import {Direction} from './useHiddenLocation';
4+
5+
export interface TranslatorProps {
6+
initialVisibility: boolean;
7+
}
8+
9+
const DEFAULT_ANIMATION_VELOCITY = 300;
10+
const DEFAULT_ANIMATION_CONFIG = {velocity: DEFAULT_ANIMATION_VELOCITY, damping: 18, stiffness: 300, mass: 0.4};
11+
12+
export default function useAnimatedTranslator(props: TranslatorProps) {
13+
const {initialVisibility} = props;
14+
15+
// Has to start at {0, 0} with {opacity: 0} so layout can be measured
16+
const translateX = useSharedValue<number>(0);
17+
const translateY = useSharedValue<number>(0);
18+
19+
const visible = useSharedValue<boolean>(initialVisibility);
20+
21+
const init = useCallback((to: {x: number; y: number},
22+
animationDirection: Direction,
23+
callback: (isFinished: boolean) => void) => {
24+
'worklet';
25+
if (['left', 'right'].includes(animationDirection)) {
26+
translateX.value = withTiming(to.x, {duration: 0}, callback);
27+
} else if (['top', 'bottom'].includes(animationDirection)) {
28+
translateY.value = withTiming(to.y, {duration: 0}, callback);
29+
}
30+
31+
visible.value = true;
32+
},
33+
// eslint-disable-next-line react-hooks/exhaustive-deps
34+
[]);
35+
36+
const animate = useCallback((to: {x: number; y: number},
37+
animationDirection: Direction,
38+
callback: (isFinished: boolean) => void) => {
39+
'worklet';
40+
if (['left', 'right'].includes(animationDirection)) {
41+
translateX.value = withSpring(to.x, DEFAULT_ANIMATION_CONFIG, callback);
42+
} else if (['top', 'bottom'].includes(animationDirection)) {
43+
translateY.value = withSpring(to.y, DEFAULT_ANIMATION_CONFIG, callback);
44+
}
45+
},
46+
// eslint-disable-next-line react-hooks/exhaustive-deps
47+
[]);
48+
49+
const animatedStyle = useAnimatedStyle(() => {
50+
return {
51+
transform: [{translateX: translateX.value}, {translateY: translateY.value}],
52+
// TODO: do we want to take the component's opacity here? - I think combining opacities is buggy
53+
opacity: Number(visible.value)
54+
};
55+
}, []);
56+
57+
return {init, animate, animatedStyle};
58+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {useCallback} from 'react';
2+
import {runOnJS} from 'react-native-reanimated';
3+
4+
export type TransitionViewAnimationType = 'enter' | 'exit';
5+
6+
export interface AnimationNotifierEndProps {
7+
/**
8+
* Callback to the animation end.
9+
*/
10+
onAnimationEnd?: (animationType: TransitionViewAnimationType) => void;
11+
}
12+
13+
export default function useAnimationEndNotifier(props: AnimationNotifierEndProps) {
14+
const {onAnimationEnd} = props;
15+
16+
const onEnterAnimationEnd = useCallback((isFinished: boolean) => {
17+
'worklet';
18+
if (onAnimationEnd && isFinished) {
19+
runOnJS(onAnimationEnd)('enter');
20+
}
21+
},
22+
[onAnimationEnd]);
23+
24+
const onExitAnimationEnd = useCallback((isFinished: boolean) => {
25+
'worklet';
26+
if (onAnimationEnd && isFinished) {
27+
runOnJS(onAnimationEnd)('exit');
28+
}
29+
},
30+
[onAnimationEnd]);
31+
32+
return {onEnterAnimationEnd, onExitAnimationEnd};
33+
}

0 commit comments

Comments
 (0)