Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding freezeOnBlur for android and iOS #113

Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/docs/docs/guides/usage-with-react-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ Tab views using the sidebar adaptable style have an appearance

Whether to enable haptic feedback on tab press. Defaults to true.

#### `detachInactiveScreens`

Boolean used to indicate whether inactive screens should be detached from the view hierarchy to save memory. This enables integration with `react-native-screens`. Defaults to `true`.

### Options

The following options can be used to configure the screens in the navigator. These can be specified under `screenOptions` prop of `Tab.navigator` or `options` prop of `Tab.Screen`.
Expand Down Expand Up @@ -174,6 +178,14 @@ Badge to show on the tab icon.

Whether this screens should render the first time it's accessed. Defaults to true. Set it to false if you want to render the screen on initial render.

#### `freezeOnBlur`

Boolean indicating whether to prevent inactive screens from re-rendering. Defaults to `false`. Defaults to `true` when `enableFreeze()` from `react-native-screens` package is run at the top of the application.

Requires `react-native-screens` version >=3.16.0.

Only supported on iOS and Android.

### Events

The navigator can emit events on certain actions. Supported events are:
Expand Down
5 changes: 5 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import NativeBottomTabs from './Examples/NativeBottomTabs';
import TintColorsExample from './Examples/TintColors';
import NativeBottomTabsVectorIcons from './Examples/NativeBottomTabsVectorIcons';
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';

