Created
July 10, 2024 18:48
-
-
Save AndreiCalazans/d78cb3514c5abd43a88883f73f9abb9f to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
ComponentProps, | |
createContext, | |
forwardRef, | |
memo, | |
ReactNode, | |
useCallback, | |
useContext, | |
useImperativeHandle, | |
useMemo, | |
useRef, | |
} from 'react'; | |
import { Platform, StyleSheet, View } from 'react-native'; | |
import Animated, { | |
AnimatedRef, | |
measure, | |
scrollTo, | |
SharedValue, | |
useAnimatedRef, | |
useAnimatedScrollHandler, | |
useAnimatedStyle, | |
useDerivedValue, | |
useSharedValue, | |
} from 'react-native-reanimated'; | |
import { useDimensions } from '@cbhq/cds-mobile/hooks/useDimensions'; | |
import { List, ListRef } from '@app/components/List'; | |
import { TabView } from '@app/components/TabView'; | |
const styles = StyleSheet.create({ | |
flexOne: { flex: 1 }, | |
}); | |
const stickyHeaderIndices = [1]; | |
type TabViewProps = ComponentProps<typeof TabView>; | |
type RenderTabBar = (tabBarProps: { | |
onPress: TabViewProps['onIndexChange']; | |
navigationState: { | |
index: number; | |
routes: TabViewProps['routes']; | |
}; | |
}) => ReactNode; | |
type CollapsibleTabViewProps = TabViewProps & { | |
renderTabBar: RenderTabBar; | |
renderHeader: () => JSX.Element; | |
renderScene: ({ route }: { route: TabViewProps['routes'][0] }) => ReactNode; | |
initialTabKey?: string; | |
}; | |
type TabBarWrapperProps = { | |
renderTabBar: RenderTabBar; | |
onPress: (name: number) => void; | |
routes: TabViewProps['routes']; | |
index: number; | |
}; | |
type CollapsibleTabViewListContextData = { | |
index: number; | |
spacerHeight: SharedValue<Record<string, number>>; | |
animatedSpacerStyle: { height: number }; | |
headerRef: AnimatedRef<View>; | |
parentScrollRef: AnimatedRef<Animated.ScrollView>; | |
setRef: (indexToUse: number) => (ref: AnimatedRef<ListRef>) => void; | |
} | null; | |
const CollapsibleTabViewListContext = | |
createContext<CollapsibleTabViewListContextData>(null); | |
const TabBarWrapper = memo(function TabBarWrapper({ | |
renderTabBar, | |
routes, | |
onPress, | |
index, | |
}: TabBarWrapperProps) { | |
return renderTabBar({ | |
onPress, | |
navigationState: { | |
index, | |
routes, | |
}, | |
}); | |
}); | |
type CollapsibleTabViewListProps = { | |
routeIndex: string; | |
} & ComponentProps<typeof List>; | |
export const CollapsibleTabViewList = forwardRef( | |
({ children, routeIndex, ...props }: CollapsibleTabViewListProps, ref) => { | |
const contextData = useContext(CollapsibleTabViewListContext); | |
if (!contextData) { | |
// Should only ever happen during development. | |
throw new Error( | |
'CollapsibleTabViewList must be used within a CollapsibleTabView', | |
); | |
} | |
const { | |
index, | |
headerRef, | |
spacerHeight, | |
parentScrollRef, | |
setRef, | |
animatedSpacerStyle, | |
} = contextData; | |
const { screenHeight } = useDimensions(); | |
const nestedScrollStyle = useMemo( | |
() => ({ height: screenHeight }), | |
[screenHeight], | |
); | |
const listRef = useAnimatedRef(); | |
useImperativeHandle(ref, () => listRef); | |
const scrollHandlerTwo = useAnimatedScrollHandler( | |
(event) => { | |
const contentHeight = event.contentSize.height; | |
const containerHeight = event.layoutMeasurement.height; | |
const offset = event.contentOffset.y; | |
const headerHeight = measure(headerRef)?.height ?? 0; | |
if (contentHeight < containerHeight) { | |
/* | |
* Careful, don't remove the index proxy here because the worklet | |
* transpiler seems to not spot the index being used as a key inside | |
* the object below and makes the index undefined. | |
* */ | |
const indexToUpdate = index; | |
spacerHeight.value = { | |
...spacerHeight.value, | |
[indexToUpdate]: headerHeight, | |
}; | |
} | |
scrollTo(parentScrollRef, 0, offset, false); | |
/* | |
* On Android the scrollTo on the outerScroll can cause jitteriness | |
* while it is happening in parallel to the inner scrolling. To avoid | |
* this we reduce the offset by a factor of 0.6 making it imperceptible. | |
* | |
* */ | |
const offsetThrottle = Platform.OS === 'android' ? 0.6 : 1; | |
scrollTo(parentScrollRef, 0, offset * offsetThrottle, false); | |
}, | |
[index], | |
); | |
return ( | |
<List | |
scrollComponent="ReanimatedFlashList" | |
// @ts-expect-error - issue with null | |
ref={setRef(Number(routeIndex))} | |
scrollEventThrottle={7} | |
showsVerticalScrollIndicator={false} | |
nestedScrollEnabled | |
onScroll={scrollHandlerTwo} | |
style={nestedScrollStyle} | |
collapsable={false} | |
{...props} | |
> | |
{children} | |
<Animated.View style={animatedSpacerStyle} /> | |
</List> | |
); | |
}, | |
); | |
/** | |
* CollapsibleTabView extends the TabView component to support full screen mode | |
* where content in the list is properly virtualized and the header content is | |
* scrollable plus collapsible. | |
* | |
* Use it like a TabView component with Screen set to scrollable false. | |
* | |
* <CollapsibleTabView | |
* index={tab} | |
* routes={routes} | |
* renderHeader={renderHeader} | |
* renderTabBar={renderTabBar} | |
* renderScene={renderContent} | |
* onIndexChange={setTab} | |
* /> | |
*/ | |
export function CollapsibleTabView({ | |
renderHeader, | |
renderTabBar, | |
renderScene, | |
onIndexChange, | |
index, | |
routes, | |
}: CollapsibleTabViewProps) { | |
const headerRef = useAnimatedRef<View>(); | |
/* | |
* spacerHeight adds a padding at the bottom of the list when the list is not | |
* long enough to fill the screen, this is required for the header to collapse. | |
* */ | |
const spacerHeight = useSharedValue<Record<string, number>>({}); | |
const parentScrollRef = useAnimatedRef<Animated.ScrollView>(); | |
/* | |
* tabViewChildScrollRefs is an array of refs to the lists rendered | |
* by the TabView. | |
* */ | |
const tabViewChildScrollRefs = useRef<AnimatedRef<ListRef>[]>([]); | |
const parentScrollHandler = useAnimatedScrollHandler((event) => { | |
const headerLayout = measure(headerRef); | |
if (!headerLayout) { | |
return; | |
} | |
if ( | |
headerLayout.height < event.contentOffset.y && | |
tabViewChildScrollRefs.current?.[index] | |
) { | |
scrollTo( | |
tabViewChildScrollRefs.current?.[index], | |
0, | |
event.contentOffset.y, | |
false, | |
); | |
} | |
}); | |
/* | |
* We must map index to a SharedValue so the worklet functions have access to | |
* its latest values else it can reference an outdated closure. | |
* */ | |
const indexSharedValue = useDerivedValue(() => index); | |
const setRef = useCallback( | |
(indexToUse: number) => (ref: AnimatedRef<ListRef>) => { | |
if (ref && ref?.current) { | |
if (!tabViewChildScrollRefs.current) { | |
tabViewChildScrollRefs.current = []; | |
} | |
tabViewChildScrollRefs.current[indexToUse] = ref; | |
} | |
}, | |
[], | |
); | |
const animatedSpacerStyle = useAnimatedStyle(() => { | |
// eslint-disable-next-line no-underscore-dangle | |
if (global._WORKLET) { | |
return { height: spacerHeight.value[indexSharedValue.value] ?? 1 }; | |
} | |
return { height: 0 }; | |
}); | |
const listContext = useMemo( | |
() => ({ | |
index, | |
spacerHeight, | |
headerRef, | |
parentScrollRef, | |
setRef, | |
animatedSpacerStyle, | |
}), | |
[ | |
index, | |
spacerHeight, | |
headerRef, | |
parentScrollRef, | |
setRef, | |
animatedSpacerStyle, | |
], | |
); | |
const header = useMemo(() => renderHeader(), [renderHeader]); | |
return ( | |
<View collapsable={false} style={styles.flexOne}> | |
<Animated.ScrollView | |
ref={parentScrollRef} | |
onScroll={parentScrollHandler} | |
scrollEventThrottle={7} | |
scrollEnabled | |
nestedScrollEnabled | |
stickyHeaderIndices={stickyHeaderIndices} | |
showsVerticalScrollIndicator={false} | |
collapsable={false} | |
> | |
<View collapsable={false} ref={headerRef}> | |
{header} | |
</View> | |
<TabBarWrapper | |
index={index} | |
onPress={onIndexChange} | |
renderTabBar={renderTabBar} | |
routes={routes} | |
/> | |
<CollapsibleTabViewListContext.Provider value={listContext}> | |
<TabView | |
index={index} | |
routes={routes} | |
onIndexChange={onIndexChange} | |
renderScene={renderScene} | |
/> | |
</CollapsibleTabViewListContext.Provider> | |
</Animated.ScrollView> | |
</View> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment