diff --git a/example/src/AnimatedHeader.tsx b/example/src/AnimatedHeader.tsx index d1c0de8..82328cd 100644 --- a/example/src/AnimatedHeader.tsx +++ b/example/src/AnimatedHeader.tsx @@ -30,8 +30,8 @@ export const Header = () => { { translateY: interpolate( top.value, - [0, -(height.value || 0 - MIN_HEADER_HEIGHT)], - [0, (height.value || 0 - MIN_HEADER_HEIGHT) / 2] + [0, -(height || 0 - MIN_HEADER_HEIGHT)], + [0, (height || 0 - MIN_HEADER_HEIGHT) / 2] ), }, ], diff --git a/example/src/FlashList.tsx b/example/src/FlashList.tsx index b0b3d4e..f3dafbf 100644 --- a/example/src/FlashList.tsx +++ b/example/src/FlashList.tsx @@ -30,8 +30,8 @@ export const Header = () => { { translateY: interpolate( top.value, - [0, -(height.value || 0 - MIN_HEADER_HEIGHT)], - [0, (height.value || 0 - MIN_HEADER_HEIGHT) / 2] + [0, -(height || 0 - MIN_HEADER_HEIGHT)], + [0, (height || 0 - MIN_HEADER_HEIGHT) / 2] ), }, ], diff --git a/example/src/MasonryFlashList.tsx b/example/src/MasonryFlashList.tsx index ee7bcae..42917b3 100644 --- a/example/src/MasonryFlashList.tsx +++ b/example/src/MasonryFlashList.tsx @@ -30,8 +30,8 @@ export const Header = () => { { translateY: interpolate( top.value, - [0, -(height.value || 0 - MIN_HEADER_HEIGHT)], - [0, (height.value || 0 - MIN_HEADER_HEIGHT) / 2] + [0, -(height || 0 - MIN_HEADER_HEIGHT)], + [0, (height || 0 - MIN_HEADER_HEIGHT) / 2] ), }, ], diff --git a/example/src/Shared/Contacts.tsx b/example/src/Shared/Contacts.tsx index 114188c..850a88e 100644 --- a/example/src/Shared/Contacts.tsx +++ b/example/src/Shared/Contacts.tsx @@ -101,11 +101,7 @@ const renderItem = ({ item }: { item: Item }) => const ListEmptyComponent = () => { const { top, height } = Tabs.useHeaderMeasurements() const translateY = useDerivedValue(() => { - return interpolate( - -top.value, - [0, height.value || 0], - [-(height.value || 0) / 2, 0] - ) + return interpolate(-top.value, [0, height || 0], [-(height || 0) / 2, 0]) }, [height]) const stylez = useAnimatedStyle(() => { diff --git a/example/src/Shared/ContactsFlashList.tsx b/example/src/Shared/ContactsFlashList.tsx index cd0f61d..84f0c66 100644 --- a/example/src/Shared/ContactsFlashList.tsx +++ b/example/src/Shared/ContactsFlashList.tsx @@ -101,11 +101,7 @@ const renderItem = ({ item }: { item: Item }) => const ListEmptyComponent = () => { const { top, height } = Tabs.useHeaderMeasurements() const translateY = useDerivedValue(() => { - return interpolate( - -top.value, - [0, height.value || 0], - [-(height.value || 0) / 2, 0] - ) + return interpolate(-top.value, [0, height || 0], [-(height || 0) / 2, 0]) }, [height]) const stylez = useAnimatedStyle(() => { diff --git a/example/src/Shared/ExampleMasonry.tsx b/example/src/Shared/ExampleMasonry.tsx index df30bb3..1b7e1b3 100644 --- a/example/src/Shared/ExampleMasonry.tsx +++ b/example/src/Shared/ExampleMasonry.tsx @@ -69,11 +69,7 @@ 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] - ) + return interpolate(-top.value, [0, height || 0], [-(height || 0) / 2, 0]) }, [height]) const stylez = useAnimatedStyle(() => { diff --git a/example/src/Shared/SectionContacts.tsx b/example/src/Shared/SectionContacts.tsx index d4a9789..41d4c95 100644 --- a/example/src/Shared/SectionContacts.tsx +++ b/example/src/Shared/SectionContacts.tsx @@ -117,11 +117,7 @@ const renderItem = ({ item }: { item: Item }) => const ListEmptyComponent = () => { const { top, height } = Tabs.useHeaderMeasurements() const translateY = useDerivedValue(() => { - return interpolate( - -top.value, - [0, height.value || 0], - [-(height.value || 0) / 2, 0] - ) + return interpolate(-top.value, [0, height || 0], [-(height || 0) / 2, 0]) }, [height]) const stylez = useAnimatedStyle(() => { diff --git a/src/Container.tsx b/src/Container.tsx index 913baa3..bf9729a 100644 --- a/src/Container.tsx +++ b/src/Container.tsx @@ -1,10 +1,5 @@ import React from 'react' -import { - LayoutChangeEvent, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' +import { StyleSheet, useWindowDimensions, View } from 'react-native' import PagerView from 'react-native-pager-view' import Animated, { runOnJS, @@ -15,6 +10,7 @@ import Animated, { useSharedValue, withDelay, withTiming, + useFrameCallback, } from 'react-native-reanimated' import { Context, TabNameContext } from './Context' @@ -27,6 +23,7 @@ import { useContainerRef, usePageScrollHandler, useTabProps, + useLayoutHeight, } from './hooks' import { CollapsibleProps, @@ -93,25 +90,29 @@ export const Container = React.memo( const windowWidth = useWindowDimensions().width const width = customWidth ?? windowWidth - const containerHeight = useSharedValue(undefined) + const [containerHeight, getContainerLayoutHeight] = useLayoutHeight() - const tabBarHeight = useSharedValue( - initialTabBarHeight - ) + const [tabBarHeight, getTabBarHeight] = + useLayoutHeight(initialTabBarHeight) - const headerHeight = useSharedValue( + const [headerHeight, getHeaderHeight] = useLayoutHeight( !renderHeader ? 0 : initialHeaderHeight ) + const initialIndex = React.useMemo( + () => + initialTabName + ? tabNamesArray.findIndex((n) => n === initialTabName) + : 0, + [initialTabName, tabNamesArray] + ) - const contentInset = useDerivedValue(() => { + const contentInset = React.useMemo(() => { if (allowHeaderOverscroll) return 0 // necessary for the refresh control on iOS to be positioned underneath the header // this also adjusts the scroll bars to clamp underneath the header area - return IS_IOS - ? (headerHeight.value || 0) + (tabBarHeight.value || 0) - : 0 - }) + return IS_IOS ? (headerHeight || 0) + (tabBarHeight || 0) : 0 + }, [headerHeight, tabBarHeight, allowHeaderOverscroll]) const snappingTo: ContextType['snappingTo'] = useSharedValue(0) const offset: ContextType['offset'] = useSharedValue(0) @@ -131,22 +132,16 @@ export const Container = React.memo( () => tabNamesArray, [tabNamesArray] ) - const index: ContextType['index'] = useSharedValue( - initialTabName - ? tabNames.value.findIndex((n) => n === initialTabName) - : 0 - ) + const index: ContextType['index'] = useSharedValue(initialIndex) const focusedTab: ContextType['focusedTab'] = useDerivedValue(() => { return tabNames.value[index.value] }, [tabNames]) - const calculateNextOffset = useSharedValue(index.value) + const calculateNextOffset = useSharedValue(initialIndex) const headerScrollDistance: ContextType['headerScrollDistance'] = useDerivedValue(() => { - return headerHeight.value !== undefined - ? headerHeight.value - minHeaderHeight - : 0 + return headerHeight !== undefined ? headerHeight - minHeaderHeight : 0 }, [headerHeight, minHeaderHeight]) const indexDecimal: ContextType['indexDecimal'] = useSharedValue( @@ -167,7 +162,7 @@ export const Container = React.memo( scrollToImpl( refMap[name], 0, - scrollYCurrent.value - contentInset.value, + scrollYCurrent.value - contentInset, false ) } @@ -213,6 +208,33 @@ export const Container = React.memo( [onIndexChange, onTabChange] ) + const syncCurrentTabScrollPosition = () => { + 'worklet' + + const name = tabNamesArray[index.value] + scrollToImpl( + refMap[name], + 0, + scrollYCurrent.value - contentInset, + false + ) + } + + /* + * We run syncCurrentTabScrollPosition in every frame after the index + * changes for about 1500ms because the Lists can be late to accept the + * scrollTo event we send. This fixes the issue of the scroll position + * jumping when the user changes tab. + * */ + const toggleSyncScrollFrame = (toggle: boolean) => + syncScrollFrame.setActive(toggle) + const syncScrollFrame = useFrameCallback(({ timeSinceFirstFrame }) => { + syncCurrentTabScrollPosition() + if (timeSinceFirstFrame > 1500) { + runOnJS(toggleSyncScrollFrame)(false) + } + }, false) + useAnimatedReaction( () => { return calculateNextOffset.value @@ -236,13 +258,14 @@ export const Container = React.memo( scrollYCurrent.value = scrollY.value[tabNames.value[index.value]] || 0 } + runOnJS(toggleSyncScrollFrame)(true) } }, [] ) useAnimatedReaction( - () => headerHeight.value, + () => headerHeight, (_current, prev) => { if (prev === undefined) { // sync scroll if we started with undefined header height @@ -267,32 +290,6 @@ export const Container = React.memo( } }, [revealHeaderOnScroll]) - const getHeaderHeight = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (headerHeight.value !== height) { - headerHeight.value = height - } - }, - [headerHeight] - ) - - const getTabBarHeight = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (tabBarHeight.value !== height) tabBarHeight.value = height - }, - [tabBarHeight] - ) - - const onLayout = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (containerHeight.value !== height) containerHeight.value = height - }, - [containerHeight] - ) - const onTabPress = React.useCallback( (name: TabName) => { const i = tabNames.value.findIndex((n) => n === name) @@ -302,7 +299,7 @@ export const Container = React.memo( runOnUI(scrollToImpl)( ref, 0, - headerScrollDistance.value - contentInset.value, + headerScrollDistance.value - contentInset, true ) } else { @@ -313,11 +310,14 @@ export const Container = React.memo( [containerRef, refMap, contentInset] ) - React.useEffect(() => { - if (index.value >= tabNamesArray.length) { - onTabPress(tabNamesArray[tabNamesArray.length - 1]) + useAnimatedReaction( + () => tabNamesArray.length, + (tabLength) => { + if (index.value >= tabLength) { + runOnJS(onTabPress)(tabNamesArray[tabLength - 1]) + } } - }, [index.value, onTabPress, tabNamesArray]) + ) const pageScrollHandler = usePageScrollHandler({ onPageScroll: (e) => { @@ -381,7 +381,7 @@ export const Container = React.memo( > diff --git a/src/FlashList.tsx b/src/FlashList.tsx index 886dd82..9ce60da 100644 --- a/src/FlashList.tsx +++ b/src/FlashList.tsx @@ -11,7 +11,6 @@ import Animated, { import { useChainCallback, useCollapsibleStyle, - useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, @@ -118,16 +117,14 @@ function FlashListImpl( [progressViewOffset, refreshControl] ) - const contentInsetValue = useConvertAnimatedToValue(contentInset) - const memoContentInset = React.useMemo( - () => ({ top: contentInsetValue }), - [contentInsetValue] + () => ({ top: contentInset }), + [contentInset] ) const memoContentOffset = React.useMemo( - () => ({ x: 0, y: -contentInsetValue }), - [contentInsetValue] + () => ({ x: 0, y: -contentInset }), + [contentInset] ) const memoContentContainerStyle = React.useMemo( diff --git a/src/FlatList.tsx b/src/FlatList.tsx index 4c82d95..e7a6981 100644 --- a/src/FlatList.tsx +++ b/src/FlatList.tsx @@ -6,7 +6,6 @@ import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, - useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, @@ -79,16 +78,14 @@ function FlatListImpl( [progressViewOffset, refreshControl] ) - const contentInsetValue = useConvertAnimatedToValue(contentInset) - const memoContentInset = React.useMemo( - () => ({ top: contentInsetValue }), - [contentInsetValue] + () => ({ top: contentInset }), + [contentInset] ) const memoContentOffset = React.useMemo( - () => ({ x: 0, y: -contentInsetValue }), - [contentInsetValue] + () => ({ x: 0, y: -contentInset }), + [contentInset] ) const memoContentContainerStyle = React.useMemo( diff --git a/src/Lazy.tsx b/src/Lazy.tsx index 576bf70..583c28d 100644 --- a/src/Lazy.tsx +++ b/src/Lazy.tsx @@ -37,15 +37,6 @@ export const Lazy: React.FC<{ const name = useTabNameContext() const { focusedTab, refMap } = useTabsContext() - /** - * We start mounted if we are the focused tab, or if props.startMounted is true. - */ - const startMounted = useSharedValue( - typeof _startMounted === 'boolean' - ? _startMounted - : focusedTab.value === name - ) - /** * We keep track of whether a layout has been triggered */ @@ -54,13 +45,24 @@ export const Lazy: React.FC<{ /** * This is used to control when children are mounted */ - const [canMount, setCanMount] = React.useState(!!startMounted.value) + const [canMount, setCanMount] = React.useState(false) /** * Ensure we don't mount after the component has been unmounted */ const isSelfMounted = React.useRef(true) - const opacity = useSharedValue(cancelLazyFadeIn || startMounted.value ? 1 : 0) + /** + * We start mounted if we are the focused tab, or if props.startMounted is true. + */ + const shouldStartMounted = + typeof _startMounted === 'boolean' + ? _startMounted + : focusedTab.value === name + let initialOpacity = 1 + if (!cancelLazyFadeIn && !shouldStartMounted) { + initialOpacity = 0 + } + const opacity = useSharedValue(initialOpacity) React.useEffect(() => { return () => { diff --git a/src/MasonryFlashList.tsx b/src/MasonryFlashList.tsx index cc5833e..93756ae 100644 --- a/src/MasonryFlashList.tsx +++ b/src/MasonryFlashList.tsx @@ -8,7 +8,6 @@ import Animated, { import { useChainCallback, useCollapsibleStyle, - useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, @@ -121,16 +120,14 @@ function MasonryFlashListImpl( [progressViewOffset, refreshControl] ) - const contentInsetValue = useConvertAnimatedToValue(contentInset) - const memoContentInset = React.useMemo( - () => ({ top: contentInsetValue }), - [contentInsetValue] + () => ({ top: contentInset }), + [contentInset] ) const memoContentOffset = React.useMemo( - () => ({ x: 0, y: -contentInsetValue }), - [contentInsetValue] + () => ({ x: 0, y: -contentInset }), + [contentInset] ) const memoContentContainerStyle = React.useMemo( diff --git a/src/ScrollView.tsx b/src/ScrollView.tsx index 71260b7..712a65b 100644 --- a/src/ScrollView.tsx +++ b/src/ScrollView.tsx @@ -6,7 +6,6 @@ import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, - useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, @@ -91,16 +90,14 @@ export const ScrollView = React.forwardRef< [progressViewOffset, refreshControl] ) - const contentInsetValue = useConvertAnimatedToValue(contentInset) - const memoContentInset = React.useMemo( - () => ({ top: contentInsetValue }), - [contentInsetValue] + () => ({ top: contentInset }), + [contentInset] ) const memoContentOffset = React.useMemo( - () => ({ x: 0, y: -contentInsetValue }), - [contentInsetValue] + () => ({ x: 0, y: -contentInset }), + [contentInset] ) const memoContentContainerStyle = React.useMemo( diff --git a/src/SectionList.tsx b/src/SectionList.tsx index 414f649..2bd6b9c 100644 --- a/src/SectionList.tsx +++ b/src/SectionList.tsx @@ -6,7 +6,6 @@ import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, - useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, @@ -86,16 +85,14 @@ function SectionListImpl( [progressViewOffset, refreshControl] ) - const contentInsetValue = useConvertAnimatedToValue(contentInset) - const memoContentInset = React.useMemo( - () => ({ top: contentInsetValue }), - [contentInsetValue] + () => ({ top: contentInset }), + [contentInset] ) const memoContentOffset = React.useMemo( - () => ({ x: 0, y: -contentInsetValue }), - [contentInsetValue] + () => ({ x: 0, y: -contentInset }), + [contentInset] ) const memoContentContainerStyle = React.useMemo( diff --git a/src/hooks.tsx b/src/hooks.tsx index de81e5c..ba7dffa 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -22,7 +22,6 @@ import Animated, { interpolate, runOnJS, runOnUI, - useDerivedValue, useEvent, useHandler, AnimatedRef, @@ -119,6 +118,20 @@ export function useTabNameContext(): TabName { return c } +export function useLayoutHeight(initialHeight: number = 0) { + const [height, setHeight] = useState(initialHeight) + + const getHeight = useCallback( + (event: LayoutChangeEvent) => { + const latestHeight = event.nativeEvent.layout.height + if (latestHeight !== height) { + setHeight(latestHeight) + } + }, + [height, setHeight] + ) + return [height, getHeight] as const +} /** * Hook to access some key styles that make the whole thing work. * @@ -133,15 +146,9 @@ export function useCollapsibleStyle(): CollapsibleStyle { allowHeaderOverscroll, minHeaderHeight, } = useTabsContext() - const [containerHeightVal, tabBarHeightVal, headerHeightVal] = [ - useConvertAnimatedToValue(containerHeight), - useConvertAnimatedToValue(tabBarHeight), - useConvertAnimatedToValue(headerHeight), - ] - const containerHeightWithMinHeader = Math.max( 0, - (containerHeightVal ?? 0) - minHeaderHeight + (containerHeight ?? 0) - minHeaderHeight ) return useMemo( @@ -150,24 +157,24 @@ export function useCollapsibleStyle(): CollapsibleStyle { contentContainerStyle: { minHeight: IS_IOS && !allowHeaderOverscroll - ? containerHeightWithMinHeader - (tabBarHeightVal || 0) - : containerHeightWithMinHeader + (headerHeightVal || 0), + ? containerHeightWithMinHeader - (tabBarHeight || 0) + : containerHeightWithMinHeader + (headerHeight || 0), paddingTop: IS_IOS && !allowHeaderOverscroll ? 0 - : (headerHeightVal || 0) + (tabBarHeightVal || 0), + : (headerHeight || 0) + (tabBarHeight || 0), }, progressViewOffset: // on iOS we need the refresh control to be at the top if overscrolling IS_IOS && allowHeaderOverscroll ? 0 : // on android we need it below the header or it doesn't show because of z-index - (headerHeightVal || 0) + (tabBarHeightVal || 0), + (headerHeight || 0) + (tabBarHeight || 0), }), [ allowHeaderOverscroll, - headerHeightVal, - tabBarHeightVal, + headerHeight, + tabBarHeight, width, containerHeightWithMinHeader, ] @@ -231,9 +238,9 @@ export function useScroller() { if (!ref) return //! this is left here on purpose to ease troubleshooting (uncomment when necessary) // console.log( - // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset.value}` + // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset}` // ) - scrollToImpl(ref, x, y - contentInset.value, animated) + scrollToImpl(ref, x, y - contentInset, animated) }, [contentInset] ) @@ -272,15 +279,8 @@ export const useScrollHandlerY = (name: TabName) => { (toggle: boolean) => { 'worklet' enabled.value = toggle - - if (toggle) { - const ref = refMap[name] - const y = scrollY.value[name] ?? scrollYCurrent.value - - scrollTo(ref, 0, y, false, `[${name}] restore scroll position - enable`) - } }, - [enabled, name, refMap, scrollTo, scrollY.value, scrollYCurrent.value] + [name, refMap, scrollTo] ) /** @@ -291,11 +291,6 @@ export const useScrollHandlerY = (name: TabName) => { */ const afterDrag = useSharedValue(0) - const tabIndex = useMemo( - () => tabNames.value.findIndex((n) => n === name), - [tabNames, name] - ) - const scrollAnimation = useSharedValue(undefined) useAnimatedReaction( @@ -359,11 +354,6 @@ export const useScrollHandlerY = (name: TabName) => { } } - const contentHeight = useDerivedValue(() => { - const tabIndex = tabNames.value.indexOf(name) - return contentHeights.value[tabIndex] || Number.MAX_VALUE - }, []) - const scrollHandler = useAnimatedScrollHandler( { onScroll: (event) => { @@ -373,11 +363,14 @@ export const useScrollHandlerY = (name: TabName) => { if (IS_IOS) { let { y } = event.contentOffset // normalize the value so it starts at 0 - y = y + contentInset.value + y = y + contentInset + + const contentHeight = + contentHeights.value[tabNames.value.indexOf(name)] || + Number.MAX_VALUE + const clampMax = - contentHeight.value - - (containerHeight.value || 0) + - contentInset.value + contentHeight - (containerHeight || 0) + contentInset // make sure the y value is clamped to the scrollable size (clamps overscrolling) scrollYCurrent.value = allowHeaderOverscroll ? y @@ -498,7 +491,7 @@ export const useScrollHandlerY = (name: TabName) => { if (focusedIsOnTop) { nextPosition = snappingTo.value } else if (currIsOnTop) { - nextPosition = headerHeight.value || 0 + nextPosition = headerHeight || 0 } } else if (currIsOnTop || focusedIsOnTop) { nextPosition = Math.min(focusedScrollY, headerScrollDistance.value) @@ -512,7 +505,7 @@ export const useScrollHandlerY = (name: TabName) => { } } }, - [revealHeaderOnScroll, refMap, snapThreshold, tabIndex, enabled, scrollTo] + [revealHeaderOnScroll, refMap, snapThreshold, enabled, scrollTo] ) return { scrollHandler, enable } @@ -586,7 +579,7 @@ export function useAfterMountEffect( export function useConvertAnimatedToValue( animatedValue: Animated.SharedValue ) { - const [value, setValue] = useState(animatedValue.value) + const [value, setValue] = useState(animatedValue.value) useAnimatedReaction( () => { @@ -600,7 +593,7 @@ export function useConvertAnimatedToValue( [value] ) - return value + return value || 0 } export interface HeaderMeasurements { @@ -611,7 +604,7 @@ export interface HeaderMeasurements { /** * Animated value that represents the height of the header */ - height: Animated.SharedValue + height: number } export function useHeaderMeasurements(): HeaderMeasurements { diff --git a/src/types.ts b/src/types.ts index 2bb38d4..00fbafc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,8 +135,9 @@ export type CollapsibleProps = { } export type ContextType = { - headerHeight: SharedValue - tabBarHeight: SharedValue + headerHeight: number + tabBarHeight: number + containerHeight: number revealHeaderOnScroll: boolean snapThreshold: number | null | undefined /** @@ -169,7 +170,6 @@ export type ContextType = { * Array of the scroll y position of each tab. */ scrollY: SharedValue> - containerHeight: SharedValue /** * Object containing the ref of each scrollable component. */ @@ -209,7 +209,7 @@ export type ContextType = { */ contentHeights: SharedValue - contentInset: SharedValue + contentInset: number headerTranslateY: SharedValue