Skip to content

Commit

Permalink
fix: use measured header height when exposing it (#11917)
Browse files Browse the repository at this point in the history
**Motivation**

Currently the `useHeaderHeight` hook returns a hardcoded value since
previously we didn't have a way to measure the header height. But now
there's a `onHeaderHeightChange` listener that we can use to measure the
accurate height.

This PR makes sure that we use this event when measuring header height.

**Test plan**

Tested in the example app:


https://github.com/react-navigation/react-navigation/assets/1174278/c6e415eb-3cba-4e65-8717-9fd2d03f8987
  • Loading branch information
satya164 committed Mar 29, 2024
1 parent 1d5ee6e commit d90ed76
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 65 deletions.
167 changes: 111 additions & 56 deletions example/src/Screens/NativeStack.tsx
@@ -1,11 +1,12 @@
import { Button, useHeaderHeight } from '@react-navigation/elements';
import type { PathConfigMap } from '@react-navigation/native';
import { Button, Text, useHeaderHeight } from '@react-navigation/elements';
import { type PathConfigMap, useTheme } from '@react-navigation/native';
import {
createNativeStackNavigator,
type NativeStackScreenProps,
useAnimatedHeaderHeight,
} from '@react-navigation/native-stack';
import * as React from 'react';
import { Platform, ScrollView, StyleSheet, View } from 'react-native';
import { Animated, Platform, ScrollView, StyleSheet, View } from 'react-native';

import { COMMON_LINKING_CONFIG } from '../constants';
import { Albums } from '../Shared/Albums';
Expand All @@ -31,32 +32,35 @@ const ArticleScreen = ({
route,
}: NativeStackScreenProps<NativeStackParams, 'Article'>) => {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={styles.buttons}>
<Button
variant="filled"
onPress={() => navigation.push('NewsFeed', { date: Date.now() })}
>
Push feed
</Button>
<Button
variant="filled"
onPress={() => navigation.replace('NewsFeed', { date: Date.now() })}
>
Replace with feed
</Button>
<Button variant="filled" onPress={() => navigation.popTo('Albums')}>
Pop to Albums
</Button>
<Button variant="tinted" onPress={() => navigation.pop()}>
Pop screen
</Button>
</View>
<Article
author={{ name: route.params?.author ?? 'Unknown' }}
scrollEnabled={scrollEnabled}
/>
</ScrollView>
<View>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={styles.buttons}>
<Button
variant="filled"
onPress={() => navigation.push('NewsFeed', { date: Date.now() })}
>
Push feed
</Button>
<Button
variant="filled"
onPress={() => navigation.replace('NewsFeed', { date: Date.now() })}
>
Replace with feed
</Button>
<Button variant="filled" onPress={() => navigation.popTo('Albums')}>
Pop to Albums
</Button>
<Button variant="tinted" onPress={() => navigation.pop()}>
Pop screen
</Button>
</View>
<Article
author={{ name: route.params?.author ?? 'Unknown' }}
scrollEnabled={scrollEnabled}
/>
</ScrollView>
<HeaderHeightView />
</View>
);
};

Expand All @@ -73,17 +77,20 @@ const NewsFeedScreen = ({
}, [navigation]);

return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={styles.buttons}>
<Button variant="filled" onPress={() => navigation.push('Albums')}>
Push Albums
</Button>
<Button variant="tinted" onPress={() => navigation.goBack()}>
Go back
</Button>
</View>
<NewsFeed scrollEnabled={scrollEnabled} date={route.params.date} />
</ScrollView>
<View>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={styles.buttons}>
<Button variant="filled" onPress={() => navigation.push('Albums')}>
Push Albums
</Button>
<Button variant="tinted" onPress={() => navigation.goBack()}>
Go back
</Button>
</View>
<NewsFeed scrollEnabled={scrollEnabled} date={route.params.date} />
</ScrollView>
<HeaderHeightView />
</View>
);
};