const FourTabsIgnoreSafeArea = () => {
return <FourTabs ignoresTopSafeArea />;
Expand Down Expand Up @@ -112,6 +113,10 @@
component: NativeBottomTabsVectorIcons,
name: 'Native Bottom Tabs with Vector Icons',
},
{
component: NativeBottomTabsFreezeOnBlur,
name: 'Native Bottom Tabs with FreezeOnBlur',
},
{ component: NativeBottomTabs, name: 'Native Bottom Tabs' },
{ component: JSBottomTabs, name: 'JS Bottom Tabs' },
{
Expand Down Expand Up @@ -166,7 +171,7 @@
name="BottomTabs Example"
component={App}
options={{
headerRight: () => (

Check warning on line 174 in example/src/App.tsx

View workflow job for this annotation

GitHub Actions / lint

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “Navigation” and pass data as props. If you want to allow component creation in props, set allowAsProps option to true
<Button
onPress={() =>
Alert.alert(
Expand Down
100 changes: 100 additions & 0 deletions example/src/Examples/NativeBottomTabsFreezeOnBlur.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import createNativeBottomTabNavigator from '../../../src/react-navigation/navigators/createNativeBottomTabNavigator';

const store = new Set<Dispatch>();

type Dispatch = (value: number) => void;

function useValue() {
const [value, setValue] = React.useState<number>(0);

React.useEffect(() => {
const dispatch = (value: number) => {

Check warning on line 13 in example/src/Examples/NativeBottomTabsFreezeOnBlur.tsx

View workflow job for this annotation

GitHub Actions / lint

'value' is already declared in the upper scope on line 10 column 10
setValue(value);
};
store.add(dispatch);
return () => {
store.delete(dispatch);
};
}, [setValue]);

return value;
}

function HomeScreen() {
return (
<View style={styles.screenContainer}>
<Text>Home!</Text>
</View>
);
}

function DetailsScreen() {
const value = useValue();
// only 1 'render' should appear at the time
console.log('Details Screen render', value);
return (
<View style={styles.screenContainer}>
<Text>Details!</Text>
<Text style={{ alignSelf: 'center' }}>Details Screen {value}</Text>

Check warning on line 40 in example/src/Examples/NativeBottomTabsFreezeOnBlur.tsx

View workflow job for this annotation

GitHub Actions / lint

Inline style: { alignSelf: 'center' }
</View>
);
}
const Tab = createNativeBottomTabNavigator();

export default function NativeBottomTabsFreezeOnBlur() {
React.useEffect(() => {
let timer = 0;
const interval = setInterval(() => {
timer = timer + 1;
store.forEach((dispatch) => dispatch(timer));
}, 3000);
return () => clearInterval(interval);
}, []);

return (
<Tab.Navigator
screenOptions={{
freezeOnBlur: true,
}}
>
<Tab.Screen
name="Article"
component={HomeScreen}
options={{
tabBarIcon: () => require('../../assets/icons/article_dark.png'),
}}
/>
<Tab.Screen
name="Albums"
component={DetailsScreen}
options={{
tabBarIcon: () => require('../../assets/icons/grid_dark.png'),
}}
/>
<Tab.Screen
name="Contact"
component={DetailsScreen}
options={{
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
}}
/>
<Tab.Screen
name="Chat"
component={DetailsScreen}
options={{
tabBarIcon: () => require('../../assets/icons/chat_dark.png'),
}}
/>
</Tab.Navigator>
);
}

const styles = StyleSheet.create({
screenContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"react": "18.3.1",
"react-native": "0.75.3",
"react-native-builder-bob": "^0.30.2",
"react-native-screens": "^3.16.0",
"release-it": "^15.0.0",
"turbo": "^1.10.7",
"typescript": "^5.2.2"
Expand All @@ -110,11 +111,15 @@
"peerDependencies": {
"@react-navigation/native": ">=6",
"react": "*",
"react-native": "*"
"react-native": "*",
"react-native-screens": ">=3.16.0"
},
"peerDependenciesMeta": {
"@react-navigation/native": {
"optional": true
},
"react-native-screens": {
"optional": true
}
},
"workspaces": [
Expand Down
94 changes: 66 additions & 28 deletions src/TabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import TabViewAdapter from './TabViewAdapter';
import useLatestCallback from 'use-latest-callback';
import { useMemo, useState } from 'react';
import type { BaseRoute, NavigationState } from './types';
import {
MaybeScreen,
MaybeScreenContainer,
} from './react-navigation/views/ScreenFallback';

const isAppleSymbol = (icon: any): icon is { sfSymbol: string } =>
icon?.sfSymbol;
Expand Down Expand Up @@ -104,6 +108,13 @@ interface Props<Route extends BaseRoute> {
focused: boolean;
}) => ImageSource | undefined;

/**
* Get freezeOnBlur for the current screen. Uses false by default.
* Defaults to `true` when `enableFreeze()` is run at the top of the application.
*
*/
getFreezeOnBlur?: (props: { route: Route }) => boolean | undefined;

/**
* Background color of the tab bar.
*/
Expand All @@ -117,6 +128,13 @@ interface Props<Route extends BaseRoute> {
* Color of tab indicator. (Android only)
*/
activeIndicatorColor?: ColorValue;

/**
* Whether inactive screens should be detached from the view hierarchy to save memory.
* Make sure to call `enableScreens` from `react-native-screens` to make it work.
* Defaults to `true` on Android.
*/
detachInactiveScreens?: boolean;
}

const ANDROID_MAX_TABS = 6;
Expand All @@ -136,6 +154,10 @@ const TabView = <Route extends BaseRoute>({
: route.focusedIcon,
barTintColor,
getActiveTintColor = ({ route }: { route: Route }) => route.activeTintColor,
getFreezeOnBlur = ({ route }: { route: Route }) => route.freezeOnBlur,
detachInactiveScreens = Platform.OS === 'web' ||
Platform.OS === 'android' ||
Platform.OS === 'ios',
tabBarActiveTintColor: activeTintColor,
tabBarInactiveTintColor: inactiveTintColor,
hapticFeedbackEnabled = true,
Expand Down Expand Up @@ -238,39 +260,51 @@ const TabView = <Route extends BaseRoute>({
barTintColor={barTintColor}
rippleColor={rippleColor}
>
{trimmedRoutes.map((route) => {
if (getLazy({ route }) !== false && !loaded.includes(route.key)) {
// Don't render a screen if we've never navigated to it
if (Platform.OS === 'android') {
return null;
<MaybeScreenContainer
enabled={detachInactiveScreens}
hasTwoStates
style={styles.container}
>
Copy link
Collaborator

@okwasniewski okwasniewski Nov 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tested it on both new and old architecture? I feel like this might break passing children on new arch as technically now we have only one child.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Till now I tested this on Android for both new and old arch.
I will be testing it on iOS today.

{trimmedRoutes.map((route) => {
if (getLazy({ route }) !== false && !loaded.includes(route.key)) {
// Don't render a screen if we've never navigated to it
if (Platform.OS === 'android') {
return null;
}
return (
<View
key={route.key}
collapsable={false}
style={styles.fullWidth}
/>
);
}

const freezeOnBlur = getFreezeOnBlur({ route });
const isFocused = route.key === focusedKey;

return (
<View
<MaybeScreen
key={route.key}
visible={isFocused}
enabled={detachInactiveScreens}
freezeOnBlur={freezeOnBlur}
collapsable={false}
style={styles.fullWidth}
/>
style={[
styles.fullWidth,
Platform.OS === 'android' && {
display: isFocused ? 'flex' : 'none',
},
]}
>
{renderScene({
route,
jumpTo,
})}
</MaybeScreen>
);
}

return (
<View
key={route.key}
collapsable={false}
style={[
styles.fullWidth,
Platform.OS === 'android' && {
display: route.key === focusedKey ? 'flex' : 'none',
},
]}
>
{renderScene({
route,
jumpTo,
})}
</View>
);
})}
})}
</MaybeScreenContainer>
</TabViewAdapter>
);
};
Expand All @@ -280,6 +314,10 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
container: {
flex: 1,
overflow: 'hidden',
},
});

export default TabView;
9 changes: 9 additions & 0 deletions src/react-navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ export type NativeBottomTabNavigationOptions = {
* Active tab color.
*/
tabBarActiveTintColor?: string;

/**
* Whether inactive screens should be suspended from re-rendering. Defaults to `false`.
* Defaults to `true` when `enableFreeze()` is run at the top of the application.
* Requires `react-native-screens` version >=3.16.0.
*
* Only supported on iOS and Android.
*/
freezeOnBlur?: boolean;
};

export type NativeBottomTabDescriptor = Descriptor<
Expand Down
3 changes: 3 additions & 0 deletions src/react-navigation/views/NativeBottomTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export default function NativeBottomTabView({

return null;
}}
getFreezeOnBlur={({ route }) =>
descriptors[route.key]?.options.freezeOnBlur
}
getLazy={({ route }) => descriptors[route.key]?.options.lazy ?? true}
onTabLongPress={(index) => {
const route = state.routes[index];
Expand Down
45 changes: 45 additions & 0 deletions src/react-navigation/views/ScreenFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import { StyleProp, View, ViewProps, ViewStyle } from 'react-native';

type Props = {
visible: boolean;
children: React.ReactNode;
enabled: boolean;
freezeOnBlur?: boolean;
style?: StyleProp<ViewStyle>;
collapsable?: boolean;
};

let Screens: typeof import('react-native-screens') | undefined;

try {
Screens = require('react-native-screens');
} catch (e) {
// Ignore
}

export const MaybeScreenContainer = ({
enabled,
...rest
}: ViewProps & {
enabled: boolean;
hasTwoStates: boolean;
children: React.ReactNode;
}) => {
if (Screens?.screensEnabled?.()) {
return <Screens.ScreenContainer enabled={enabled} {...rest} />;
}

return <View {...rest} />;
};

export function MaybeScreen({ visible, children, ...rest }: Props) {
if (Screens?.screensEnabled?.()) {
return (
<Screens.Screen activityState={visible ? 2 : 0} {...rest}>
{children}
</Screens.Screen>
);
}
return <View {...rest}>{children}</View>;
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type BaseRoute = {
focusedIcon?: ImageSourcePropType | AppleIcon;
unfocusedIcon?: ImageSourcePropType | AppleIcon;
activeTintColor?: string;
freezeOnBlur?: boolean;
};

export type NavigationState<Route extends BaseRoute> = {
Expand Down
Loading
Loading