From 6345facf765451eea24e3ff91037424fe68bc389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Tue, 29 Nov 2022 10:09:16 +0100 Subject: [PATCH] fix: split updating state to batches on long lists (#11046) **Motivation** As reported here: https://github.com/satya164/react-native-tab-view/issues/1405, when using really long lists header wouldn't get all tab widths and therefore would stop rendering. If we have more than 10 routes we split rendering into multiple batches using onViewableItemsChanged. **Test plan** https://user-images.githubusercontent.com/52801365/204381404-47fe743c-e79a-45ba-9c05-08c8cdd9f92e.mp4 Launch Scrollable tab bar example and scroll. The change must pass lint, typescript and tests. --- packages/react-native-tab-view/src/TabBar.tsx | 47 +++++++++++++++++-- .../src/TabBarIndicator.tsx | 13 +++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/react-native-tab-view/src/TabBar.tsx b/packages/react-native-tab-view/src/TabBar.tsx index f8b23101c2..26911c0721 100644 --- a/packages/react-native-tab-view/src/TabBar.tsx +++ b/packages/react-native-tab-view/src/TabBar.tsx @@ -11,7 +11,9 @@ import { TextStyle, View, ViewStyle, + ViewToken, } from 'react-native'; +import useLatestCallback from 'use-latest-callback'; import TabBarIndicator, { Props as IndicatorProps } from './TabBarIndicator'; import TabBarItem, { Props as TabBarItemProps } from './TabBarItem'; @@ -248,6 +250,10 @@ const renderIndicatorDefault = (props: IndicatorProps) => ( const getTestIdDefault = ({ route }: Scene) => route.testID; +// How many items measurements should we update per batch. +// Defaults to 10, since that's whats FlatList is using in initialNumToRender. +const MEASURE_PER_BATCH = 10; + export default function TabBar({ getLabelText = getLabelTextDefault, getAccessible = getAccessibleDefault, @@ -280,7 +286,7 @@ export default function TabBar({ }: Props) { const [layout, setLayout] = React.useState({ width: 0, height: 0 }); const [tabWidths, setTabWidths] = React.useState>({}); - const flatListRef = React.useRef(null); + const flatListRef = React.useRef(null); const isFirst = React.useRef(true); const scrollAmount = useAnimatedValue(0); const measuredTabWidths = React.useRef>({}); @@ -299,7 +305,9 @@ export default function TabBar({ const hasMeasuredTabWidths = Boolean(layout.width) && - routes.every((r) => typeof tabWidths[r.key] === 'number'); + routes + .slice(0, navigationState.index) + .every((r) => typeof tabWidths[r.key] === 'number'); React.useEffect(() => { if (isFirst.current) { @@ -308,7 +316,6 @@ export default function TabBar({ } if (isWidthDynamic && !hasMeasuredTabWidths) { - // When tab width is dynamic, only adjust the scroll once we have all tab widths and layout return; } @@ -376,11 +383,24 @@ export default function TabBar({ // When we have measured widths for all of the tabs, we should updates the state // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app + // If we have more than 10 routes divide updating tabWidths into multiple batches. Here we update only first batch of 10 items. if ( + routes.length > MEASURE_PER_BATCH && + index === MEASURE_PER_BATCH && + routes + .slice(0, MEASURE_PER_BATCH) + .every( + (r) => typeof measuredTabWidths.current[r.key] === 'number' + ) + ) { + setTabWidths({ ...measuredTabWidths.current }); + } else if ( routes.every( (r) => typeof measuredTabWidths.current[r.key] === 'number' ) ) { + // When we have measured widths for all of the tabs, we should updates the state + // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app setTabWidths({ ...measuredTabWidths.current }); } } @@ -495,6 +515,25 @@ export default function TabBar({ [scrollAmount] ); + const handleViewableItemsChanged = useLatestCallback( + ({ changed }: { changed: ViewToken[] }) => { + if (routes.length <= MEASURE_PER_BATCH) { + return; + } + // Get next vievable item + const item = changed[changed.length - 1]; + const index = item?.index || 0; + if ( + item.isViewable && + (index % 10 === 0 || + index === navigationState.index || + index === routes.length - 1) + ) { + setTabWidths({ ...measuredTabWidths.current }); + } + } + ); + return ( ({ keyboardShouldPersistTaps="handled" scrollEnabled={scrollEnabled} bounces={bounces} + initialNumToRender={MEASURE_PER_BATCH} + onViewableItemsChanged={handleViewableItemsChanged} alwaysBounceHorizontal={false} scrollsToTop={false} showsHorizontalScrollIndicator={false} diff --git a/packages/react-native-tab-view/src/TabBarIndicator.tsx b/packages/react-native-tab-view/src/TabBarIndicator.tsx index d760d2f895..5c13db2eab 100644 --- a/packages/react-native-tab-view/src/TabBarIndicator.tsx +++ b/packages/react-native-tab-view/src/TabBarIndicator.tsx @@ -59,9 +59,12 @@ export default function TabBarIndicator({ const opacity = useAnimatedValue(isWidthDynamic ? 0 : 1); - const hasMeasuredTabWidths = - Boolean(layout.width) && - navigationState.routes.every((_, i) => getTabWidth(i)); + const indicatorVisible = isWidthDynamic + ? layout.width && + navigationState.routes + .slice(0, navigationState.index) + .every((_, r) => getTabWidth(r)) + : true; React.useEffect(() => { const fadeInIndicator = () => { @@ -69,7 +72,7 @@ export default function TabBarIndicator({ !isIndicatorShown.current && isWidthDynamic && // We should fade-in the indicator when we have widths for all the tab items - hasMeasuredTabWidths + indicatorVisible ) { isIndicatorShown.current = true; @@ -85,7 +88,7 @@ export default function TabBarIndicator({ fadeInIndicator(); return () => opacity.stopAnimation(); - }, [hasMeasuredTabWidths, isWidthDynamic, opacity]); + }, [indicatorVisible, isWidthDynamic, opacity]); const { routes } = navigationState;