Skip to content

Commit

Permalink
feat: add shifting animation to bottom-tabs and various fixes
Browse files Browse the repository at this point in the history
This adds a new shifting animation preset to bottom tabs, as well as various animation related fixes.

- Instead of `0`, `1` for animated value, use `-1`, `0`, `1` - so that it can represent both focus state and direction
- Animate with `duration: 0` when animation is not enabled - this ensures that the animation values stay in sync
- Add correct types to animated styles instead of `any`
  • Loading branch information
satya164 committed Sep 7, 2023
1 parent 36d19bd commit b334c38
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 58 deletions.
1 change: 1 addition & 0 deletions example/package.json
Expand Up @@ -13,6 +13,7 @@
"test:e2e": "npx playwright test --config=e2e/playwright.config.ts"
},
"dependencies": {
"@expo/react-native-action-sheet": "^4.0.1",
"@expo/vector-icons": "^13.0.0",
"@react-native-async-storage/async-storage": "~1.17.11",
"@react-native-masked-view/masked-view": "0.2.9",
Expand Down
60 changes: 42 additions & 18 deletions example/src/Screens/BottomTabs.tsx
@@ -1,3 +1,4 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
import {
createBottomTabNavigator,
Expand All @@ -14,7 +15,7 @@ import type { StackScreenProps } from '@react-navigation/stack';
import { BlurView } from 'expo-blur';
import * as React from 'react';
import { ScrollView, StatusBar, StyleSheet } from 'react-native';
import { Button } from 'react-native-paper';
import { Appbar } from 'react-native-paper';

import { Albums } from '../Shared/Albums';
import { Chat } from '../Shared/Chat';
Expand Down Expand Up @@ -55,6 +56,12 @@ const AlbumsScreen = () => {

const Tab = createBottomTabNavigator<BottomTabParams>();

const animations = {
shifting: TransitionPresets.ShiftingTransition,
fade: TransitionPresets.FadeTransition,
none: null,
} as const;

export function BottomTabs({
navigation,
}: StackScreenProps<ParamListBase, string>) {
Expand All @@ -64,23 +71,46 @@ export function BottomTabs({
});
}, [navigation]);

const [animationEnabled, setAnimationEnabled] = React.useState(false);
const [animation, setAnimation] =
React.useState<keyof typeof animations>('none');

const { showActionSheetWithOptions } = useActionSheet();

return (
<Tab.Navigator
screenOptions={{
headerLeft: (props) => (
<HeaderBackButton {...props} onPress={navigation.goBack} />
),
headerRight: () => (
<Button
style={styles.leftButton}
onPress={() => setAnimationEnabled((prev) => !prev)}
icon={animationEnabled ? 'heart' : 'heart-outline'}
>
Fade {animationEnabled ? 'off' : 'on'}
</Button>
headerRight: ({ tintColor }) => (
<Appbar.Action
icon={animation === 'none' ? 'heart-outline' : 'heart'}
color={tintColor}
onPress={() => {
const options = Object.keys(
animations
) as (keyof typeof animations)[];

showActionSheetWithOptions(
{
options: options.map((option) => {
if (option === animation) {
return `${option} (current)`;
}

return option;
}),
},
(index) => {
if (index != null) {
setAnimation(options[index]);
}
}
);
}}
/>
),
...(animationEnabled && TransitionPresets.FadeTransition),
...animations[animation],
}}
>
<Tab.Screen
Expand All @@ -95,7 +125,7 @@ export function BottomTabs({
name="TabChat"
component={Chat}
options={{
tabBarLabel: 'Chat (Animated)',
tabBarLabel: 'Chat',
tabBarIcon: getTabBarIcon('message-reply'),
tabBarBadge: 2,
}}
Expand Down Expand Up @@ -141,9 +171,3 @@ export function BottomTabs({
</Tab.Navigator>
);
}

const styles = StyleSheet.create({
leftButton: {
paddingRight: 8,
},
});
22 changes: 20 additions & 2 deletions example/src/index.tsx
@@ -1,3 +1,4 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useReduxDevToolsExtension } from '@react-navigation/devtools';
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
Divider,
List,
Provider as PaperProvider,
Theme,
} from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';

Expand Down Expand Up @@ -173,7 +175,7 @@ export function App() {
const isLargeScreen = dimensions.width >= 1024;

return (
<PaperProvider theme={paperTheme}>
<Providers theme={paperTheme}>
<StatusBar
translucent
barStyle={theme.dark ? 'light-content' : 'dark-content'}
Expand Down Expand Up @@ -336,6 +338,22 @@ export function App() {
))}
</Stack.Navigator>
</NavigationContainer>
</PaperProvider>
</Providers>
);
}

const Providers = ({
theme,
children,
}: {
theme: Theme;
children: React.ReactNode;
}) => {
return (
<PaperProvider theme={theme}>
<ActionSheetProvider>
<>{children}</>
</ActionSheetProvider>
</PaperProvider>
);
};
Expand Up @@ -18,3 +18,27 @@ export function forCrossFade({
},
};
}

/**
* Animation where the screens slightly shift to left/right
*/
export function forShifting({
current,
}: BottomTabSceneInterpolationProps): BottomTabSceneInterpolatedStyle {
return {

Check warning on line 28 in packages/bottom-tabs/src/TransitionConfigs/SceneStyleInterpolators.tsx

View check run for this annotation

Codecov / codecov/patch

packages/bottom-tabs/src/TransitionConfigs/SceneStyleInterpolators.tsx#L27-L28

Added lines #L27 - L28 were not covered by tests
sceneStyle: {
opacity: current.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 1, 0],
}),
transform: [
{
translateX: current.interpolate({
inputRange: [-1, 0, 1],
outputRange: [-50, 1, 50],
}),
},
],
},
};
}
12 changes: 8 additions & 4 deletions packages/bottom-tabs/src/TransitionConfigs/TransitionPresets.tsx
@@ -1,9 +1,13 @@
import type { BottomTabTransitionPreset } from '../types';
import { forCrossFade } from './SceneStyleInterpolators';
import { CrossFadeAnimationSpec } from './TransitionSpecs';
import { forCrossFade, forShifting } from './SceneStyleInterpolators';
import { CrossFadeSpec, ShiftingSpec } from './TransitionSpecs';

