Collapsing headers and swipeable tabs are among the most common UI elements in mobile apps. For example, such a template is widely used on profile screens on Social Media apps like Instagram or Twitter.
In this article, we are going to create a screen with a collapsing header and multiple swipeable tabs below step-by-step using React Native. This behavior can be achieved easily with the help of React Native Reanimated and React Navigation libraries.
Starting point of creating React Native collapsible tab
This simple tab screen will be the starting point of our journey. It’s just 2 tabs created using material top tab navigator from React Navigation. Each tab contains a FlatList with some mocked data.
Nothing special about the implementation of the ConnectionList, except for a reference forwarding. We’ll need to use this technique in the future, so let’s prepare it in advance.
1... 2 3type Props = Omit<FlatListProps<Connection>, "renderItem">; 4 5const ConnectionList = forwardRef<FlatList, Props>((props, ref) => { 6 const keyExtractor = useCallback((_, index) => index.toString(), []); 7 8 const renderItem = useCallback<ListRenderItem<Connection>>( 9 ({ item }) => <ConnectionItem connection={item} />, 10 [] 11 ); 12 13 return ( 14 <FlatList 15 ref={ref} 16 style={styles.container} 17 renderItem={renderItem} 18 keyExtractor={keyExtractor} 19 {...props} 20 /> 21 ); 22}); 23 24const styles = StyleSheet.create({ 25 container: { 26 flex: 1, 27 }, 28}); 29 30export default memo(ConnectionList);
Here is the simple Profile component with two tabs. It will be the canvas for our future React Native collapsible tabs.
1... 2 3const Profile: FC = () => { 4 const renderFriends = useCallback( 5 () => <ConnectionList data={FRIENDS} />, 6 [] 7 ); 8 9 const renderSuggestions = useCallback( 10 () => <ConnectionList data={SUGGESTIONS} />, 11 [] 12 ); 13 14 return ( 15 <SafeAreaView style={styles.container}> 16 <Tab.Navigator> 17 <Tab.Screen name="Friends">{renederFriends}</Tab.Screen> 18 <Tab.Screen name="Suggestions">{renderSuggestions}</Tab.Screen> 19 </Tab.Navigator> 20 </SafeAreaView> 21 ); 22}; 23 24const styles = StyleSheet.create({ 25 container: { 26 flex: 1, 27 backgroundColor: "white", 28 }, 29}); 30 31export default memo(Profile); 32
That’s what we have after completing the first step and rendering the component above.
Simple static header
To implement collapsing behavior, we’ll need to place the header above our screen and add a corresponding offset to the list and tab components.
1... 2 3 return ( 4 <View style={styles.container}> 5 <Tab.Navigator> 6 <Tab.Screen name="Friends">{renderFriends}</Tab.Screen> 7 <Tab.Screen name="Suggestions">{renderSuggestions}</Tab.Screen> 8 </Tab.Navigator> 9 <Animated.View style={styles.headerContainer}> 10 <Header 11 name="Emily Davis" 12 bio="Let's get started 🚀" 13 photo={"https://picsum.photos/id/1027/300/300"} 14 /> 15 </Animated.View> 16 </View> 17 ); 18}; 19 20const styles = StyleSheet.create({ 21... 22 headerContainer: { 23 top: 0, 24 left: 0, 25 right: 0, 26 position: "absolute", 27 }, 28}); 29 30... 31
In order to provide a correct offset, we’ll have to know the exact height of the header. If you already know it — good for youNothing needs to be solved when we already know the height before rendering. But it’s not the case, so this is where onLayout comes in handy.
1... 2 3 const [headerHeight, setHeaderHeight] = useState(0); 4 5 const handleHeaderLayout = useCallback<NonNullable<ViewProps["onLayout"]>>( 6 (event) => setHeaderHeight(event.nativeEvent.layout.height), 7 [] 8 ); 9 10 ... 11 12 <Animated.View 13 style={styles.headerContainer} 14 onLayout={handleHeaderLayout}> 15 16...
This offset should be added to the tab bar component and the content containers of our lists. React Navigation provides an easy way to customize a tab bar appearance via tabBar prop.
Additionally, let’s replace the SafeAreaView container with a plain View. It’s better to add the insets handling manually inside the content containers to avoid the list item cutting like in the first screenshot.
1... 2 3 const { top, bottom } = useSafeAreaInsets(); 4 5 const contentContainerStyle = useMemo<StyleProp<ViewStyle>>( 6 () => ({ 7 paddingTop: headerHeight + TAB_BAR_HEIGHT, 8 paddingBottom: bottom, 9 }), 10 [headerHeight, bottom] 11 ); 12 13 const sharedProps = useMemo<Partial<FlatListProps<Connection>>>( 14 () => ({ 15 contentContainerStyle, 16 scrollIndicatorInsets: { top: headerHeight }, 17 }), 18 [contentContainerStyle] 19 ); 20 21 const renderFriends = useCallback( 22 () => <ConnectionList data={FRIENDS} {...sharedProps} />, 23 [sharedProps] 24 ); 25 26 const renderSuggestions = useCallback( 27 () => <ConnectionList data={SUGGESTIONS} {...sharedProps} />, 28 [sharedProps] 29 ); 30 31 const tabBarStyle = useMemo<StyleProp<ViewStyle>>( 32 () => [styles.tabBarContainer, { top: headerHeight }], 33 [headerHeight] 34 ); 35 36 const renderTabBar = useCallback< 37 (props: MaterialTopTabBarProps) => React.ReactElement 38 >( 39 (props) => ( 40 <Animated.View style={tabBarStyle}> 41 <TabBar {...props} /> 42 </Animated.View> 43 ), 44 [tabBarStyle, headerHeight] 45 ); 46 47 const headerContainerStyle = useMemo<StyleProp<ViewStyle>>( 48 () => [styles.headerContainer, { paddingTop: top }], 49 [headerHeight] 50 ); 51 52 return ( 53 <View style={styles.container}> 54 <Tab.Navigator tabBar={renderTabBar}> 55 <Tab.Screen name="Friends">{renderFriends}</Tab.Screen> 56 <Tab.Screen name="Suggestions">{renderSuggestions}</Tab.Screen> 57 </Tab.Navigator> 58 <Animated.View onLayout={handleHeaderLayout} style={headerContainerStyle}> 59 <Header 60 name="Emily Davis" 61 bio="Let's get started 🚀" 62 photo={"https://picsum.photos/id/1027/300/300"} 63 /> 64 </Animated.View> 65 </View> 66 ); 67}; 68 69... 70
Okay, that will work, but what about the very first render? We still know nothing about the header’s height and onLayout callback hasn’t been called yet. To avoid the abruptness of the swipes caused by the unsuitable header height, we’ll have to be a little bit creative.
The idea is to use relative rendering instead of absolute one, but only for the first render. The components will perfectly align below one another with no additional help. And as soon as we know the header’s height, we’ll re-render it using absolute alignment. The component tree will be adjusted, but a user won’t notice a thing.
1... 2 3 const rendered = headerHeight > 0; 4 5 ... 6 7 const contentContainerStyle = useMemo<StyleProp<ViewStyle>>( 8 () => ({ 9 paddingTop: rendered ? headerHeight + TAB_BAR_HEIGHT : 0, 10 paddingBottom: bottom, 11 }), 12 [rendered, headerHeight, bottom] 13 ); 14 15 ... 16 17 const tabBarStyle = useMemo<StyleProp<ViewStyle>>( 18 () => [ 19 rendered ? styles.tabBarContainer : undefined, 20 { top: rendered ? headerHeight : undefined }, 21 ], 22 [rendered, headerHeight] 23 ); 24 25 ... 26 27 const headerContainerStyle = useMemo<StyleProp<ViewStyle>>( 28 () => [rendered ? styles.headerContainer : undefined, { paddingTop: top }], 29 30 [rendered, headerHeight] 31 ); 32 33... 34
Animating the Header
After we’ve created a static header, it’s time to spice it up with some animations.
React Native Reanimated has been recently upgraded to v2, which brings a brand new imperative animation API. At Stormotion, we always try to be up to date with the new technologies and approaches, so we definitely wanted to give it a go 🚀
The point of animation here is to make more space for the main content by collapsing the header and making the tabs stick to the top as the user scrolls.
Before starting to work on animations, we’d like to provide a simple way to determine the scroll length required to collapse the header.
1... 2 3export type HeaderConfig = { 4 heightExpanded: number; 5 heightCollapsed: number; 6}; 7 8... 9 10 const { top, bottom } = useSafeAreaInsets(); 11 12 const [headerHeight, setHeaderHeight] = useState(0); 13 14 const defaultHeaderHeight = top + HEADER_HEIGHT; 15 16 const headerConfig = useMemo<HeaderConfig>( 17 () => ({ 18 heightCollapsed: defaultHeaderHeight, 19 heightExpanded: headerHeight, 20 }), 21 [] 22 ); 23 24 const { heightCollapsed, heightExpanded } = headerConfig; 25 26 const headerHeightDiff = heightExpanded - heightCollapsed; 27 28...
In this case, header height diff is exactly equal to the scroll distance we need to use.
With React Native Reanimated, animated scroll value capturing became significantly easier.
1... 2 3 const friendsScrollValue = useSharedValue(0); 4 5 const friendsScrollHandler = useAnimatedScrollHandler( 6 (event) => (friendsScrollValue.value = event.contentOffset.y) 7 ); 8 9 const suggestionsScrollValue = useSharedValue(0); 10 11 const suggestionsScrollHandler = useAnimatedScrollHandler( 12 (event) => (suggestionsScrollValue.value = event.contentOffset.y) 13 ); 14 15... 16
Now, we need to create some variable which will reflect the animated scroll position of the currently displayed list. Unfortunately, tab navigator doesn’t provide any way to listen to current tab changes. However, we can achieve this by a little trick with a custom tab bar.
1... 2 3type Props = MaterialTopTabBarProps & { 4 onIndexChange?: (index: number) => void; 5}; 6 7const TabBar: FC<Props> = ({ onIndexChange, ...props }) => { 8 9 const { index } = props.state; 10 11 useEffect(() => { 12 onIndexChange?.(index); 13 }, [onIndexChange, index]); 14 15 return <MaterialTopTabBar {...props} />; 16}; 17 18export default TabBar;
1 ... 2 3 const [tabIndex, setTabIndex] = useState(0); 4 5 ... 6 7 const renderTabBar = useCallback< 8 (props: MaterialTopTabBarProps) => React.ReactElement 9 >( 10 (props) => ( 11 <Animated.View style={tabBarStyle}> 12 <TabBar onIndexChange={setTabIndex} {...props} /> 13 </Animated.View> 14 ), 15 [tabBarStyle, headerHeight, rendered] 16 ); 17 18...
At this stage, we can finally animate the header.
1... 2 3const сurrentScrollValue = useDerivedValue( 4 () => 5 tabIndex === 0 ? friendsScrollValue.value : suggestionsScrollValue.value, 6 [tabIndex] 7 ); 8 9 const translateY = useDerivedValue( 10 () => -Math.min(сurrentScrollValue.value, headerHeightDiff) 11 ); 12 13 const tabBarAnimatedStyle = useAnimatedStyle(() => ({ 14 transform: [{ translateY: translateY.value }], 15 })); 16 17 const headerAnimatedStyle = useAnimatedStyle(() => ({ 18 transform: [{ translateY: translateY.value }], 19 })); 20 21...
As you can see, collapsing is working, but it’s not exactly what we wanted — an excess part of the header remains displayed and cropped. Our goal here is to display only a profile name when the header is collapsed.
1... 2 3const headerAnimatedStyle = useAnimatedStyle(() => ({ 4 transform: [{ translateY: translateY.value }], 5 opacity: interpolate( 6 translateY.value, 7 [-headerDiff, 0], 8 [Visibility.Hidden, Visibility.Visible] 9 ), 10 })); 11 12... 13 14const collapsedOverlayAnimatedStyle = useAnimatedStyle(() => ({ 15 opacity: interpolate( 16 translateY.value, 17 [-headerDiff, OVERLAY_VISIBILITY_OFFSET - headerDiff, 0], 18 [Visibility.Visible, Visibility.Hidden, Visibility.Hidden] 19 ), 20 })); 21 22 const collapsedOverlayStyle = useMemo<StyleProp<ViewStyle>>( 23 () => [ 24 styles.collapsedOverlay, 25 collapsedOverlayAnimatedStyle, 26 { height: heightCollapsed, paddingTop: top }, 27 ], 28 [collapsedOverlayAnimatedStyle, heightCollapsed] 29 ); 30 31... 32 33return ( 34 <View style={styles.container}> 35 <Tab.Navigator tabBar={renderTabBar}> 36 <Tab.Screen name="Friends">{renderFriends}</Tab.Screen> 37 <Tab.Screen name="Suggestions">{renderSuggestions}</Tab.Screen> 38 </Tab.Navigator> 39 <Animated.View onLayout={handleHeaderLayout} style={headerContainerStyle}> 40 <Header 41 name="Emily Davis" 42 bio="Let's get started 🚀" 43 photo={"https://picsum.photos/id/1027/300/300"} 44 /> 45 </Animated.View> 46 <Animated.View style={collapsedOverlayStyle}> 47 <HeaderOverlay name="Emily Davis" /> 48 </Animated.View> 49 </View> 50 ); 51}; 52 53const styles = StyleSheet.create({ 54 ... 55 56 collapsedOverlay: { 57 position: "absolute", 58 top: 0, 59 left: 0, 60 right: 0, 61 backgroundColor: "white", 62 alignItems: "center", 63 justifyContent: "center", 64 zIndex: 2, 65 } 66 ... 67 }); 68 69...
Looks much better!
Here is the detailed overview of the dimensions used to create the collapsing animation.
Collapsing tab header dimensions (image by Stormotion)
Scroll syncing
This example already looks pretty good, but it isn't completely ready yet since we wanted to have more than 1 list. In this case, we have to synchronize the header position between the two tabs. Otherwise, the header will jump while switching the tab.
The idea is to manually set a correct offset to all the lists that are not displayed to sync them with the currently displayed tab.
We’ll need to create some entity which we will use to store the information about each list reference and its current position. Let’s name it ScrollPair:
1... 2 3export type ScrollPair = { 4 list: RefObject<FlatList>; 5 position: Animated.SharedValue<number>; 6}; 7 8... 9 10 const frindsRef = useRef<FlatList>(null); 11 const suggestionsRef = useRef<FlatList>(null); 12 13 ... 14 15 const scrollPairs = useMemo<ScrollPair[]>( 16 () => [ 17 { list: friendsRef, position: friendsScrollValue }, 18 { list: suggestionsRef, position: suggestionsScrollValue }, 19 ], 20 [friendsRef, friendsScrollValue, suggestionsRef, suggestionsScrollValue] 21 ); 22 23...
Here is where previously implemented ref forwarding comes in handy. This list of the so-called scroll pairs will help us to go through all the lists and sync the header position.
In fact, this concise hook is the key solution of the issue! In the sync function, we are iterating over the list of all scroll pairs and adjusting their scroll offset depending on the offset of the current list.
1... 2 3const useScrollSync = ( 4 scrollPairs: ScrollPair[], 5 headerConfig: HeaderConfig 6) => { 7 const sync: NonNullable<FlatListProps<any>["onMomentumScrollEnd"]> = ( 8 event 9 ) => { 10 const { y } = event.nativeEvent.contentOffset; 11 12 const { heightCollapsed, heightExpanded } = headerConfig; 13 14 const headerHeightDiff = heightExpanded - heightCollapsed; 15 16 for (const { list, position } of scrollPairs) { 17 const scrollPosition = position.value ?? 0; 18 19 if (scrollPosition > headerHeightDiff && y > headerHeightDiff) { 20 continue; 21 } 22 23 list.current?.scrollToOffset({ 24 offset: Math.min(y, headerDiff), 25 animated: false, 26 }); 27 } 28 }; 29 30 return { sync }; 31}; 32 33... 34
When applying the hook to the list, we’d recommend using both onMomentumScrollEnd and onScrollEndDrag callbacks to cover all possible scrolling cases.
1... 2 3 const { sync } = useScrollSync(scrollPairs, headerConfig); 4 5 const sharedProps = useMemo<Partial<FlatListProps<Connection>>>( 6 () => ({ 7 contentContainerStyle, 8 onMomentumScrollEnd: sync, 9 onScrollEndDrag: sync, 10 scrollEventThrottle: 16, 11 scrollIndicatorInsets: { top: heightExpanded }, 12 }), 13 [contentContainerStyle, sync] 14 ); 15 16... 17
The last but not the least. Some lists, like our “Friends” list are too short to perform long enough scroll in order to collapse the header. Therefore, we need to add a satisfying minHeight to the contentContainerStyle prop.
1... 2 3 const { height: screenHeight } = useWindowDimensions(); 4 5 ... 6 7 const contentContainerStyle = useMemo<StyleProp<ViewStyle>>( 8 () => ({ 9 paddingTop: rendered ? headerHeight + TAB_BAR_HEIGHT : 0, 10 paddingBottom: bottom, 11 minHeight: screenHeight + headerDiff, 12 }), 13 [rendered, headerHeight, bottom] 14 ); 15 16... 17
That’s it, the screen is ready! We hope that this step-by-step guide was helpful for you!
You can find the complete source code in our GitHub repository.
No comments:
Post a Comment