Skip to content

Commit

Permalink
feat: add animation prop to bottom tab (#11323)
Browse files Browse the repository at this point in the history
**Description**
----

This PR adds properties to `bottom-tabs` navigation `screenOptions` to
enable animation (as `animationEnabled`), add custom animations or use
presets (as `styleInterpolator`), and also let you set transition config
(as `transitionSpec`) hence, providing a medium to animate bottoms-tabs
screens.

**Video Walkthrough**
----
The tabs as shown in the video currently uses a custom `fade` preset.



https://user-images.githubusercontent.com/22779249/234637219-9456967d-55c7-4318-9c7e-41a842747c12.mov



**Quality check**
----
The change takes into account lint, typescript and test checks (all
passing).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: osdnk <micosa97@gmail.com>
  • Loading branch information
3 people committed Aug 31, 2023
1 parent ba53154 commit 8d2a6d8
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 22 deletions.
4 changes: 3 additions & 1 deletion example/src/Screens/BottomTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
import {
createBottomTabNavigator,
TransitionPresets,
useBottomTabBarHeight,
} from '@react-navigation/bottom-tabs';
import { HeaderBackButton, useHeaderHeight } from '@react-navigation/elements';
Expand Down Expand Up @@ -87,9 +88,10 @@ export function BottomTabs({
name="TabChat"
component={Chat}
options={{
tabBarLabel: 'Chat',
tabBarLabel: 'Chat (Animated)',
tabBarIcon: getTabBarIcon('message-reply'),
tabBarBadge: 2,
...TransitionPresets.FadeTransition,
}}
/>
<Tab.Screen
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {
BottomTabSceneInterpolatedStyle,
BottomTabSceneInterpolationProps,
} from '../types';

/**
* Simple cross fade animation
*/
export function forCrossFade({
current,
}: BottomTabSceneInterpolationProps): BottomTabSceneInterpolatedStyle {
return {
sceneStyle: {
opacity: current.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 1, 0],
}),
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { BottomTabTransitionPreset } from '../types';
import { forCrossFade } from './SceneStyleInterpolators';
import { CrossFadeAnimationSpec } from './TransitionSpecs';

export const FadeTransition: BottomTabTransitionPreset = {
animationEnabled: true,
transitionSpec: CrossFadeAnimationSpec,
sceneStyleInterpolator: forCrossFade,
};
11 changes: 11 additions & 0 deletions packages/bottom-tabs/src/TransitionConfigs/TransitionSpecs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Easing } from 'react-native';

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

export const CrossFadeAnimationSpec: TransitionSpec = {
animation: 'timing',
config: {
duration: 1000,
easing: Easing.ease,
},
};
7 changes: 6 additions & 1 deletion packages/bottom-tabs/src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import { fireEvent, render } from '@testing-library/react-native';
import * as React from 'react';
import { Button, Text, View } from 'react-native';
import { Animated, Button, Text, View } from 'react-native';

import { BottomTabScreenProps, createBottomTabNavigator } from '../index';

it('renders a bottom tab navigator with screens', async () => {
// @ts-expect-error: incomplete mock for testing
jest.spyOn(Animated, 'timing').mockImplementation(() => ({
start: (callback) => callback?.({ finished: true }),
}));

const Test = ({ route, navigation }: BottomTabScreenProps<ParamListBase>) => (
<View>
<Text>Screen {route.name}</Text>
Expand Down
9 changes: 9 additions & 0 deletions packages/bottom-tabs/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import * as SceneStyleInterpolator from './TransitionConfigs/SceneStyleInterpolators';
import * as TransitionPresets from './TransitionConfigs/TransitionPresets';
import * as TransitionSpecs from './TransitionConfigs/TransitionSpecs';

/**
* Transition Presets
*/
export { SceneStyleInterpolator, TransitionPresets, TransitionSpecs };

/**
* Navigators
*/
Expand Down
67 changes: 67 additions & 0 deletions packages/bottom-tabs/src/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ export type BottomTabNavigationOptions = HeaderOptions & {
* Only supported on iOS and Android.
*/
freezeOnBlur?: boolean;

/**
* Whether transition animations should be enabled when switching tabs.
* Defaults to `false`.
*/
animationEnabled?: boolean;

/**
* Function which specifies interpolated styles for bottom-tab scenes.
*/
sceneStyleInterpolator?: BottomTabSceneStyleInterpolator;

/**
* Object which specifies the animation type (timing or spring) and their options (such as duration for timing).
*/
transitionSpec?: TransitionSpec;
};

export type BottomTabDescriptor = Descriptor<
Expand All @@ -264,6 +280,57 @@ export type BottomTabDescriptor = Descriptor<

export type BottomTabDescriptorMap = Record<string, BottomTabDescriptor>;

export type BottomTabSceneInterpolationProps = {
/**
* animation Values for the current screen.
*/
current: Animated.Value;
};

export type BottomTabSceneInterpolatedStyle = {
/**
* Interpolated style for the view representing the Scene (View).
*/
sceneStyle: any;
};

export type BottomTabSceneStyleInterpolator = (
props: BottomTabSceneInterpolationProps
) => BottomTabSceneInterpolatedStyle;

export type TransitionSpec =
| {
animation: 'timing';
config: Omit<
Animated.TimingAnimationConfig,
'toValue' | keyof Animated.AnimationConfig
>;
}
| {
animation: 'spring';
config: Omit<
Animated.SpringAnimationConfig,
'toValue' | keyof Animated.AnimationConfig
>;
};

export type BottomTabTransitionPreset = {
/**
* Whether transition animations should be enabled when switching tabs.
*/
animationEnabled?: boolean;

/**
* Function which specifies interpolated styles for bottom-tab scenes.
*/
sceneStyleInterpolator?: BottomTabSceneStyleInterpolator;

/**
* Object which specifies the animation type (timing or spring) and their options (such as duration for timing).
*/
transitionSpec?: TransitionSpec;
};

export type BottomTabNavigationConfig = {
/**
* Function that returns a React element to display as the tab bar.
Expand Down
22 changes: 22 additions & 0 deletions packages/bottom-tabs/src/utils/useAnimatedHashMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Route } from '@react-navigation/routers';
import * as React from 'react';
import { Animated } from 'react-native';

export function useAnimatedHashMap(routes: Route<string>[]) {
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))
) {
return previous;
}
refs.current = {};

routes.forEach(({ key }) => {
refs.current[key] = previous[key] ?? new Animated.Value(0);
});

return refs.current;
}
81 changes: 76 additions & 5 deletions packages/bottom-tabs/src/views/BottomTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
TabNavigationState,
} from '@react-navigation/native';
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
import { Animated, Platform, StyleSheet } from 'react-native';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';

import type {
Expand All @@ -22,6 +22,7 @@ import type {
} from '../types';
import { BottomTabBarHeightCallbackContext } from '../utils/BottomTabBarHeightCallbackContext';
import { BottomTabBarHeightContext } from '../utils/BottomTabBarHeightContext';
import { useAnimatedHashMap } from '../utils/useAnimatedHashMap';
import { BottomTabBar, getTabBarHeight } from './BottomTabBar';
import { MaybeScreen, MaybeScreenContainer } from './ScreenFallback';

Expand All @@ -31,6 +32,11 @@ type Props = BottomTabNavigationConfig & {
descriptors: BottomTabDescriptorMap;
};

const EPSILON = 1e-5;
const STATE_INACTIVE = 0;
const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
const STATE_ON_TOP = 2;

export function BottomTabView(props: Props) {
const {
tabBar = (props: BottomTabBarProps) => <BottomTabBar {...props} />,
Expand All @@ -43,14 +49,55 @@ export function BottomTabView(props: Props) {
Platform.OS === 'ios',
sceneContainerStyle,
} = props;

const focusedRouteKey = state.routes[state.index].key;

const { animationEnabled, sceneStyleInterpolator, transitionSpec } =
descriptors[focusedRouteKey].options;

/**
* List of loaded tabs, tabs will be loaded when navigated to.
*/
const [loaded, setLoaded] = React.useState([focusedRouteKey]);

if (!loaded.includes(focusedRouteKey)) {
// Set the current tab to be loaded if it was not loaded before
setLoaded([...loaded, focusedRouteKey]);
}

const tabAnims = useAnimatedHashMap(state.routes);

React.useEffect(() => {
const animateToIndex = () => {
if (!animationEnabled) {
for (const route of routes) {
tabAnims[route.key].setValue(route.key === focusedRouteKey ? 0 : 1);
}
return;
}
Animated.parallel(
state.routes.map((route) => {
return Animated[transitionSpec?.animation || 'timing'](
tabAnims[route.key],
{
...transitionSpec?.config,
toValue: route.key === focusedRouteKey ? 0 : 1,
useNativeDriver: true,
}
);
})
).start();
};

animateToIndex();
}, [

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

View workflow job for this annotation

GitHub Actions / autofix

React Hook React.useEffect has a missing dependency: 'routes'. Either include it or remove the dependency array

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

View workflow job for this annotation

GitHub Actions / lint

React Hook React.useEffect has a missing dependency: 'routes'. Either include it or remove the dependency array
transitionSpec?.animation,
transitionSpec?.config,
state.routes,
tabAnims,
focusedRouteKey,
animationEnabled,
]);

const dimensions = SafeAreaProviderCompat.initialMetrics.frame;
const [tabBarHeight, setTabBarHeight] = React.useState(() =>
getTabBarHeight({
Expand Down Expand Up @@ -88,11 +135,16 @@ export function BottomTabView(props: Props) {

const { routes } = state;

// not needed if not animation
const hasTwoStates = !routes.some(
(route) => descriptors[route.key].options.animationEnabled
);

return (
<SafeAreaProviderCompat>
<MaybeScreenContainer
enabled={detachInactiveScreens}
hasTwoStates
hasTwoStates={hasTwoStates}
style={styles.container}
>
{routes.map((route, index) => {
Expand Down Expand Up @@ -123,11 +175,30 @@ export function BottomTabView(props: Props) {
headerTransparent,
} = descriptor.options;

const { sceneStyle } =
sceneStyleInterpolator?.({
current: tabAnims[route.key],
}) ?? {};

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
? tabAnims[route.key].interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [
STATE_TRANSITIONING_OR_BELOW_TOP, // screen visible during transition
STATE_TRANSITIONING_OR_BELOW_TOP,
STATE_INACTIVE, // the screen is detached after transition
],
extrapolate: 'extend',
})
: STATE_INACTIVE;

return (
<MaybeScreen
key={route.key}
style={[StyleSheet.absoluteFill, { zIndex: isFocused ? 0 : -1 }]}
visible={isFocused}
active={activityState}
enabled={detachInactiveScreens}
freezeOnBlur={freezeOnBlur}
>
Expand All @@ -146,7 +217,7 @@ export function BottomTabView(props: Props) {
descriptor.navigation as BottomTabNavigationProp<ParamListBase>,
options: descriptor.options,
})}
style={sceneContainerStyle}
style={[sceneContainerStyle, animationEnabled && sceneStyle]}
>
{descriptor.render()}
</Screen>
Expand Down
19 changes: 6 additions & 13 deletions packages/bottom-tabs/src/views/ScreenFallback.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { ResourceSavingView } from '@react-navigation/elements';
import * as React from 'react';
import { StyleProp, View, ViewProps, ViewStyle } from 'react-native';
import { Animated, StyleProp, View, ViewProps, ViewStyle } from 'react-native';

type Props = {
visible: boolean;
children: React.ReactNode;
enabled: boolean;
active: 0 | 1 | 2 | Animated.AnimatedInterpolation<0 | 1>;
children: React.ReactNode;
freezeOnBlur?: boolean;
style?: StyleProp<ViewStyle>;
};
Expand Down Expand Up @@ -33,18 +32,12 @@ export const MaybeScreenContainer = ({
return <View {...rest} />;
};

export function MaybeScreen({ visible, children, ...rest }: Props) {
export function MaybeScreen({ enabled, active, ...rest }: ViewProps & Props) {
if (Screens?.screensEnabled?.()) {
return (
<Screens.Screen activityState={visible ? 2 : 0} {...rest}>
{children}
</Screens.Screen>
<Screens.Screen enabled={enabled} activityState={active} {...rest} />
);
}

return (
<ResourceSavingView visible={visible} {...rest}>
{children}
</ResourceSavingView>
);
return <View {...rest} />;
}
4 changes: 2 additions & 2 deletions packages/elements/src/Background.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useTheme } from '@react-navigation/native';
import * as React from 'react';
import { View, ViewProps } from 'react-native';
import { Animated, ViewProps } from 'react-native';

type Props = ViewProps & {
children: React.ReactNode;
Expand All @@ -10,7 +10,7 @@ export function Background({ style, ...rest }: Props) {
const { colors } = useTheme();

return (
<View
<Animated.View
{...rest}
style={[{ flex: 1, backgroundColor: colors.background }, style]}
/>
Expand Down

0 comments on commit 8d2a6d8

Please sign in to comment.