Expand All @@ -93,28 +100,62 @@ const AlbumsScreen = ({
const headerHeight = useHeaderHeight();

return (
<ScrollView contentContainerStyle={{ paddingTop: headerHeight }}>
<View style={styles.buttons}>
<Button
variant="filled"
onPress={() =>
navigation.navigate('Article', { author: 'Babel fish' })
}
>
Navigate to article
</Button>
<Button variant="tinted" onPress={() => navigation.pop(2)}>
Pop by 2
</Button>
</View>
<Albums scrollEnabled={scrollEnabled} />
</ScrollView>
<View>
<ScrollView contentContainerStyle={{ paddingTop: headerHeight }}>
<View style={styles.buttons}>
<Button
variant="filled"
onPress={() =>
navigation.navigate('Article', { author: 'Babel fish' })
}
>
Navigate to article
</Button>
<Button variant="tinted" onPress={() => navigation.pop(2)}>
Pop by 2
</Button>
</View>
<Albums scrollEnabled={scrollEnabled} />
</ScrollView>
<HeaderHeightView hasOffset />
</View>
);
};

const HeaderHeightView = ({
hasOffset = Platform.OS === 'ios',
}: {
hasOffset?: boolean;
}) => {
const { colors } = useTheme();

const animatedHeaderHeight = useAnimatedHeaderHeight();
const headerHeight = useHeaderHeight();

return (
<Animated.View
style={[
styles.headerHeight,
{
backgroundColor: colors.card,
borderColor: colors.border,
shadowColor: colors.border,
},
hasOffset && {
transform: [{ translateY: animatedHeaderHeight }],
},
]}
>
<Text>{headerHeight.toFixed(2)}</Text>
</Animated.View>
);
};

const Stack = createNativeStackNavigator<NativeStackParams>();

export function NativeStack() {
const { colors } = useTheme();

return (
<Stack.Navigator>
<Stack.Screen
Expand Down Expand Up @@ -143,6 +184,10 @@ export function NativeStack() {
presentation: 'modal',
headerTransparent: true,
headerBlurEffect: 'light',
headerStyle: {
// Add a background color since Android doesn't support blur effect
backgroundColor: colors.card,
},
}}
/>
</Stack.Navigator>
Expand All @@ -162,4 +207,14 @@ const styles = StyleSheet.create({
gap: 12,
padding: 12,
},
headerHeight: {
position: 'absolute',
top: 0,
right: 0,
padding: 12,
borderWidth: StyleSheet.hairlineWidth,
borderRightWidth: 0,
borderTopWidth: 0,
borderBottomLeftRadius: 3,
},
});
14 changes: 14 additions & 0 deletions packages/native-stack/src/utils/debounce.tsx
@@ -0,0 +1,14 @@
export function debounce<T extends (...args: any[]) => void>(
func: T,
duration: number
): T {
let timeout: NodeJS.Timeout;

return function (this: unknown, ...args) {
clearTimeout(timeout);

timeout = setTimeout(() => {
func.apply(this, args);
}, duration);
} as T;
}
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import type { Animated } from 'react-native';

export const AnimatedHeaderHeightContext = React.createContext<
Animated.Value | undefined
Animated.AnimatedInterpolation<number> | undefined
>(undefined);

export function useAnimatedHeaderHeight() {
Expand Down
79 changes: 71 additions & 8 deletions packages/native-stack/src/views/NativeStackView.native.tsx
Expand Up @@ -20,6 +20,7 @@ import * as React from 'react';
import {
Animated,
Platform,
StatusBar,
StyleSheet,
useAnimatedValue,
View,
Expand All @@ -42,6 +43,7 @@ import type {
NativeStackNavigationHelpers,
NativeStackNavigationOptions,
} from '../types';
import { debounce } from '../utils/debounce';
import { getModalRouteKeys } from '../utils/getModalRoutesKeys';
import { AnimatedHeaderHeightContext } from '../utils/useAnimatedHeaderHeight';
import { useDismissedRouteError } from '../utils/useDismissedRouteError';
Expand Down Expand Up @@ -252,15 +254,48 @@ const SceneView = ({

const { preventedRoutes } = usePreventRemoveContext();

const defaultHeaderHeight = getDefaultHeaderHeight(frame, isModal, topInset);
const defaultHeaderHeight = Platform.select({
// FIXME: Currently screens isn't using Material 3
// So our `getDefaultHeaderHeight` doesn't return the correct value
// So we hardcode the value here for now until screens is updated
android: 56 + topInset,
default: getDefaultHeaderHeight(frame, isModal, topInset),
});

const [headerHeight, setHeaderHeight] = React.useState(defaultHeaderHeight);

// eslint-disable-next-line react-hooks/exhaustive-deps
const setHeaderHeightDebounced = React.useCallback(
// Debounce the header height updates to avoid excessive re-renders
debounce(setHeaderHeight, 100),
[]
);

const hasCustomHeader = header !== undefined;

let headerHeightCorrectionOffset = 0;

if (isAndroid && !hasCustomHeader) {
const statusBarHeight = StatusBar.currentHeight ?? 0;

const [customHeaderHeight, setCustomHeaderHeight] =
React.useState(defaultHeaderHeight);
// FIXME: On Android, the native header height is not correctly calculated
// It includes status bar height even if statusbar is not translucent
// And the statusbar value itself doesn't match the actual status bar height
// So we subtract the bogus status bar height and add the actual top inset
headerHeightCorrectionOffset = -statusBarHeight + topInset;
}

const animatedHeaderHeight = useAnimatedValue(defaultHeaderHeight);
const rawAnimatedHeaderHeight = useAnimatedValue(defaultHeaderHeight);
const animatedHeaderHeight = React.useMemo(
() =>
Animated.add<number>(
rawAnimatedHeaderHeight,
headerHeightCorrectionOffset
),
[headerHeightCorrectionOffset, rawAnimatedHeaderHeight]
);

const headerTopInsetEnabled = topInset !== 0;
const headerHeight = header ? customHeaderHeight : defaultHeaderHeight;

const backTitle = previousDescriptor
? getHeaderTitle(previousDescriptor.options, previousDescriptor.route.name)
Expand Down Expand Up @@ -332,11 +367,36 @@ const SceneView = ({
[
{
nativeEvent: {
headerHeight: animatedHeaderHeight,
headerHeight: rawAnimatedHeaderHeight,
},
},
],
{ useNativeDriver: true }
{
useNativeDriver: true,
listener: (e) => {
if (
e.nativeEvent &&
typeof e.nativeEvent === 'object' &&
'headerHeight' in e.nativeEvent &&
typeof e.nativeEvent.headerHeight === 'number'
) {
const headerHeight =
e.nativeEvent.headerHeight + headerHeightCorrectionOffset;

// Only debounce if header has large title or search bar
// As it's the only case where the header height can change frequently
const doesHeaderAnimate =
Platform.OS === 'ios' &&
(options.headerLargeTitle || options.headerSearchBarOptions);

if (doesHeaderAnimate) {
setHeaderHeightDebounced(headerHeight);
} else {
setHeaderHeight(headerHeight);
}
}
},
}
)}
// this prop is available since rn-screens 3.16
freezeOnBlur={freezeOnBlur}
Expand Down Expand Up @@ -388,7 +448,10 @@ const SceneView = ({
{header !== undefined && headerShown !== false ? (
<View
onLayout={(e) => {
setCustomHeaderHeight(e.nativeEvent.layout.height);
const headerHeight = e.nativeEvent.layout.height;

setHeaderHeight(headerHeight);
rawAnimatedHeaderHeight.setValue(headerHeight);
}}
style={headerTransparent ? styles.absolute : null}
>
Expand Down

0 comments on commit d90ed76

Please sign in to comment.