diff --git a/example/src/Shared/QuickStartDemo.tsx b/example/src/Shared/QuickStartDemo.tsx index d2fca6ac..2e844851 100644 --- a/example/src/Shared/QuickStartDemo.tsx +++ b/example/src/Shared/QuickStartDemo.tsx @@ -4,6 +4,9 @@ import { Tabs } from 'react-native-collapsible-tab-view' const HEADER_HEIGHT = 250 +const DATA = [0, 1, 2, 3, 4] +const identity = (v: unknown): string => v + '' + const Header = () => { return } @@ -22,9 +25,9 @@ const Example: React.FC = () => { > v + ''} + keyExtractor={identity} /> diff --git a/src/Container.tsx b/src/Container.tsx index fffe08ad..de37285d 100644 --- a/src/Container.tsx +++ b/src/Container.tsx @@ -93,21 +93,22 @@ export const Container = React.memo( const windowWidth = useWindowDimensions().width const firstRender = React.useRef(true) - const [containerHeight, setContainerHeight] = React.useState< - number | undefined - >(undefined) - const [tabBarHeight, setTabBarHeight] = React.useState< - number | undefined - >(initialTabBarHeight) - const [headerHeight, setHeaderHeight] = React.useState< - number | undefined - >(!renderHeader ? 0 : initialHeaderHeight) - - const contentInset = React.useMemo( - () => (IS_IOS ? (headerHeight || 0) + (tabBarHeight || 0) : 0), - [headerHeight, tabBarHeight] + const containerHeight = useSharedValue(undefined) + + const tabBarHeight = useSharedValue( + initialTabBarHeight + ) + + const headerHeight = useSharedValue( + !renderHeader ? 0 : initialHeaderHeight ) + const contentInset = useDerivedValue(() => { + return IS_IOS + ? (headerHeight.value || 0) + (tabBarHeight.value || 0) + : 0 + }) + const isSwiping = useSharedValue(false) const isSnapping: ContextType['isSnapping'] = useSharedValue(false) const snappingTo: ContextType['snappingTo'] = useSharedValue(0) @@ -156,7 +157,9 @@ export const Container = React.memo( }, [tabNames]) const calculateNextOffset = useSharedValue(index.value) const headerScrollDistance: ContextType['headerScrollDistance'] = useDerivedValue(() => { - return headerHeight !== undefined ? headerHeight - minHeaderHeight : 0 + return headerHeight.value !== undefined + ? headerHeight.value - minHeaderHeight + : 0 }, [headerHeight, minHeaderHeight]) const getItemLayout = React.useCallback( @@ -218,7 +221,7 @@ export const Container = React.memo( scrollToImpl( refMap[name], 0, - scrollY.value[index.value] - contentInset, + scrollY.value[index.value] - contentInset.value, false ) }) @@ -334,8 +337,8 @@ export const Container = React.memo( const getHeaderHeight = React.useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height - if (headerHeight !== height) { - setHeaderHeight(height) + if (headerHeight.value !== height) { + headerHeight.value = height } }, [headerHeight] @@ -344,7 +347,7 @@ export const Container = React.memo( const getTabBarHeight = React.useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height - if (tabBarHeight !== height) setTabBarHeight(height) + if (tabBarHeight.value !== height) tabBarHeight.value = height }, [tabBarHeight] ) @@ -352,7 +355,7 @@ export const Container = React.memo( const onLayout = React.useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height - if (containerHeight !== height) setContainerHeight(height) + if (containerHeight.value !== height) containerHeight.value = height }, [containerHeight] ) @@ -393,7 +396,7 @@ export const Container = React.memo( runOnUI(scrollToImpl)( ref, 0, - headerScrollDistance.value - contentInset, + headerScrollDistance.value - contentInset.value, true ) } else { @@ -442,8 +445,8 @@ export const Container = React.memo( >>( + (props, passRef) => { + return ( + + ) + } + ) +) + function FlatListImpl( { contentContainerStyle, @@ -26,15 +44,14 @@ function FlatListImpl( const name = useTabNameContext() const { setRef, contentInset, scrollYCurrent } = useTabsContext() const ref = useSharedAnimatedRef>(passRef) - const [canBindScrollEvent, setCanBindScrollEvent] = React.useState(false) + const { scrollHandler, enable } = useScrollHandlerY(name) useAfterMountEffect(() => { // we enable the scroll event after mounting // otherwise we get an `onScroll` call with the initial scroll position which can break things - setCanBindScrollEvent(true) + enable(true) }) - const scrollHandler = useScrollHandlerY(name, { enabled: canBindScrollEvent }) const { style: _style, contentContainerStyle: _contentContainerStyle, @@ -50,35 +67,57 @@ function FlatListImpl( }) const scrollContentSizeChangeHandlers = useChainCallback( - scrollContentSizeChange, - onContentSizeChange + React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ]) + ) + + const memoRefreshControl = React.useMemo( + () => + refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), + [progressViewOffset, refreshControl] + ) + const memoContentOffset = React.useMemo( + () => ({ + y: IS_IOS ? -contentInset.value + scrollYCurrent.value : 0, + x: 0, + }), + [contentInset.value, scrollYCurrent.value] + ) + const memoContentInset = React.useMemo(() => ({ top: contentInset.value }), [ + contentInset.value, + ]) + const memoContentContainerStyle = React.useMemo( + () => [ + _contentContainerStyle, + // TODO: investigate types + contentContainerStyle as any, + ], + [_contentContainerStyle, contentContainerStyle] ) + const memoStyle = React.useMemo(() => [_style, style], [_style, style]) return ( - ) } diff --git a/src/MaterialTabBar/TabBar.tsx b/src/MaterialTabBar/TabBar.tsx index d2ad13cd..f775df90 100644 --- a/src/MaterialTabBar/TabBar.tsx +++ b/src/MaterialTabBar/TabBar.tsx @@ -61,9 +61,8 @@ const MaterialTabBar = ({ const tabBarRef = useAnimatedRef() const windowWidth = useWindowDimensions().width const isFirstRender = React.useRef(true) - const [itemsLayoutGathering, setItemsLayoutGathering] = React.useState( - new Map() - ) + const itemLayoutGathering = React.useRef(new Map()) + const tabsOffset = useSharedValue(0) const isScrolling = useSharedValue(false) @@ -97,30 +96,26 @@ const MaterialTabBar = ({ if (scrollEnabled) { if (!event.nativeEvent?.layout) return const { width, x } = event.nativeEvent.layout - setItemsLayoutGathering((itemsLayoutGathering) => { - const update = new Map(itemsLayoutGathering) - return update.set(name, { - width, - x, - }) + + itemLayoutGathering.current.set(name, { + width, + x, }) + + // pick out the layouts for the tabs we know about (in case they changed dynamically) + const layout = Array.from(itemLayoutGathering.current.entries()) + .filter(([tabName]) => tabNames.includes(tabName)) + .map(([, layout]) => layout) + .sort((a, b) => a.x - b.x) + + if (layout.length === tabNames.length) { + setItemsLayout(layout) + } } }, - [scrollEnabled] + [scrollEnabled, tabNames] ) - React.useEffect(() => { - // pick out the layouts for the tabs we know about (in case they changed dynamically) - const layout = Array.from(itemsLayoutGathering.entries()) - .filter(([tabName]) => tabNames.includes(tabName)) - .map(([, layout]) => layout) - .sort((a, b) => a.x - b.x) - - if (layout.length === tabNames.length) { - setItemsLayout(layout) - } - }, [itemsLayoutGathering, tabNames]) - const cancelNextScrollSync = useSharedValue(index.value) const onScroll = useAnimatedScrollHandler( diff --git a/src/ScrollView.tsx b/src/ScrollView.tsx index b995e6cb..bc230fae 100644 --- a/src/ScrollView.tsx +++ b/src/ScrollView.tsx @@ -14,6 +14,24 @@ import { 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 + */ +const ScrollViewMemo = React.memo( + React.forwardRef>( + (props, passRef) => { + return ( + + ) + } + ) +) + /** * Use like a regular ScrollView. */ @@ -40,16 +58,11 @@ export const ScrollView = React.forwardRef< contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle() - const [canBindScrollEvent, setCanBindScrollEvent] = React.useState(false) - + const { scrollHandler, enable } = useScrollHandlerY(name) useAfterMountEffect(() => { // we enable the scroll event after mounting // otherwise we get an `onScroll` call with the initial scroll position which can break things - setCanBindScrollEvent(true) - }) - - const scrollHandler = useScrollHandlerY(name, { - enabled: canBindScrollEvent, + enable(true) }) React.useEffect(() => { @@ -61,41 +74,59 @@ export const ScrollView = React.forwardRef< }) const scrollContentSizeChangeHandlers = useChainCallback( - scrollContentSizeChange, - onContentSizeChange + React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ]) + ) + + const memoRefreshControl = React.useMemo( + () => + refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), + [progressViewOffset, refreshControl] + ) + const memoContentOffset = React.useMemo( + () => ({ + y: IS_IOS ? -contentInset.value + scrollYCurrent.value : 0, + x: 0, + }), + [contentInset.value, scrollYCurrent.value] + ) + const memoContentInset = React.useMemo( + () => ({ top: contentInset.value }), + [contentInset.value] + ) + const memoContentContainerStyle = React.useMemo( + () => [ + _contentContainerStyle, + // TODO: investigate types + contentContainerStyle as any, + ], + [_contentContainerStyle, contentContainerStyle] ) + const memoStyle = React.useMemo(() => [_style, style], [_style, style]) return ( - {children} - + ) } ) diff --git a/src/hooks.tsx b/src/hooks.tsx index 108419f2..faaad363 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -127,29 +127,37 @@ export function useTabNameContext(): TabName { export function useCollapsibleStyle(): CollapsibleStyle { const { headerHeight, tabBarHeight, containerHeight } = useTabsContext() const windowWidth = useWindowDimensions().width - - return { - style: { width: windowWidth }, - contentContainerStyle: { - minHeight: IS_IOS - ? (containerHeight || 0) - tabBarHeight - : (containerHeight || 0) + headerHeight, - paddingTop: IS_IOS ? 0 : headerHeight + tabBarHeight, - }, - progressViewOffset: headerHeight + tabBarHeight, - } + const [containerHeightVal, tabBarHeightVal, headerHeightVal] = [ + useConvertAnimatedToValue(containerHeight), + useConvertAnimatedToValue(tabBarHeight), + useConvertAnimatedToValue(headerHeight), + ] + return useMemo( + () => ({ + style: { width: windowWidth }, + contentContainerStyle: { + minHeight: IS_IOS + ? (containerHeightVal || 0) - (tabBarHeightVal || 0) + : (containerHeightVal || 0) + (headerHeightVal || 0), + paddingTop: IS_IOS + ? 0 + : (headerHeightVal || 0) + (tabBarHeightVal || 0), + }, + progressViewOffset: (headerHeightVal || 0) + (tabBarHeightVal || 0), + }), + [containerHeightVal, headerHeightVal, tabBarHeightVal, windowWidth] + ) } export function useUpdateScrollViewContentSize({ name }: { name: TabName }) { const { tabNames, contentHeights } = useTabsContext() - const setContentHeights = useCallback( (name: TabName, height: number) => { const tabIndex = tabNames.value.indexOf(name) contentHeights.value[tabIndex] = height contentHeights.value = [...contentHeights.value] }, - [contentHeights, tabNames.value] + [contentHeights, tabNames] ) const scrollContentSizeChange = useCallback( @@ -168,7 +176,7 @@ export function useUpdateScrollViewContentSize({ name }: { name: TabName }) { * @param fns array of functions to call * @returns a function that once called will call all passed functions */ -export function useChainCallback(...fns: (Function | undefined)[]) { +export function useChainCallback(fns: (Function | undefined)[]) { const callAll = useCallback( (...args: unknown[]) => { fns.forEach((fn) => { @@ -196,7 +204,7 @@ export function useScroller() { 'worklet' if (!ref) return // console.log(`${_debugKey}, y: ${y}, y adjusted: ${y - contentInset}`) - scrollToImpl(ref, x, y - contentInset, animated) + scrollToImpl(ref, x, y - contentInset.value, animated) }, [contentInset] ) @@ -204,10 +212,7 @@ export function useScroller() { return scroller } -export const useScrollHandlerY = ( - name: TabName, - { enabled }: { enabled: boolean } -) => { +export const useScrollHandlerY = (name: TabName) => { const { accDiffClamp, focusedTab, @@ -232,6 +237,15 @@ export const useScrollHandlerY = ( contentHeights, } = useTabsContext() + const enabled = useSharedValue(false) + + const enable = useCallback( + (toggle: boolean) => { + enabled.value = toggle + }, + [enabled] + ) + /** * Helper value to track if user is dragging on iOS, because iOS calls * onMomentumEnd only after a vigorous swipe. If the user has finished the @@ -249,7 +263,7 @@ export const useScrollHandlerY = ( const onMomentumEnd = () => { 'worklet' - if (!enabled) return + if (!enabled.value) return if (typeof snapThreshold === 'number') { if (revealHeaderOnScroll) { @@ -328,15 +342,17 @@ export const useScrollHandlerY = ( const scrollHandler = useAnimatedScrollHandler( { onScroll: (event) => { - if (!enabled) return + if (!enabled.value) return if (focusedTab.value === name) { if (IS_IOS) { let { y } = event.contentOffset // normalize the value so it starts at 0 - y = y + contentInset + y = y + contentInset.value const clampMax = - contentHeight.value - (containerHeight || 0) + contentInset + contentHeight.value - + (containerHeight.value || 0) + + contentInset.value // make sure the y value is clamped to the scrollable size (clamps overscrolling) scrollYCurrent.value = interpolate( y, @@ -381,7 +397,7 @@ export const useScrollHandlerY = ( } }, onBeginDrag: () => { - if (!enabled) return + if (!enabled.value) return // ensure the header stops snapping cancelAnimation(accDiffClamp) @@ -393,7 +409,7 @@ export const useScrollHandlerY = ( if (IS_IOS) cancelAnimation(afterDrag) }, onEndDrag: () => { - if (!enabled) return + if (!enabled.value) return isGliding.value = true @@ -414,7 +430,7 @@ export const useScrollHandlerY = ( } }, onMomentumBegin: () => { - if (!enabled) return + if (!enabled.value) return if (IS_IOS) { cancelAnimation(afterDrag) @@ -438,7 +454,10 @@ export const useScrollHandlerY = ( useAnimatedReaction( () => { return ( - !isSnapping.value && !isScrolling.value && !isGliding.value && enabled + !isSnapping.value && + !isScrolling.value && + !isGliding.value && + enabled.value ) }, (sync) => { @@ -462,7 +481,7 @@ export const useScrollHandlerY = ( if (focusedIsOnTop) { nextPosition = snappingTo.value } else if (currIsOnTop) { - nextPosition = headerHeight + nextPosition = headerHeight.value || 0 } } else if (currIsOnTop || focusedIsOnTop) { nextPosition = Math.min(focusedScrollY, headerScrollDistance.value) @@ -478,7 +497,7 @@ export const useScrollHandlerY = ( [revealHeaderOnScroll, refMap, snapThreshold, tabIndex, enabled, scrollTo] ) - return scrollHandler + return { scrollHandler, enable } } type ForwardRefType = @@ -514,7 +533,7 @@ export function useSharedAnimatedRef( export function useAfterMountEffect(effect: React.EffectCallback) { const [didExecute, setDidExecute] = useState(false) - const result = useEffect(() => { + useEffect(() => { if (didExecute) return const timeout = setTimeout(() => { @@ -525,7 +544,6 @@ export function useAfterMountEffect(effect: React.EffectCallback) { clearTimeout(timeout) } }, [didExecute, effect]) - return result } export function useConvertAnimatedToValue( @@ -563,7 +581,7 @@ export function useHeaderMeasurements(): HeaderMeasurements { const { headerTranslateY, headerHeight } = useTabsContext() return { top: headerTranslateY, - height: headerHeight, + height: headerHeight.value || 0, } } diff --git a/src/types.ts b/src/types.ts index 42645d69..e4f596e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,8 +139,8 @@ export type CollapsibleProps = { } export type ContextType = { - headerHeight: number - tabBarHeight: number + headerHeight: Animated.SharedValue + tabBarHeight: Animated.SharedValue revealHeaderOnScroll: boolean snapThreshold: number | null | undefined /** @@ -173,7 +173,7 @@ export type ContextType = { * Array of the scroll y position of each tab. */ scrollY: Animated.SharedValue - containerHeight?: number + containerHeight: Animated.SharedValue /** * Object containing the ref of each scrollable component. */ @@ -219,7 +219,7 @@ export type ContextType = { */ contentHeights: Animated.SharedValue - contentInset: number + contentInset: Animated.SharedValue headerTranslateY: Animated.SharedValue }