diff --git a/docs/docs/01-getting-started.mdx b/docs/docs/01-getting-started.mdx index e8b4cb9..8fb8815 100644 --- a/docs/docs/01-getting-started.mdx +++ b/docs/docs/01-getting-started.mdx @@ -32,7 +32,7 @@ Before you can use `react-native-header`, you need to have the following librari If you haven't installed these libraries yet, please follow the installation instructions on their respective documentation pages. -If you intend to use the [FlashListWithHeaders](/docs/components/flash-list-with-headers) component, please ensure that you review the [Compatibilty table](/docs/getting-started#compatibility) above and install the correct versions of each library. +If you intend to use the [FlashListWithHeaders](/docs/components/flash-list-with-headers) or [MasonryFlashListWithHeaders](/docs/components/masonry-flash-list-with-headers) component, please ensure that you review the [Compatibilty table](/docs/getting-started#compatibility) above and install the correct versions of each library. ## Installation diff --git a/docs/docs/03-api-reference/05-masonry-flash-list-with-headers.mdx b/docs/docs/03-api-reference/05-masonry-flash-list-with-headers.mdx new file mode 100644 index 0000000..cc3a195 --- /dev/null +++ b/docs/docs/03-api-reference/05-masonry-flash-list-with-headers.mdx @@ -0,0 +1,152 @@ +--- +title: MasonryFlashListWithHeaders +hide_table_of_contents: false +slug: /components/masonry-flash-list-with-headers +description: Shopify's MasonryFlashList paired with React Native Header. +--- + +Component that extends Shopify's [MasonryFlashListFlashList](https://shopify.github.io/flash-list/docs/guides/masonry) to add support for +headers exported from this library. + +The implementation of this component relies on the [HeaderComponent](/docs/components/flash-list-with-headers#headercomponent) +and [LargeHeaderComponent](/docs/components/flash-list-with-headers#largeheadercomponent) props. +The [HeaderComponent](/docs/components/flash-list-with-headers#headercomponent) is rendered above +the MasonryFlashList and the [LargeHeaderComponent](/docs/components/flash-list-with-headers#largeheadercomponent) +is rendered as the `ListHeaderComponent` of the MasonryFlashList. Using these two props will allow for +animations/built-in features in this library to work properly. + +## Note + +This component is only available in react-native-header version >= `0.14.x`. Please review the [Compatibility matrix](/docs/getting-started#compatibility) and ensure +you have the correct dependencies installed in your project before using this component. + +## Props + +This component uses the MasonryFlashList under the hood, which inherits [all of the props +from the MasonryFlashList component](https://shopify.github.io/flash-list/docs/guides/masonry). + +### HeaderComponent + +The component to render above the MasonryFlashList. This accepts a function that returns a React Element +to display as the header. The function will be called with the following arguments: + +- `showNavBar`: An animated value that will be 0 when the header's subcomponents should be hidden + and 1 when they should be shown. This is useful for animating the header's subcomponents. The + [Header](/docs/components/header) component uses this value to animate its left, center, and + right children. + +### LargeHeaderComponent + +An optional component to render as the large header for this component. This accepts a function +that returns a React Element to display as the large header. The function will be called with the +following arguments: + +- `scrollY`: An animated value that keeps track of the current scroll position of the MasonryFlashList. + This prop is useful for creating custom animations on the large header. In our [example](/docs/example), + we use the [ScalingView](/docs/components/scaling-view) component to scale the large header + when the user pulls down on the MasonryFlashList (to mimic native iOS behaviour). +- `showNavBar`: An animated value that keeps track of whether or not the small header is hidden. + This prop is useful if you want to create your own custom animations based on whether or not the + small header is hidden. + +### LargeHeaderSubtitleComponent + +An optional component to render as a subtitle for the large header for this component. This accepts a function +that returns a React Element to display as the large header subtitle. The function will be called with the +following arguments: + +- `scrollY`: An animated value that keeps track of the current scroll position of the MasonryFlashList. + This prop is useful for creating custom animations on the large header. In our [example](/docs/example), + we use the [ScalingView](/docs/components/scaling-view) component to scale the large header + when the user pulls down on the FlatList (to mimic native iOS behaviour). +- `showNavBar`: An animated value that keeps track of whether or not the small header is hidden. + This prop is useful if you want to create your own custom animations based on whether or not the + small header is hidden. + +### ignoreLeftSafeArea + +An optional boolean that determines whether or not to ignore the left safe area. Defaults to +`false`. The safe area adjustments are used to make sure that the scroll container does not +overlap with the notch/headers on different phones - leave this prop as false if you want to +respect those safe areas. + +### ignoreRightSafeArea + +An optional boolean that determines whether or not to ignore the right safe area. Defaults to +`false`. The safe area adjustments are used to make sure that the scroll container does not +overlap with the notch/headers on different phones - leave this prop as `false` if you want to +respect those safe areas. + +### disableAutoFixScroll + +An optional to disable the auto fix scroll mechanism. This is useful if you want to disable the +auto scroll when the large header is partially visible. Defaults to `false`. + +### containerStyle + +An optional style object that will be applied to the parent container of the scroll container. + +### largeHeaderContainerStyle + +An optional style object that will be applied to the large header container. + +### largeHeaderShown + +An optional animated [Shared Value](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/shared-values/) +that will be mutated by the library when the large header is shown or hidden. This is useful if you +would like to track when the large header is shown or hidden. + +### onLargeHeaderLayout + +An optional callback that will be called when the large header is laid out. This is useful if you +want to access the layout of the large header to calculate the height of the large header. + +### absoluteHeader + +This property controls whether or not the header component is absolutely positioned. This is useful +if you want to render a header component that allows for transparency. + +**Note**: This is only available in version >= 0.9.0. + +### initialAbsoluteHeaderHeight + +This property is used when `absoluteHeader` is true. This is the initial height of the +absolute header. Since the header's height is computed on its layout event, this is used +to set the initial height of the header so that it doesn't jump when it is initially rendered. + +**Note**: This is only available in version >= 0.9.0. + +### headerFadeInThreshold + +A number between 0 and 1 representing at what point the header should fade in, +based on the percentage of the LargeHeader's height. For example, if this is set to 0.5, +the header will fade in when the scroll position is at 50% of the LargeHeader's height. + +Defaults to `1`. + +**Note**: This is only available in version >= 0.10.0. + +### disableLargeHeaderFadeAnim + +Whether or not the LargeHeaderComponent should fade in and out. Defaults to `false`. + +**Note**: This is only available in version >= 0.10.0. + +### onScrollWorklet + +A custom worklet that allows custom tracking scroll container's +state (i.e., its scroll contentInset, contentOffset, etc.). Please +ensure that this function is a [worklet](https://docs.swmansion.com/react-native-reanimated/docs/2.x/fundamentals/worklets/). + +Since the library uses the `onScroll` prop to handle animations internally and [reanimated +does not currently allow for multiple onScroll handlers](https://github.com/software-mansion/react-native-reanimated/discussions/1763), +you must use this property to track the state of the scroll container's state. + +An example is shown below: + +```tsx +const scrollHandlerWorklet = (evt: NativeScrollEvent) => { + 'worklet'; + console.log('offset: ', evt.contentOffset); +}; +``` diff --git a/docs/docs/03-api-reference/05-header.mdx b/docs/docs/03-api-reference/06-header.mdx similarity index 100% rename from docs/docs/03-api-reference/05-header.mdx rename to docs/docs/03-api-reference/06-header.mdx diff --git a/docs/docs/03-api-reference/06-large-header.mdx b/docs/docs/03-api-reference/07-large-header.mdx similarity index 100% rename from docs/docs/03-api-reference/06-large-header.mdx rename to docs/docs/03-api-reference/07-large-header.mdx diff --git a/docs/docs/03-api-reference/07-fading-view.mdx b/docs/docs/03-api-reference/08-fading-view.mdx similarity index 100% rename from docs/docs/03-api-reference/07-fading-view.mdx rename to docs/docs/03-api-reference/08-fading-view.mdx diff --git a/docs/docs/03-api-reference/08-scaling-view.mdx b/docs/docs/03-api-reference/09-scaling-view.mdx similarity index 100% rename from docs/docs/03-api-reference/08-scaling-view.mdx rename to docs/docs/03-api-reference/09-scaling-view.mdx diff --git a/example/src/navigation/AppNavigation.tsx b/example/src/navigation/AppNavigation.tsx index 5de322f..9102025 100644 --- a/example/src/navigation/AppNavigation.tsx +++ b/example/src/navigation/AppNavigation.tsx @@ -3,6 +3,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import type { RootStackParamList } from './types'; import { FlashListUsageScreen, + MasonryFlashListUsageScreen, FlatListUsageScreen, HomeScreen, ProfileScreen, @@ -29,6 +30,7 @@ export default () => ( + ; +export type MasonryFlashListUsageScreenNavigationProps = NativeStackScreenProps< + RootStackParamList, + 'MasonryFlashListUsageScreen' +>; + export type SectionListUsageScreenNavigationProps = NativeStackScreenProps< RootStackParamList, 'SectionListUsageScreen' diff --git a/example/src/screens/Home.tsx b/example/src/screens/Home.tsx index a678db7..0734933 100644 --- a/example/src/screens/Home.tsx +++ b/example/src/screens/Home.tsx @@ -33,6 +33,11 @@ const SCREEN_LIST_CONFIG: ScreenConfigItem[] = [ route: 'FlashListUsageScreen', description: "A simple example with Shopify's FlashList.", }, + { + name: 'MasonryFlashList Example', + route: 'MasonryFlashListUsageScreen', + description: "A simple example with Shopify's MasonryFlashList.", + }, { name: 'SectionList Example', route: 'SectionListUsageScreen', diff --git a/example/src/screens/index.ts b/example/src/screens/index.ts index 7a6f865..a252906 100644 --- a/example/src/screens/index.ts +++ b/example/src/screens/index.ts @@ -5,6 +5,7 @@ export { default as ProfileScreen } from './Profile'; export { default as SimpleUsageScreen } from './usage/Simple'; export { default as FlatListUsageScreen } from './usage/FlatList'; export { default as FlashListUsageScreen } from './usage/FlashList'; +export { default as MasonryFlashListUsageScreen } from './usage/MasonryFlashList'; export { default as SectionListUsageScreen } from './usage/SectionList'; export { default as InvertedUsageScreen } from './usage/Inverted'; export { default as SurfaceComponentUsageScreen } from './usage/SurfaceComponent'; diff --git a/example/src/screens/usage/MasonryFlashList.tsx b/example/src/screens/usage/MasonryFlashList.tsx new file mode 100644 index 0000000..419b8ea --- /dev/null +++ b/example/src/screens/usage/MasonryFlashList.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { + Header, + LargeHeader, + ScalingView, + ScrollHeaderProps, + ScrollLargeHeaderProps, + MasonryFlashListWithHeaders, +} from '@codeherence/react-native-header'; +import { range } from '../../utils'; +import { Avatar, BackButton } from '../../components'; +import { RANDOM_IMAGE_NUM } from '../../constants'; +import type { MasonryFlashListUsageScreenNavigationProps } from '../../navigation'; +import type { ListRenderItem, MasonryFlashListRef } from '@shopify/flash-list'; +import { Image } from 'expo-image'; + +const { width: dWidth, height: dHeight } = Dimensions.get('window'); + +const HeaderComponent: React.FC = ({ showNavBar }) => { + const navigation = useNavigation(); + const onPressProfile = () => navigation.navigate('Profile'); + + return ( +
+ Header + + } + headerRight={ + <> + + + + + } + headerRightFadesIn + headerLeft={} + /> + ); +}; + +const LargeHeaderComponent: React.FC = ({ scrollY }) => { + const navigation = useNavigation(); + const onPressProfile = () => navigation.navigate('Profile'); + + return ( + + + Large Header + + + + + + ); +}; + +// Used for FlashList optimization +const ITEM_HEIGHT = 200; + +const MasonryFlashListExample: React.FC = () => { + const { bottom } = useSafeAreaInsets(); + const ref = useRef>(null); + + const data = useMemo(() => range({ end: 500 }), []); + + const renderItem: ListRenderItem = useCallback(({ item, index }) => { + const randomHeights = [100, 150, 200, 250]; + const randomHeight = randomHeights[index % randomHeights.length]; + return ( + + + {item} + + ); + }, []); + + return ( + `text-row-${i}`} + /> + ); +}; + +export default MasonryFlashListExample; + +const styles = StyleSheet.create({ + navBarTitle: { fontSize: 16, fontWeight: 'bold' }, + title: { fontSize: 32, fontWeight: 'bold' }, + leftHeader: { gap: 2 }, + item: { minHeight: ITEM_HEIGHT, padding: 16, justifyContent: 'center', alignItems: 'center' }, + image: { width: 150 }, + itemText: { textAlign: 'center' }, +}); diff --git a/src/components/containers/MasonryFlashList.tsx b/src/components/containers/MasonryFlashList.tsx new file mode 100644 index 0000000..b22b1e7 --- /dev/null +++ b/src/components/containers/MasonryFlashList.tsx @@ -0,0 +1,188 @@ +import React, { useImperativeHandle } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Animated, { useAnimatedRef } from 'react-native-reanimated'; +import { MasonryFlashList, MasonryFlashListProps, MasonryFlashListRef } from '@shopify/flash-list'; + +import type { SharedScrollContainerProps } from '.'; +import FadingView from './FadingView'; +import { useScrollContainerLogic } from './useScrollContainerLogic'; + +type AnimatedMasonryFlashListType = React.ComponentProps< + React.ComponentClass>, any> +> & + SharedScrollContainerProps; + +const AnimatedMasonryFlashList = Animated.createAnimatedComponent( + MasonryFlashList +) as unknown as React.ComponentClass>>; + +type MasonryFlashListWithHeadersProps = Omit< + AnimatedMasonryFlashListType, + 'onScroll' +>; + +const MasonryFlashListWithHeadersInputComp = ( + { + largeHeaderShown, + containerStyle, + LargeHeaderSubtitleComponent, + LargeHeaderComponent, + largeHeaderContainerStyle, + HeaderComponent, + onLargeHeaderLayout, + onScrollBeginDrag, + onScrollEndDrag, + onScrollWorklet, + onMomentumScrollBegin, + onMomentumScrollEnd, + ignoreLeftSafeArea, + ignoreRightSafeArea, + disableAutoFixScroll = false, + // We use this to ensure that the onScroll property isn't accidentally used. + // @ts-ignore + onScroll: _unusedOnScroll, + absoluteHeader = false, + initialAbsoluteHeaderHeight = 0, + contentContainerStyle = {}, + automaticallyAdjustsScrollIndicatorInsets, + headerFadeInThreshold = 1, + disableLargeHeaderFadeAnim = false, + scrollIndicatorInsets = {}, + ...rest + }: MasonryFlashListWithHeadersProps, + ref: React.Ref +) => { + if (_unusedOnScroll) { + throw new Error( + "The 'onScroll' property is not supported. Please use onScrollWorklet to track the scroll container's state." + ); + } + + const insets = useSafeAreaInsets(); + const scrollRef = useAnimatedRef(); + useImperativeHandle(ref, () => scrollRef.current); + + const { + scrollY, + showNavBar, + largeHeaderHeight, + largeHeaderOpacity, + scrollHandler, + debouncedFixScroll, + onAbsoluteHeaderLayout, + scrollViewAdjustments, + } = useScrollContainerLogic({ + scrollRef, + largeHeaderShown, + disableAutoFixScroll, + largeHeaderExists: !!LargeHeaderComponent, + absoluteHeader, + initialAbsoluteHeaderHeight, + headerFadeInThreshold, + onScrollWorklet, + }); + + return ( + + {!absoluteHeader && HeaderComponent({ showNavBar, scrollY })} + { + debouncedFixScroll.cancel(); + if (onScrollBeginDrag) onScrollBeginDrag(e); + }} + onScrollEndDrag={(e) => { + debouncedFixScroll(); + if (onScrollEndDrag) onScrollEndDrag(e); + }} + onMomentumScrollBegin={(e) => { + debouncedFixScroll.cancel(); + if (onMomentumScrollBegin) onMomentumScrollBegin(e); + }} + onMomentumScrollEnd={(e) => { + debouncedFixScroll(); + if (onMomentumScrollEnd) onMomentumScrollEnd(e); + }} + contentContainerStyle={{ + // The reason why we do this is because FlashList does not support an array of + // styles (will throw a warning when you supply one). + ...scrollViewAdjustments.contentContainerStyle, + ...contentContainerStyle, + }} + automaticallyAdjustsScrollIndicatorInsets={ + automaticallyAdjustsScrollIndicatorInsets !== undefined + ? automaticallyAdjustsScrollIndicatorInsets + : !absoluteHeader + } + scrollIndicatorInsets={{ + ...scrollViewAdjustments.scrollIndicatorInsets, + ...scrollIndicatorInsets, + }} + ListHeaderComponent={ + <> + {LargeHeaderComponent && ( + { + largeHeaderHeight.value = e.nativeEvent.layout.height; + + if (onLargeHeaderLayout) onLargeHeaderLayout(e.nativeEvent.layout); + }} + > + {!disableLargeHeaderFadeAnim ? ( + + {LargeHeaderComponent({ scrollY, showNavBar })} + + ) : ( + + {LargeHeaderComponent({ scrollY, showNavBar })} + + )} + + )} + {LargeHeaderSubtitleComponent && LargeHeaderSubtitleComponent({ showNavBar, scrollY })} + + } + {...rest} + /> + + {absoluteHeader && ( + + {HeaderComponent({ showNavBar, scrollY })} + + )} + + ); +}; + +// The typecast is needed to make the component generic. +const MasonryFlashListWithHeaders = React.forwardRef(MasonryFlashListWithHeadersInputComp) as < + ItemT = any +>( + props: MasonryFlashListWithHeadersProps & { + ref?: React.RefObject>; + } +) => React.ReactElement; + +export default MasonryFlashListWithHeaders; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + absoluteHeader: { + position: 'absolute', + top: 0, + right: 0, + left: 0, + }, +}); diff --git a/src/components/containers/index.ts b/src/components/containers/index.ts index d2885d7..ad8732e 100644 --- a/src/components/containers/index.ts +++ b/src/components/containers/index.ts @@ -4,6 +4,7 @@ export { default as ScrollViewWithHeaders } from './ScrollView'; export { default as FlatListWithHeaders } from './FlatList'; export { default as SectionListWithHeaders } from './SectionList'; export { default as FlashListWithHeaders } from './FlashList'; +export { default as MasonryFlashListWithHeaders } from './MasonryFlashList'; export type { ScrollHeaderProps, ScrollLargeHeaderProps, diff --git a/src/components/index.ts b/src/components/index.ts index 9b10be5..8893f81 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { FlatListWithHeaders, SectionListWithHeaders, FlashListWithHeaders, + MasonryFlashListWithHeaders, } from './containers'; export type { ScrollHeaderProps, diff --git a/src/index.ts b/src/index.ts index 86769f2..7229d9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { FlatListWithHeaders, SectionListWithHeaders, FlashListWithHeaders, + MasonryFlashListWithHeaders, } from './components'; export type {