From a6c20feadb0b08b87eb3c98658e31184a039d4b8 Mon Sep 17 00:00:00 2001 From: Pedro Bern Date: Sat, 30 Jan 2021 18:14:12 -0300 Subject: [PATCH] feat: add imperactive ref handler --- example/src/App.tsx | 2 + example/src/Ref.tsx | 34 ++ example/src/Shared/ExampleComponent.tsx | 50 +- example/src/Shared/Header.tsx | 2 + src/createCollapsibleTabs.tsx | 731 ++++++++++++------------ src/index.tsx | 2 + src/types.ts | 23 +- 7 files changed, 460 insertions(+), 384 deletions(-) create mode 100644 example/src/Ref.tsx diff --git a/example/src/App.tsx b/example/src/App.tsx index b70fe7b9..949f6bc4 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -18,6 +18,7 @@ import DiffClamp from './DiffClamp' import DiffClampSnap from './DiffClampSnap' import Lazy from './Lazy' import QuickStartDemo from './QuickStartDemo' +import Ref from './Ref' import ScrollOnHeader from './ScrollOnHeader' import ScrollableTabs from './ScrollableTabs' import Snap from './Snap' @@ -37,6 +38,7 @@ const EXAMPLE_COMPONENTS: ExampleComponentType[] = [ QuickStartDemo, UndefinedHeaderHeight, StartOnSpecificTab, + Ref, ] const ExampleList: React.FC = () => { diff --git a/example/src/Ref.tsx b/example/src/Ref.tsx new file mode 100644 index 00000000..d9534877 --- /dev/null +++ b/example/src/Ref.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { CollapsibleRef } from 'react-native-collapsible-tab-view' + +import ExampleComponent from './Shared/ExampleComponent' +import { buildHeader } from './Shared/Header' +import { TabNames } from './Shared/Tabs' +import { ExampleComponentType } from './types' + +const title = 'Ref example "jumpToTab" after 1 second' + +const Header = buildHeader(title) + +const RefExample: ExampleComponentType = () => { + const ref = React.useRef>() + + React.useEffect(() => { + const timer = setTimeout(() => { + ref.current?.jumpToTab('contacts') + // ref.current?.setIndex(2) + // ref.current?.getCurrentIndex() + // ref.current?.getFocusedTab() + }, 1000) + + return () => { + clearTimeout(timer) + } + }, []) + + return +} + +RefExample.title = title + +export default RefExample diff --git a/example/src/Shared/ExampleComponent.tsx b/example/src/Shared/ExampleComponent.tsx index b77b2d9e..2523193c 100644 --- a/example/src/Shared/ExampleComponent.tsx +++ b/example/src/Shared/ExampleComponent.tsx @@ -3,6 +3,7 @@ import { CollapsibleProps, RefComponent, ContainerRef, + CollapsibleRef, } from 'react-native-collapsible-tab-view' import { useAnimatedRef } from 'react-native-reanimated' @@ -16,30 +17,33 @@ type Props = { emptyContacts?: boolean } & Partial> -const Example: React.FC = ({ emptyContacts, ...props }) => { - const containerRef = useAnimatedRef() - const albumsRef = useAnimatedRef() - const articleRef = useAnimatedRef() - const contactsRef = useAnimatedRef() +const Example = React.forwardRef, Props>( + ({ emptyContacts, ...props }, ref) => { + const containerRef = useAnimatedRef() + const albumsRef = useAnimatedRef() + const articleRef = useAnimatedRef() + const contactsRef = useAnimatedRef() - const [refMap] = React.useState({ - article: articleRef, - albums: albumsRef, - contacts: contactsRef, - }) + const [refMap] = React.useState({ + article: articleRef, + albums: albumsRef, + contacts: contactsRef, + }) - return ( - -
- - - - ) -} + return ( + +
+ + + + ) + } +) export default Example diff --git a/example/src/Shared/Header.tsx b/example/src/Shared/Header.tsx index 7bdb54de..8dc4784b 100644 --- a/example/src/Shared/Header.tsx +++ b/example/src/Shared/Header.tsx @@ -32,10 +32,12 @@ const styles = StyleSheet.create({ backgroundColor: '#2196f3', justifyContent: 'center', alignItems: 'center', + padding: 16, }, text: { color: 'white', fontSize: 24, + textAlign: 'center', }, }) diff --git a/src/createCollapsibleTabs.tsx b/src/createCollapsibleTabs.tsx index 12df48f4..f10e78f3 100644 --- a/src/createCollapsibleTabs.tsx +++ b/src/createCollapsibleTabs.tsx @@ -20,16 +20,14 @@ import Animated, { cancelAnimation, } from 'react-native-reanimated' -import MaterialTabBar, { - TABBAR_HEIGHT, - MaterialTabBarProps, -} from './MaterialTabBar' +import MaterialTabBar, { TABBAR_HEIGHT } from './MaterialTabBar' import { CollapsibleProps, ContextType, ScrollViewProps, FlatListProps, - TabBarProps, + ParamList, + CollapsibleRef, } from './types' const AnimatedFlatList = Animated.createAnimatedComponent(RNFlatList) @@ -37,10 +35,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(RNFlatList) /** The time one frame takes at 60 fps (16 ms) */ const ONE_FRAME_MS = 16 -const createCollapsibleTabs = < - T extends string, - TP extends TabBarProps = MaterialTabBarProps ->() => { +const createCollapsibleTabs = () => { const Context = React.createContext | undefined>(undefined) function useTabsContext(): ContextType { @@ -57,382 +52,414 @@ const createCollapsibleTabs = < return c } - const Container: React.FC> = ({ - initialTabName, - containerRef, - headerHeight: initialHeaderHeight, - tabBarHeight: initialTabBarHeight = TABBAR_HEIGHT, - snapEnabled = false, - diffClampEnabled = false, - snapThreshold = 0.5, - children, - HeaderComponent, - TabBarComponent = MaterialTabBar, - refMap, - headerContainerStyle, - cancelTranslation, - containerStyle, - lazy, - cancelLazyFadeIn, - tabBarProps, - pagerProps, - }) => { - const windowWidth = useWindowDimensions().width - const firstRender = React.useRef(true) - - const [containerHeight, setContainerHeight] = React.useState< - number | undefined - >(undefined) - const [tabBarHeight, setTabBarHeight] = React.useState( - initialTabBarHeight - ) - const [headerHeight, setHeaderHeight] = React.useState( - initialHeaderHeight - ) - const isScrolling = useSharedValue(false) - const scrollYCurrent = useSharedValue(0) - const scrollY = useSharedValue([...new Array(children.length)].map(() => 0)) - const offset = useSharedValue(0) - const accScrollY = useSharedValue(0) - const oldAccScrollY = useSharedValue(0) - const accDiffClamp = useSharedValue(0) - // @ts-ignore - const tabNames = useSharedValue(Object.keys(refMap)) - const index = useSharedValue( - initialTabName ? tabNames.value.findIndex((n) => n === initialTabName) : 0 - ) - const scrollX = useSharedValue(index.value * windowWidth) - const pagerOpacity = useSharedValue( - initialHeaderHeight === undefined || index.value !== 0 ? 0 : 1 - ) - const isSwiping = useSharedValue(false) - const isSnapping = useSharedValue(false) - const snappingTo = useSharedValue(0) - const [data] = React.useState( - [...new Array(children.length)].map((_, i) => i) - ) - const focusedTab = useDerivedValue(() => { - return tabNames.value[index.value] - }) - const isGliding = useSharedValue(false) - const endDrag = useSharedValue(0) - const calculateNextOffset = useSharedValue(index.value) - - const getItemLayout = React.useCallback( - (_: unknown, index: number) => ({ - length: windowWidth, - offset: windowWidth * index, - index, - }), - [windowWidth] - ) + const Container = React.forwardRef, CollapsibleProps>( + ( + { + initialTabName, + containerRef, + headerHeight: initialHeaderHeight, + tabBarHeight: initialTabBarHeight = TABBAR_HEIGHT, + snapEnabled = false, + diffClampEnabled = false, + snapThreshold = 0.5, + children, + HeaderComponent, + TabBarComponent = MaterialTabBar, + refMap, + headerContainerStyle, + cancelTranslation, + containerStyle, + lazy, + cancelLazyFadeIn, + pagerProps, + }, + ref + ) => { + 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 + >(initialHeaderHeight) + const isScrolling = useSharedValue(false) + const scrollYCurrent = useSharedValue(0) + const scrollY = useSharedValue( + [...new Array(children.length)].map(() => 0) + ) + const offset = useSharedValue(0) + const accScrollY = useSharedValue(0) + const oldAccScrollY = useSharedValue(0) + const accDiffClamp = useSharedValue(0) + // @ts-ignore + const tabNames = useSharedValue(Object.keys(refMap)) + const index = useSharedValue( + initialTabName + ? tabNames.value.findIndex((n) => n === initialTabName) + : 0 + ) + const scrollX = useSharedValue(index.value * windowWidth) + const pagerOpacity = useSharedValue( + initialHeaderHeight === undefined || index.value !== 0 ? 0 : 1 + ) + const isSwiping = useSharedValue(false) + const isSnapping = useSharedValue(false) + const snappingTo = useSharedValue(0) + const [data] = React.useState( + [...new Array(children.length)].map((_, i) => i) + ) + const focusedTab = useDerivedValue(() => { + return tabNames.value[index.value] + }) + const isGliding = useSharedValue(false) + const endDrag = useSharedValue(0) + const calculateNextOffset = useSharedValue(index.value) + + const getItemLayout = React.useCallback( + (_: unknown, index: number) => ({ + length: windowWidth, + offset: windowWidth * index, + index, + }), + [windowWidth] + ) - const indexDecimal = useDerivedValue(() => { - return scrollX.value / windowWidth - }, [windowWidth]) + const indexDecimal = useDerivedValue(() => { + return scrollX.value / windowWidth + }, [windowWidth]) - React.useEffect(() => { - if (firstRender.current) { - if (initialTabName !== undefined && index.value !== 0) { + React.useEffect(() => { + if (firstRender.current) { + if (initialTabName !== undefined && index.value !== 0) { + containerRef.current?.scrollToIndex({ + index: index.value, + animated: false, + }) + } + firstRender.current = false + } else { containerRef.current?.scrollToIndex({ - index: index.value, animated: false, + index: index.value, }) } - firstRender.current = false - } else { - containerRef.current?.scrollToIndex({ - animated: false, - index: index.value, - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [containerRef, index.value, initialTabName, windowWidth]) - - // derived from scrollX - // calculate the next offset and index if swiping - // if scrollX changes from tab press, - // the same logic must be done, but knowing - // the next index in advance - useAnimatedReaction( - () => { - const nextIndex = isSwiping.value - ? Math.round(indexDecimal.value) - : null - return nextIndex - }, - (nextIndex) => { - if (nextIndex !== null && nextIndex !== index.value) { - calculateNextOffset.value = nextIndex + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerRef, index.value, initialTabName, windowWidth]) + + // derived from scrollX + // calculate the next offset and index if swiping + // if scrollX changes from tab press, + // the same logic must be done, but knowing + // the next index in advance + useAnimatedReaction( + () => { + const nextIndex = isSwiping.value + ? Math.round(indexDecimal.value) + : null + return nextIndex + }, + (nextIndex) => { + if (nextIndex !== null && nextIndex !== index.value) { + calculateNextOffset.value = nextIndex + } } - } - ) + ) - useAnimatedReaction( - () => { - return calculateNextOffset.value - }, - (i) => { - if (i !== index.value) { - offset.value = - scrollY.value[index.value] - scrollY.value[i] + offset.value - index.value = i + useAnimatedReaction( + () => { + return calculateNextOffset.value + }, + (i) => { + if (i !== index.value) { + offset.value = + scrollY.value[index.value] - scrollY.value[i] + offset.value + index.value = i + } } - } - ) + ) - const scrollHandlerX = useAnimatedScrollHandler( - { - onScroll: (event) => { - const { x } = event.contentOffset - scrollX.value = x - }, - onBeginDrag: () => { - isSwiping.value = true - }, - onMomentumEnd: () => { - isSwiping.value = false + const scrollHandlerX = useAnimatedScrollHandler( + { + onScroll: (event) => { + const { x } = event.contentOffset + scrollX.value = x + }, + onBeginDrag: () => { + isSwiping.value = true + }, + onMomentumEnd: () => { + isSwiping.value = false + }, }, - }, - [refMap] - ) + [refMap] + ) - // derived from accScrollY, to calculate the accDiffClamp value - useAnimatedReaction( - () => { - return diffClampEnabled ? accScrollY.value - oldAccScrollY.value : 0 - }, - (delta) => { - if (delta) { - if (accScrollY.value <= 0) { - // handle overscroll on ios, when being dragged beyond the top border - accDiffClamp.value = 0 - } else { - const nextValue = accDiffClamp.value + delta - if (delta > 0) { - // scrolling down - accDiffClamp.value = Math.min(headerHeight || 0, nextValue) - } else if (delta < 0) { - // scrolling up - accDiffClamp.value = Math.max(0, nextValue) + // derived from accScrollY, to calculate the accDiffClamp value + useAnimatedReaction( + () => { + return diffClampEnabled ? accScrollY.value - oldAccScrollY.value : 0 + }, + (delta) => { + if (delta) { + if (accScrollY.value <= 0) { + // handle overscroll on ios, when being dragged beyond the top border + accDiffClamp.value = 0 + } else { + const nextValue = accDiffClamp.value + delta + if (delta > 0) { + // scrolling down + accDiffClamp.value = Math.min(headerHeight || 0, nextValue) + } else if (delta < 0) { + // scrolling up + accDiffClamp.value = Math.max(0, nextValue) + } } } - } - }, - [] - ) - - const renderItem = React.useCallback( - ({ index: i }) => { - return ( - - {lazy ? ( - - {children[i]} - - ) : ( - children[i] - )} - - ) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [children, lazy, tabNames.value, cancelLazyFadeIn] - ) + }, + [] + ) - const stylez = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: diffClampEnabled - ? -accDiffClamp.value - : -Math.min(scrollYCurrent.value, headerHeight || 0), - }, - ], - } - }, [diffClampEnabled, headerHeight]) + const renderItem = React.useCallback( + ({ index: i }) => { + return ( + + {lazy ? ( + + {children[i]} + + ) : ( + children[i] + )} + + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [children, lazy, tabNames.value, cancelLazyFadeIn] + ) - const getHeaderHeight = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (headerHeight !== height) { - setHeaderHeight(height) + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: diffClampEnabled + ? -accDiffClamp.value + : -Math.min(scrollYCurrent.value, headerHeight || 0), + }, + ], } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [headerHeight] - ) + }, [diffClampEnabled, headerHeight]) - const getTabBarHeight = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (tabBarHeight !== height) setTabBarHeight(height) - }, - [tabBarHeight] - ) + const getHeaderHeight = React.useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + if (headerHeight !== height) { + setHeaderHeight(height) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [headerHeight] + ) - const onLayout = React.useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height - if (containerHeight !== height) setContainerHeight(height) - }, - [containerHeight] - ) + const getTabBarHeight = React.useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + if (tabBarHeight !== height) setTabBarHeight(height) + }, + [tabBarHeight] + ) - // fade in the pager if the headerHeight is not defined - useAnimatedReaction( - () => { - return ( - (initialHeaderHeight === undefined || initialTabName !== undefined) && - headerHeight !== undefined && - pagerOpacity.value === 0 - ) - }, - (update) => { - if (update) { - pagerOpacity.value = withTiming(1) - } - }, - [headerHeight] - ) + const onLayout = React.useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + if (containerHeight !== height) setContainerHeight(height) + }, + [containerHeight] + ) - const pagerStylez = useAnimatedStyle(() => { - return { - opacity: pagerOpacity.value, - } - }, []) + // fade in the pager if the headerHeight is not defined + useAnimatedReaction( + () => { + return ( + (initialHeaderHeight === undefined || + initialTabName !== undefined) && + headerHeight !== undefined && + pagerOpacity.value === 0 + ) + }, + (update) => { + if (update) { + pagerOpacity.value = withTiming(1) + } + }, + [headerHeight] + ) - const onTabPress = React.useCallback( - (name: T) => { - // simplify logic by preventing index change - // when is scrolling or gliding. - if (!isScrolling.value && !isGliding.value) { - const i = tabNames.value.findIndex((n) => n === name) - calculateNextOffset.value = i - if (name === focusedTab.value) { - // @ts-ignore - if (refMap[name].current?.scrollTo) { - // @ts-ignore - refMap[name].current?.scrollTo({ - x: 0, - y: 0, - animated: true, - }) - // @ts-ignore - } else if (refMap[name].current?.scrollToOffset) { + const pagerStylez = useAnimatedStyle(() => { + return { + opacity: pagerOpacity.value, + } + }, []) + + const onTabPress = React.useCallback( + (name: T) => { + // simplify logic by preventing index change + // when is scrolling or gliding. + if (!isScrolling.value && !isGliding.value) { + const i = tabNames.value.findIndex((n) => n === name) + calculateNextOffset.value = i + if (name === focusedTab.value) { // @ts-ignore - refMap[name].current?.scrollToOffset({ - offset: 0, - animated: true, - }) + if (refMap[name].current?.scrollTo) { + // @ts-ignore + refMap[name].current?.scrollTo({ + x: 0, + y: 0, + animated: true, + }) + // @ts-ignore + } else if (refMap[name].current?.scrollToOffset) { + // @ts-ignore + refMap[name].current?.scrollToOffset({ + offset: 0, + animated: true, + }) + } + } else { + containerRef.current?.scrollToIndex({ animated: true, index: i }) } - } else { - containerRef.current?.scrollToIndex({ animated: true, index: i }) } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [containerRef, refMap] - ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [containerRef, refMap] + ) - return ( - - ({ + setIndex: (index) => { + if (isScrolling.value || isGliding.value) return false + const name = tabNames.value[index] + onTabPress(name) + return true + }, + jumpToTab: (name) => { + if (isScrolling.value || isGliding.value) return false + onTabPress(name) + return true + }, + getFocusedTab: () => { + return tabNames.value[index.value] + }, + getCurrentIndex: () => { + return index.value + }, + }), + [onTabPress] + ) + + return ( + - - {HeaderComponent && ( - - )} - - - {TabBarComponent && ( - - )} - + + {HeaderComponent && ( + + )} + + + {TabBarComponent && ( + + )} + + + item + ''} + renderItem={renderItem} + horizontal + pagingEnabled + onScroll={scrollHandlerX} + showsHorizontalScrollIndicator={false} + getItemLayout={getItemLayout} + scrollEventThrottle={16} + {...pagerProps} + style={[pagerStylez, pagerProps?.style]} + /> - item + ''} - renderItem={renderItem} - horizontal - pagingEnabled - onScroll={scrollHandlerX} - showsHorizontalScrollIndicator={false} - getItemLayout={getItemLayout} - scrollEventThrottle={16} - {...pagerProps} - style={[pagerStylez, pagerProps?.style]} - /> - - - ) - } + + ) + } + ) const Lazy: React.FC<{ startMounted?: boolean diff --git a/src/index.tsx b/src/index.tsx index 21f467e8..95c9d6fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,7 @@ import { CollapsibleProps, RefComponent, ContainerRef, + CollapsibleRef, } from './types' export type { @@ -13,6 +14,7 @@ export type { ContainerRef, MaterialTabBarProps, MaterialTabItemProps, + CollapsibleRef, } export { default as createCollapsibleTabs } from './createCollapsibleTabs' diff --git a/src/types.ts b/src/types.ts index f8184546..7bd40dfb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,15 +9,24 @@ import { } from 'react-native' import Animated from 'react-native-reanimated' -import { MaterialTabBarProps } from './MaterialTabBar' - export type ContainerRef = FlatList export type RefComponent = FlatList | ScrollView export type Ref = React.RefObject -export type TabBarProps = { +export type ParamList = string | number | symbol + +export type RefHandler = { + jumpToTab: (name: T) => boolean + setIndex: (index: number) => boolean + getFocusedTab: () => T + getCurrentIndex: () => number +} + +export type CollapsibleRef = RefHandler | undefined + +export type TabBarProps = { indexDecimal: Animated.SharedValue focusedTab: Animated.SharedValue refMap: Record @@ -26,10 +35,7 @@ export type TabBarProps = { onTabPress: (name: T) => void } -export type CollapsibleProps< - T extends string, - TP extends TabBarProps = MaterialTabBarProps -> = { +export type CollapsibleProps = { initialTabName?: T containerRef: React.RefObject headerHeight?: number @@ -46,7 +52,6 @@ export type CollapsibleProps< cancelTranslation?: boolean lazy?: boolean cancelLazyFadeIn?: boolean - tabBarProps?: Omit> pagerProps?: Omit< RNFlatListProps, | 'data' @@ -60,7 +65,7 @@ export type CollapsibleProps< > } -export type ContextType = { +export type ContextType = { headerHeight: number tabBarHeight: number snapEnabled: boolean