diff --git a/README.md b/README.md index 0dc4c3d..7f115b8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ - [Tabs.Container](#tabscontainer) - [Tabs.Lazy](#tabslazy) - [Tabs.FlatList](#tabsflatlist) - - [Tabs.FlashList](#tabsflashlist) + - [Tabs.FlashList](#tabsflatlist) + - [Tabs.MasonryFlashList](#tabsmasonryflatlist) - [Tabs.SectionList](#tabssectionlist) - [Tabs.ScrollView](#tabsscrollview) - [Ref](#ref) @@ -242,6 +243,10 @@ Use like a regular FlatList. Use like a regular FlashList. +### Tabs.MasonryFlashList + +Use like a regular MasonryFlashList. + ### Tabs.ScrollView Use like a regular ScrollView. diff --git a/documentation/README_TEMPLATE.md b/documentation/README_TEMPLATE.md index e7407a4..25e0ede 100644 --- a/documentation/README_TEMPLATE.md +++ b/documentation/README_TEMPLATE.md @@ -17,6 +17,7 @@ - [Tabs.Container](#tabscontainer) - [Tabs.Lazy](#tabslazy) - [Tabs.FlatList](#tabsflatlist) + - [Tabs.MasonryFlashList](#tabsmasonryflatlist) - [Tabs.FlashList](#tabsflatlist) - [Tabs.SectionList](#tabssectionlist) - [Tabs.ScrollView](#tabsscrollview) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index cd0ea24..e042eab 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -339,7 +339,7 @@ PODS: - React-Core - RNGestureHandler (2.9.0): - React-Core - - RNReanimated (2.14.4): + - RNReanimated (3.4.1): - DoubleConversion - FBLazyVector - FBReactNativeSpec @@ -411,7 +411,7 @@ DEPENDENCIES: - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - - "RNFlashList (from `../../node_modules/@shopify/flash-list`)" + - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -504,7 +504,7 @@ EXTERNAL SOURCES: ReactCommon: :path: "../node_modules/react-native/ReactCommon" RNFlashList: - :path: "../../node_modules/@shopify/flash-list" + :path: "../node_modules/@shopify/flash-list" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNReanimated: @@ -557,7 +557,7 @@ SPEC CHECKSUMS: ReactCommon: e1067159764444e5db7c14e294d5cd79fb159c59 RNFlashList: 7fbca4fc075484a9426f1610d648dbea2de94eb0 RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39 - RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128 + RNReanimated: f13888574906e326894b4978b8c670c3a4063aec Yoga: ba09b6b11e6139e3df8229238aa794205ca6a02a PODFILE CHECKSUM: 4a1d4df08804ad081db13b6bea02bebbb098497a diff --git a/example/package.json b/example/package.json index b5ab85c..d98ebef 100644 --- a/example/package.json +++ b/example/package.json @@ -14,7 +14,7 @@ "react-native": "0.71.6", "react-native-gesture-handler": "~2.9.0", "react-native-pager-view": "6.2.0", - "react-native-reanimated": "~2.14.4", + "react-native-reanimated": "^3.4.1", "react-native-web": "~0.18.11", "use-debounce": "^5.2.0", "use-deep-compare": "^1.1.0" diff --git a/example/src/App.tsx b/example/src/App.tsx index 1240ae0..87b5516 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -23,6 +23,7 @@ import FlashList from './FlashList' import HeaderOverscrollExample from './HeaderOverscroll' import Lazy from './Lazy' import LazyNoFade from './LazyNoFade' +import MasonryFlashList from './MasonryFlashList' import MinHeaderHeight from './MinHeaderHeight' import OnTabChange from './OnTabChange' import QuickStartDemo from './QuickStartDemo' @@ -45,6 +46,7 @@ const EXAMPLE_COMPONENTS: ExampleComponentType[] = [ RevealHeaderOnScrollSnap, Lazy, LazyNoFade, + MasonryFlashList, ScrollableTabs, CenteredEmptyList, ScrollOnHeader, diff --git a/example/src/MasonryFlashList.tsx b/example/src/MasonryFlashList.tsx new file mode 100644 index 0000000..40fc910 --- /dev/null +++ b/example/src/MasonryFlashList.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { StyleSheet, View } from 'react-native' +import { useHeaderMeasurements } from 'react-native-collapsible-tab-view' +import Animated, { + interpolate, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated' + +import { useCurrentTabScrollY } from '../../src/hooks' +import ExampleComponent from './Shared/ExampleComponentMasonryFlashList' +import ReText from './Shared/ReText' +import { ExampleComponentType } from './types' + +const title = 'MasonryFlashList' + +const MIN_HEADER_HEIGHT = 48 + +export const Header = () => { + const { top, height } = useHeaderMeasurements() + const scrollY = useCurrentTabScrollY() + + const scrollYText = useDerivedValue( + () => `Scroll Y is: ${scrollY.value.toFixed(2)}` + ) + + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + top.value, + [0, -(height.value || 0 - MIN_HEADER_HEIGHT)], + [0, (height.value || 0 - MIN_HEADER_HEIGHT) / 2] + ), + }, + ], + } + }) + + return ( + + + + + + ) +} + +const Example: ExampleComponentType = () => { + return ( +
} + minHeaderHeight={MIN_HEADER_HEIGHT} + /> + ) +} + +const styles = StyleSheet.create({ + root: { + backgroundColor: '#2196f3', + justifyContent: 'center', + alignItems: 'center', + padding: 16, + height: 250, + }, + container: { + height: MIN_HEADER_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + width: '100%', + }, + text: { + position: 'absolute', + color: 'white', + fontSize: 24, + textAlign: 'center', + }, +}) + +Example.title = title + +export default Example diff --git a/example/src/Shared/ExampleComponentMasonryFlashList.tsx b/example/src/Shared/ExampleComponentMasonryFlashList.tsx new file mode 100644 index 0000000..f15eb76 --- /dev/null +++ b/example/src/Shared/ExampleComponentMasonryFlashList.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { + Tabs, + CollapsibleRef, + CollapsibleProps, +} from 'react-native-collapsible-tab-view' + +import Albums from './Albums' +import Article from './Article' +import ContactsFlashList from './ContactsFlashList' +import ExampleMasonry from './ExampleMasonry' +import { HEADER_HEIGHT } from './Header' + +type Props = { + emptyContacts?: boolean + hideArticleTab?: boolean +} & Partial + +const Example = React.forwardRef( + ({ emptyContacts, ...props }, ref) => { + return ( + + {props.hideArticleTab ? ( + +
+ + ) : null} + + + + + {/* + TODO: masonry has the same issue unfortunatelly when not having a lot of elements + // see: https://github.com/PedroBern/react-native-collapsible-tab-view/issues/335 + + */} + + + + + + + ) + } +) + +export default Example diff --git a/example/src/Shared/ExampleMasonry.tsx b/example/src/Shared/ExampleMasonry.tsx new file mode 100644 index 0000000..0be3ddd --- /dev/null +++ b/example/src/Shared/ExampleMasonry.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' +import { + Alert, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import * as Tabs from 'react-native-collapsible-tab-view' +import Animated, { + interpolate, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated' + +import { useRefresh } from './useRefresh' + +type Item = { id: string; aspectRatio: number; color: string } + +export function getItems(count = 20) { + return Array.from({ length: count }, (v, k) => { + const r = Math.random() + 0.5 + const t = Date.now() + return { + id: `${t}-${k}-${r}`, + aspectRatio: r, + color: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)})`, + } + }) +} + +export async function asyncGetItems(count = 20, delay = 1000) { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, delay) + }) + + return getItems(count) +} + +interface MasonryItemProps { + item: Item + index: number +} + +const MasonryItem: React.FC = ({ item, index }) => { + return ( + Alert.alert(item.id)} + > + + {index} - {item.aspectRatio} + + + ) +} + +const ItemSeparator = () => + +const ListEmptyComponent = () => { + const { top, height } = Tabs.useHeaderMeasurements() + const translateY = useDerivedValue(() => { + return interpolate( + -top.value, + [0, height.value || 0], + [-(height.value || 0) / 2, 0] + ) + }, [height]) + + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: translateY.value, + }, + ], + } + }) + + return ( + + Centered Empty List! + + ) +} + +const ExampleMasonry: React.FC<{ + emptyList?: boolean + nestedScrollEnabled?: boolean + limit?: number +}> = ({ emptyList, nestedScrollEnabled, limit }) => { + const [isRefreshing, startRefreshing] = useRefresh() + const [refreshing, setRefreshing] = React.useState(false) + const [data, setData] = React.useState([]) + + const refresh = React.useCallback(async () => { + if (refreshing) { + return + } + setRefreshing(true) + const res = await asyncGetItems() + setData(res) + setRefreshing(false) + }, [refreshing]) + + React.useEffect(() => { + refresh() + }, []) + + return ( + String(i)} + renderItem={MasonryItem} + ItemSeparatorComponent={ItemSeparator} + ListEmptyComponent={ListEmptyComponent} + // see https://github.com/software-mansion/react-native-reanimated/issues/1703 + onRefresh={Platform.OS === 'ios' ? startRefreshing : undefined} + refreshing={Platform.OS === 'ios' ? isRefreshing : undefined} + nestedScrollEnabled={nestedScrollEnabled} + /> + ) +} + +export default ExampleMasonry + +const styles = StyleSheet.create({ + item: { + borderRadius: 10, + }, + listEmpty: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + borderColor: 'black', + borderWidth: 10, + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: 'rgba(0, 0, 0, .08)', + }, +}) diff --git a/example/yarn.lock b/example/yarn.lock index 0388beb..0906933 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -2746,6 +2746,11 @@ convert-source-map@^1.7.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -4343,11 +4348,6 @@ lodash.debounce@4.0.8, lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== - lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" @@ -5567,18 +5567,15 @@ react-native-pager-view@6.2.0: resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.0.tgz#51380d93fbe47f6380dc71d613a787bf27a4ca37" integrity sha512-pf9OnL/Tkr+5s4Gjmsn7xh91PtJLDa6qxYa/bmtUhd/+s4cQdWQ8DIFoOFghwZIHHHwVdWtoXkp6HtpjN+r20g== -react-native-reanimated@~2.14.4: - version "2.14.4" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.14.4.tgz#3fa3da4e7b99f5dfb28f86bcf24d9d1024d38836" - integrity sha512-DquSbl7P8j4SAmc+kRdd75Ianm8G+IYQ9T4AQ6lrpLVeDkhZmjWI0wkutKWnp6L7c5XNVUrFDUf69dwETLCItQ== +react-native-reanimated@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.4.1.tgz#3518f4fbe716423a4361dfd4a2276c9f9c832a05" + integrity sha512-UjEBbWloCoUB/fkehj9GWAhuvfUx2BLYkQ3rP7+nVMJ5K5Ck1K6XFEBGfYdz4ttCBoyTSuzrPxe2XxJl3yRSdA== dependencies: "@babel/plugin-transform-object-assign" "^7.16.7" "@babel/preset-typescript" "^7.16.7" - convert-source-map "^1.7.0" + convert-source-map "^2.0.0" invariant "^2.2.4" - lodash.isequal "^4.5.0" - setimmediate "^1.0.5" - string-hash-64 "^1.0.3" react-native-web@~0.18.11: version "0.18.12" @@ -6250,11 +6247,6 @@ stream-buffers@2.2.x: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== -string-hash-64@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string-hash-64/-/string-hash-64-1.0.3.tgz#0deb56df58678640db5c479ccbbb597aaa0de322" - integrity sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw== - string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" diff --git a/package.json b/package.json index 4cedecb..81ce77b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@commitlint/config-conventional": "^9.1.1", "@microsoft/tsdoc": "^0.13.0", "@release-it/conventional-changelog": "^1.1.4", - "@shopify/flash-list": "^1.4.2", + "@shopify/flash-list": "^1.5.0", "@types/react": "~18.0.27", "@types/react-native": "0.71.6", "babel-jest": "^26.2.2", diff --git a/src/MasonryFlashList.tsx b/src/MasonryFlashList.tsx new file mode 100644 index 0000000..e98bc70 --- /dev/null +++ b/src/MasonryFlashList.tsx @@ -0,0 +1,152 @@ +import { + MasonryFlashListProps, + MasonryFlashList as SPMasonryFlashList, +} from '@shopify/flash-list' +import React from 'react' +import Animated from 'react-native-reanimated' + +import { + useAfterMountEffect, + useChainCallback, + useCollapsibleStyle, + useConvertAnimatedToValue, + useScrollHandlerY, + useSharedAnimatedRef, + useTabNameContext, + useTabsContext, + useUpdateScrollViewContentSize, +} from './hooks' + +/** + * Used as a memo to prevent rerendering too often when the context changes. + * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 + */ + +type MasonryFlashListMemoProps = React.PropsWithChildren< + MasonryFlashListProps +> +type MasonryFlashListMemoRef = typeof SPMasonryFlashList + +const MasonryFlashListMemo = React.memo( + React.forwardRef( + (props, passRef) => { + // Load FlashList dynamically or print a friendly error message + try { + const flashListModule = require('@shopify/flash-list') + const AnimatedMasonryFlashList = (Animated.createAnimatedComponent( + flashListModule.MasonryFlashList + ) as unknown) as React.ComponentClass> + // @ts-expect-error + return + } catch (error) { + console.error( + 'The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.' + ) + return <> + } + } + ) +) + +function MasonryFlashListImpl( + { + style, + onContentSizeChange, + contentContainerStyle: _contentContainerStyle, + refreshControl, + ...rest + }: Omit, 'onScroll'>, + passRef: React.Ref +) { + const name = useTabNameContext() + const { setRef, contentInset } = useTabsContext() + const ref = useSharedAnimatedRef(passRef) + + const { scrollHandler, enable } = useScrollHandlerY(name) + + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet' + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true) + }) + + const { progressViewOffset, contentContainerStyle } = useCollapsibleStyle() + + React.useEffect(() => { + setRef(name, ref) + }, [name, ref, setRef]) + + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }) + + const scrollContentSizeChangeHandlers = useChainCallback( + React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ]) + ) + + const memoRefreshControl = React.useMemo( + () => + refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), + [progressViewOffset, refreshControl] + ) + + const contentInsetValue = useConvertAnimatedToValue(contentInset) + + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]) + + const memoContentOffset = React.useMemo( + () => ({ x: 0, y: -contentInsetValue }), + [contentInsetValue] + ) + + const memoContentContainerStyle = React.useMemo( + () => ({ + paddingTop: contentContainerStyle.paddingTop, + ..._contentContainerStyle, + }), + [_contentContainerStyle, contentContainerStyle.paddingTop] + ) + + return ( + // @ts-expect-error typescript complains about `unknown` in the memo, it should be T + { + // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 + // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give + // this ref the access to it + // eslint-ignore + // @ts-expect-error + ;(ref as any)(value?.recyclerlistview_unsafe) + }} + bouncesZoom={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + contentInset={memoContentInset} + contentOffset={memoContentOffset} + refreshControl={memoRefreshControl} + progressViewOffset={progressViewOffset} + automaticallyAdjustContentInsets={false} + onContentSizeChange={scrollContentSizeChangeHandlers} + /> + ) +} + +/** + * Use like a regular MasonryFlashList. + */ +export const MasonryFlashList = React.forwardRef(MasonryFlashListImpl) as ( + p: MasonryFlashListProps & { ref?: React.Ref } +) => React.ReactElement diff --git a/src/index.tsx b/src/index.tsx index 019eb4b..3d0be9d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import { Container } from './Container' import { FlashList } from './FlashList' import { FlatList } from './FlatList' import { Lazy } from './Lazy' +import { MasonryFlashList } from './MasonryFlashList' import { MaterialTabBarProps, MaterialTabItemProps } from './MaterialTabBar' import { ScrollView } from './ScrollView' import { SectionList } from './SectionList' @@ -38,9 +39,19 @@ export const Tabs = { ScrollView, SectionList, FlashList, + MasonryFlashList, } -export { Container, Tab, Lazy, FlatList, ScrollView, SectionList, FlashList } +export { + Container, + Tab, + Lazy, + FlatList, + ScrollView, + SectionList, + FlashList, + MasonryFlashList, +} export { useCurrentTabScrollY, useHeaderMeasurements, diff --git a/yarn.lock b/yarn.lock index b2f6fe0..03e3e15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,10 +2914,10 @@ prepend-file "^1.3.1" release-it "^13.5.6" -"@shopify/flash-list@^1.4.2": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.4.2.tgz#86c188f42fcb4bb2a569c0354978e9148a83d1f3" - integrity sha512-MX3vyiHdyCoveqrv+0LufQVlLpoWMZ/bpn+4v6RKfW6ZE0+z8S7WdZTU5Gdj7IFPlkulJAtdFn4Jl0V7tDvd6A== +"@shopify/flash-list@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.5.0.tgz#f1cf3c4f9bb706a58c3d1f794ddfaedb5872f431" + integrity sha512-XeocevDIXastr6jh3TPo1MzV5XkdqTyWtw/j8kUhz9EOBc2SzNWbpJWyzrAsYKlqYNrnxxs0P9C0amlX2jaQnw== dependencies: recyclerlistview "4.2.0" tslib "2.4.0"