Skip to content

Instantly share code, notes, and snippets.

@AndreiCalazans
Created July 10, 2024 18:48
Show Gist options
  • Save AndreiCalazans/d78cb3514c5abd43a88883f73f9abb9f to your computer and use it in GitHub Desktop.
Save AndreiCalazans/d78cb3514c5abd43a88883f73f9abb9f to your computer and use it in GitHub Desktop.
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