export const FadeTransition: BottomTabTransitionPreset = {
animationEnabled: true,
transitionSpec: CrossFadeAnimationSpec,
transitionSpec: CrossFadeSpec,
sceneStyleInterpolator: forCrossFade,
};

export const ShiftingTransition: BottomTabTransitionPreset = {
transitionSpec: ShiftingSpec,
sceneStyleInterpolator: forShifting,
};
10 changes: 9 additions & 1 deletion packages/bottom-tabs/src/TransitionConfigs/TransitionSpecs.tsx
Expand Up @@ -2,10 +2,18 @@ import { Easing } from 'react-native';

import type { TransitionSpec } from '../types';

export const CrossFadeAnimationSpec: TransitionSpec = {
export const CrossFadeSpec: TransitionSpec = {
animation: 'timing',
config: {
duration: 150,
easing: Easing.in(Easing.linear),
},
};

export const ShiftingSpec: TransitionSpec = {
animation: 'timing',
config: {
duration: 150,
easing: Easing.inOut(Easing.ease),
},
};
9 changes: 6 additions & 3 deletions packages/bottom-tabs/src/types.tsx
Expand Up @@ -282,16 +282,19 @@ export type BottomTabDescriptorMap = Record<string, BottomTabDescriptor>;

export type BottomTabSceneInterpolationProps = {
/**
* animation Values for the current screen.
* Animated value for the current screen:
* - -1 if the index is lower than active tab,
* - 0 if they're active,
* - 1 if the index is higher than active tab
*/
current: Animated.Value;
};

export type BottomTabSceneInterpolatedStyle = {
/**
* Interpolated style for the view representing the Scene (View).
* Interpolated style for the view representing the scene containing screen content.
*/
sceneStyle: any;
sceneStyle: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
};

export type BottomTabSceneStyleInterpolator = (
Expand Down
11 changes: 7 additions & 4 deletions packages/bottom-tabs/src/utils/useAnimatedHashMap.tsx
@@ -1,11 +1,12 @@
import type { Route } from '@react-navigation/routers';
import type { NavigationState } from '@react-navigation/routers';
import * as React from 'react';
import { Animated } from 'react-native';

export function useAnimatedHashMap(routes: Route<string>[]) {
export function useAnimatedHashMap({ routes, index }: NavigationState) {
const refs = React.useRef<Record<string, Animated.Value>>({});
const previous = refs.current;
const routeKeys = Object.keys(previous);

if (
routes.length === routeKeys.length &&
routes.every((route) => routeKeys.includes(route.key))
Expand All @@ -14,8 +15,10 @@ export function useAnimatedHashMap(routes: Route<string>[]) {
}
refs.current = {};

routes.forEach(({ key }) => {
refs.current[key] = previous[key] ?? new Animated.Value(0);
routes.forEach(({ key }, i) => {
refs.current[key] =
previous[key] ??
new Animated.Value(i === index ? 0 : i >= index ? 1 : -1);
});

return refs.current;
Expand Down
55 changes: 37 additions & 18 deletions packages/bottom-tabs/src/views/BottomTabView.tsx
Expand Up @@ -18,6 +18,7 @@ import type {
BottomTabHeaderProps,
BottomTabNavigationConfig,
BottomTabNavigationHelpers,
BottomTabNavigationOptions,
BottomTabNavigationProp,
} from '../types';
import { BottomTabBarHeightCallbackContext } from '../utils/BottomTabBarHeightCallbackContext';
Expand All @@ -38,6 +39,16 @@ const STATE_INACTIVE = 0;
const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
const STATE_ON_TOP = 2;

const hasAnimation = (options: BottomTabNavigationOptions) => {
const { animationEnabled, transitionSpec } = options;

if (animationEnabled === false || !transitionSpec) {
return false;
}

return true;

Check warning on line 49 in packages/bottom-tabs/src/views/BottomTabView.tsx

View check run for this annotation

Codecov / codecov/patch

packages/bottom-tabs/src/views/BottomTabView.tsx#L49

Added line #L49 was not covered by tests
};

export function BottomTabView(props: Props) {
const {
tabBar = (props: BottomTabBarProps) => <BottomTabBar {...props} />,
Expand All @@ -62,33 +73,41 @@ export function BottomTabView(props: Props) {
setLoaded([...loaded, focusedRouteKey]);
}

const tabAnims = useAnimatedHashMap(state.routes);
const tabAnims = useAnimatedHashMap(state);

React.useEffect(() => {
const animateToIndex = () => {
Animated.parallel(
state.routes
.map((route) => {
const { animationEnabled, transitionSpec } =
descriptors[route.key].options;
.map((route, index) => {
const { options } = descriptors[route.key];
const { transitionSpec } = options;

const animationEnabled = hasAnimation(options);

const toValue =
index === state.index ? 0 : index >= state.index ? 1 : -1;

if (!animationEnabled || !transitionSpec) {
return false;
}
return Animated[transitionSpec?.animation || 'timing'](
tabAnims[route.key],
{
...transitionSpec?.config,
toValue: route.key === focusedRouteKey ? 0 : 1,
return Animated.timing(tabAnims[route.key], {
toValue,
duration: 0,
useNativeDriver: true,
}
);
});
}

return Animated[transitionSpec.animation](tabAnims[route.key], {

Check warning on line 99 in packages/bottom-tabs/src/views/BottomTabView.tsx

View check run for this annotation

Codecov / codecov/patch

packages/bottom-tabs/src/views/BottomTabView.tsx#L99

Added line #L99 was not covered by tests
...transitionSpec.config,
toValue,
useNativeDriver: true,
});
})
.filter(Boolean) as CompositeAnimation[]
).start();
};

animateToIndex();
}, [state.routes, tabAnims, focusedRouteKey, descriptors]);
}, [descriptors, state.index, state.routes, tabAnims]);

const dimensions = SafeAreaProviderCompat.initialMetrics.frame;
const [tabBarHeight, setTabBarHeight] = React.useState(() =>
Expand Down Expand Up @@ -127,9 +146,9 @@ export function BottomTabView(props: Props) {

const { routes } = state;

// not needed if not animation
const hasTwoStates = !routes.some(
(route) => descriptors[route.key].options.animationEnabled
// If there is no animation, we only have 2 states: visible and invisible
const hasTwoStates = !routes.some((route) =>
hasAnimation(descriptors[route.key].options)
);

return (
Expand All @@ -144,7 +163,6 @@ export function BottomTabView(props: Props) {
const {
lazy = true,
unmountOnBlur,
animationEnabled,
sceneStyleInterpolator,
} = descriptor.options;
const isFocused = state.index === index;
Expand Down Expand Up @@ -177,6 +195,7 @@ export function BottomTabView(props: Props) {
current: tabAnims[route.key],
}) ?? {};

const animationEnabled = hasAnimation(descriptor.options);
const activityState = isFocused
? STATE_ON_TOP // the screen is on top after the transition
: animationEnabled // is animation is not enabled, immediately move to inactive state
Expand Down
5 changes: 3 additions & 2 deletions packages/elements/src/Background.tsx
@@ -1,8 +1,9 @@
import { useTheme } from '@react-navigation/native';
import * as React from 'react';
import { Animated, ViewProps } from 'react-native';
import { Animated, StyleProp, ViewProps, ViewStyle } from 'react-native';

type Props = ViewProps & {
type Props = Omit<ViewProps, 'style'> & {
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
children: React.ReactNode;
};

Expand Down

0 comments on commit b334c38

Please sign in to comment.