diff --git a/example/src/App.tsx b/example/src/App.tsx index ca9c0eef..58c27c38 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -26,6 +26,7 @@ import Ref from './Ref' import RevealHeaderOnScroll from './RevealHeaderOnScroll' import RevealHeaderOnScrollSnap from './RevealHeaderOnScrollSnap' import ScrollOnHeader from './ScrollOnHeader' +import ScrollOnHeaderWithTouchables from './ScrollOnHeaderWithTouchables' import ScrollableTabs from './ScrollableTabs' import Snap from './Snap' import StartOnSpecificTab from './StartOnSpecificTab' @@ -51,6 +52,7 @@ const EXAMPLE_COMPONENTS: ExampleComponentType[] = [ AnimatedHeader, AndroidSharedPullToRefresh, HeaderOverscrollExample, + ScrollOnHeaderWithTouchables, ] const ExampleList: React.FC = () => { diff --git a/example/src/ScrollOnHeaderWithTouchables.tsx b/example/src/ScrollOnHeaderWithTouchables.tsx new file mode 100644 index 00000000..20150bdf --- /dev/null +++ b/example/src/ScrollOnHeaderWithTouchables.tsx @@ -0,0 +1,90 @@ +import React, { useMemo } from 'react' +import { + StyleSheet, + View, + FlatList, + Text, + Alert, + TouchableOpacity, +} from 'react-native' + +import { TabBarProps } from '../../src/types' +import ExampleComponent from './Shared/ExampleComponent' +import { ExampleComponentType } from './types' + +const title = 'Scroll On Header with Touchables' + +const SLIDER_ITEM_SIZE = 200 +const HEADER_HEIGHT = SLIDER_ITEM_SIZE * 2 + +const data = Array.from({ length: 15 }).map((_, i) => i.toString()) + +const styles = StyleSheet.create({ + item: { + width: SLIDER_ITEM_SIZE, + height: SLIDER_ITEM_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + itemButton: { + padding: 16, + backgroundColor: 'white', + }, + itemName: { + fontSize: 48, + color: 'black', + }, + itemSeparator: { width: 4 }, +}) + +const Slider = ({ isReversed = false }) => { + const config = useMemo( + () => ({ + data: isReversed ? [...data].reverse() : data, + backgroundColor: isReversed ? 'purple' : 'orangered', + }), + [isReversed] + ) + + return ( + item} + showsHorizontalScrollIndicator={false} + ItemSeparatorComponent={() => } + bounces={false} + renderItem={({ item }) => ( + + Alert.alert(`Touchable number ${item} pressed`)} + > + {item} + + + )} + /> + ) +} + +const NewHeader: React.FC = () => { + return ( + + + + + ) +} + +const DefaultExample: ExampleComponentType = () => { + return ( + + ) +} + +DefaultExample.title = title + +export default DefaultExample diff --git a/src/Container.tsx b/src/Container.tsx index 9395997f..2a2edefa 100644 --- a/src/Container.tsx +++ b/src/Container.tsx @@ -3,7 +3,6 @@ import { LayoutChangeEvent, StyleSheet, useWindowDimensions, - View, } from 'react-native' import Animated, { runOnJS, @@ -18,9 +17,12 @@ import Animated, { } from 'react-native-reanimated' import { Context, TabNameContext } from './Context' +import { HeaderContainer } from './HeaderContainer' import { Lazy } from './Lazy' import { MaterialTabBar, TABBAR_HEIGHT } from './MaterialTabBar' import { Tab } from './Tab' +import { TabBarContainer } from './TabBarContainer' +import { TopContainer } from './TopContainer' import { AnimatedFlatList, IS_IOS, @@ -125,6 +127,7 @@ export const Container = React.memo( const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0) const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0) const isScrolling: ContextType['isScrolling'] = useSharedValue(0) + const isSlidingTopContainer = useSharedValue(false) const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0) const scrollY: ContextType['scrollY'] = useSharedValue( tabNamesArray.map(() => 0) @@ -325,34 +328,6 @@ export const Container = React.memo( : -Math.min(scrollYCurrent.value, headerScrollDistance.value) }, [revealHeaderOnScroll]) - const stylez = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: headerTranslateY.value, - }, - ], - } - }, [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 @@ -388,8 +363,12 @@ export const Container = React.memo( const onTabPress = React.useCallback( (name: TabName) => { // simplify logic by preventing index change - // when is scrolling or gliding. - if (!isScrolling.value && !isGliding.value) { + // when is scrolling, gliding, or scrolling the top container + if ( + !isScrolling.value && + !isGliding.value && + !isSlidingTopContainer.value + ) { const i = tabNames.value.findIndex((n) => n === name) if (name === focusedTab.value) { @@ -473,6 +452,7 @@ export const Container = React.memo( headerTranslateY, width, allowHeaderOverscroll, + isSlidingTopContainer, }} > - - - {renderHeader && - renderHeader({ - containerRef, - index, - tabNames: tabNamesArray, - focusedTab, - indexDecimal, - onTabPress, - tabProps, - })} - - - {renderTabBar && - renderTabBar({ - containerRef, - index, - tabNames: tabNamesArray, - focusedTab, - indexDecimal, - width, - onTabPress, - tabProps, - })} - - + + + + + {headerHeight !== undefined && ( = Pick< + CollapsibleProps, + 'renderHeader' +> & + Pick, 'containerRef' | 'onTabPress' | 'tabProps'> & { + tabNamesArray: TabName[] + } + +export const HeaderContainer: React.FC = ({ + renderHeader, + containerRef, + tabNamesArray, + onTabPress, + tabProps, +}) => { + const { headerHeight, focusedTab, index, indexDecimal } = useTabsContext() + + const getHeaderHeight = React.useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + if (headerHeight.value !== height) { + headerHeight.value = height + } + }, + [headerHeight] + ) + + return ( + + {renderHeader && + renderHeader({ + containerRef, + index, + tabNames: tabNamesArray, + focusedTab, + indexDecimal, + onTabPress, + tabProps, + })} + + ) +} + +const styles = StyleSheet.create({ + container: { + zIndex: 2, + flex: 1, + }, +}) diff --git a/src/TabBarContainer.tsx b/src/TabBarContainer.tsx new file mode 100644 index 00000000..0a17d755 --- /dev/null +++ b/src/TabBarContainer.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { LayoutChangeEvent, StyleSheet, View } from 'react-native' + +import { useTabsContext } from './hooks' +import { CollapsibleProps, TabBarProps, TabName } from './types' + +type TabBarContainerProps = Pick< + CollapsibleProps, + 'renderTabBar' | 'width' +> & + Pick, 'onTabPress' | 'tabProps' | 'containerRef'> & { + tabNamesArray: TabName[] + } + +export const TabBarContainer: React.FC = ({ + renderTabBar, + onTabPress, + tabProps, + tabNamesArray, + containerRef, + width, +}) => { + const { tabBarHeight, focusedTab, index, indexDecimal } = useTabsContext() + + const getTabBarHeight = React.useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + if (tabBarHeight.value !== height) tabBarHeight.value = height + }, + [tabBarHeight] + ) + + return ( + + {renderTabBar && + renderTabBar({ + containerRef, + index, + tabNames: tabNamesArray, + focusedTab, + indexDecimal, + width, + onTabPress, + tabProps, + })} + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, zIndex: 1 }, +}) diff --git a/src/TopContainer.tsx b/src/TopContainer.tsx new file mode 100644 index 00000000..03e9bfbd --- /dev/null +++ b/src/TopContainer.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import { StyleSheet } from 'react-native' +import { + PanGestureHandler, + PanGestureHandlerGestureEvent, +} from 'react-native-gesture-handler' +import Animated, { + useAnimatedStyle, + Extrapolate, + interpolate, + useAnimatedGestureHandler, + withDecay, + useAnimatedReaction, + useSharedValue, +} from 'react-native-reanimated' + +import { IS_IOS, scrollToImpl } from './helpers' +import { useOnScroll, useSnap, useTabsContext } from './hooks' +import { CollapsibleProps } from './types' + +type TabBarContainerProps = Pick< + CollapsibleProps, + 'headerContainerStyle' | 'cancelTranslation' +> + +export const TopContainer: React.FC = ({ + children, + headerContainerStyle, + cancelTranslation, +}) => { + const { + headerTranslateY, + revealHeaderOnScroll, + isSlidingTopContainer, + scrollYCurrent, + contentInset, + refMap, + tabNames, + index, + headerScrollDistance, + } = useTabsContext() + + const isSlidingTopContainerPrev = useSharedValue(false) + const isTopContainerOutOfSync = useSharedValue(false) + + const tryToSnap = useSnap() + const onScroll = useOnScroll() + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: headerTranslateY.value, + }, + ], + } + }, [revealHeaderOnScroll]) + + const syncActiveTabScroll = (position: number) => { + 'worklet' + + scrollToImpl(refMap[tabNames.value[index.value]], 0, position, false) + } + + const gestureHandler = useAnimatedGestureHandler< + PanGestureHandlerGestureEvent, + { startY: number } + >({ + onActive: (event, ctx) => { + if (!isSlidingTopContainer.value) { + ctx.startY = scrollYCurrent.value + isSlidingTopContainer.value = true + return + } + + scrollYCurrent.value = interpolate( + -event.translationY + ctx.startY, + [0, headerScrollDistance.value], + [0, headerScrollDistance.value], + Extrapolate.CLAMP + ) + }, + onEnd: (event, ctx) => { + if (!isSlidingTopContainer.value) return + + ctx.startY = 0 + scrollYCurrent.value = withDecay( + { + velocity: -event.velocityY, + clamp: [0, headerScrollDistance.value], + deceleration: IS_IOS ? 0.998 : 0.99, + }, + (finished) => { + isSlidingTopContainer.value = false + isTopContainerOutOfSync.value = finished + } + ) + }, + }) + + //Keeps updating the active tab scroll as we scroll on the top container element + useAnimatedReaction( + () => scrollYCurrent.value - contentInset.value, + (nextPosition, previousPosition) => { + if (nextPosition !== previousPosition && isSlidingTopContainer.value) { + syncActiveTabScroll(nextPosition) + onScroll() + } + } + ) + + /* Syncs the scroll of the active tab once we complete the scroll gesture + on the header and the decay animation completes with success + */ + useAnimatedReaction( + () => { + return ( + isSlidingTopContainer.value !== isSlidingTopContainerPrev.value && + isTopContainerOutOfSync.value + ) + }, + (result) => { + isSlidingTopContainerPrev.value = isSlidingTopContainer.value + + if (!result) return + if (isSlidingTopContainer.value === true) return + + syncActiveTabScroll(scrollYCurrent.value - contentInset.value) + onScroll() + tryToSnap() + + isTopContainerOutOfSync.value = false + } + ) + + return ( + + + {children} + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + zIndex: 100, + width: '100%', + backgroundColor: 'white', + shadowColor: '#000000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.23, + shadowRadius: 2.62, + elevation: 4, + }, +}) diff --git a/src/hooks.tsx b/src/hooks.tsx index 7c8b736b..081c5b52 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -225,6 +225,7 @@ export function useScroller() { // console.log( // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset.value}` // ) + scrollToImpl(ref, x, y - contentInset.value, animated) }, [contentInset] @@ -233,60 +234,23 @@ export function useScroller() { return scroller } -export const useScrollHandlerY = (name: TabName) => { +export const useSnap = () => { const { accDiffClamp, - focusedTab, snapThreshold, revealHeaderOnScroll, refMap, - tabNames, - index, - headerHeight, - contentInset, - containerHeight, scrollYCurrent, - scrollY, - isScrolling, - isGliding, - oldAccScrollY, - accScrollY, - offset, headerScrollDistance, isSnapping, snappingTo, - contentHeights, - indexDecimal, - allowHeaderOverscroll, + focusedTab, } = 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 - * drag, but the onMomentumEnd has never triggered, we need to manually - * call it to sync the scenes. - */ - const afterDrag = useSharedValue(0) - - const tabIndex = useMemo(() => tabNames.value.findIndex((n) => n === name), [ - tabNames, - name, - ]) - const scrollTo = useScroller() - const onMomentumEnd = () => { + const tryToSnap = useCallback(() => { 'worklet' - if (!enabled.value) return if (typeof snapThreshold === 'number') { if (revealHeaderOnScroll) { @@ -317,11 +281,11 @@ export const useScrollHandlerY = (name: TabName) => { if (scrollYCurrent.value < headerScrollDistance.value) { scrollTo( - refMap[name], + refMap[focusedTab.value], 0, headerScrollDistance.value, true, - `[${name}] sticky snap up` + `[${focusedTab.value}] sticky snap up` ) } } @@ -339,21 +303,147 @@ export const useScrollHandlerY = (name: TabName) => { ) { // snap down snappingTo.value = 0 - scrollTo(refMap[name], 0, 0, true, `[${name}] snap down`) + scrollTo( + refMap[focusedTab.value], + 0, + 0, + true, + `[${focusedTab.value}] snap down` + ) } else if (scrollYCurrent.value <= headerScrollDistance.value) { // snap up snappingTo.value = headerScrollDistance.value + scrollTo( - refMap[name], + refMap[focusedTab.value], 0, headerScrollDistance.value, true, - `[${name}] snap up` + `[${focusedTab.value}] snap up` ) } isSnapping.value = false } } + }, [ + accDiffClamp, + focusedTab, + isSnapping, + snappingTo, + headerScrollDistance, + scrollTo, + refMap, + revealHeaderOnScroll, + snapThreshold, + scrollYCurrent, + ]) + + return tryToSnap +} + +export const useOnScroll = () => { + const { + accDiffClamp, + revealHeaderOnScroll, + index, + scrollYCurrent, + scrollY, + oldAccScrollY, + accScrollY, + offset, + headerScrollDistance, + isSnapping, + } = useTabsContext() + + const onScroll = useCallback(() => { + 'worklet' + + scrollY.value[index.value] = scrollYCurrent.value + oldAccScrollY.value = accScrollY.value + accScrollY.value = scrollY.value[index.value] + offset.value + + if (!isSnapping.value && revealHeaderOnScroll) { + const delta = accScrollY.value - oldAccScrollY.value + const nextValue = accDiffClamp.value + delta + if (delta > 0) { + // scrolling down + accDiffClamp.value = Math.min(headerScrollDistance.value, nextValue) + } else if (delta < 0) { + // scrolling up + accDiffClamp.value = Math.max(0, nextValue) + } + } + }, [ + scrollY, + oldAccScrollY, + index, + accDiffClamp, + accScrollY, + headerScrollDistance, + revealHeaderOnScroll, + isSnapping, + offset, + scrollYCurrent, + ]) + + return onScroll +} + +export const useScrollHandlerY = (name: TabName) => { + const { + accDiffClamp, + focusedTab, + snapThreshold, + revealHeaderOnScroll, + refMap, + tabNames, + headerHeight, + contentInset, + containerHeight, + scrollYCurrent, + scrollY, + isScrolling, + isGliding, + headerScrollDistance, + isSnapping, + snappingTo, + contentHeights, + indexDecimal, + allowHeaderOverscroll, + isSlidingTopContainer, + } = 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 + * drag, but the onMomentumEnd has never triggered, we need to manually + * call it to sync the scenes. + */ + const afterDrag = useSharedValue(0) + + const tabIndex = useMemo(() => tabNames.value.findIndex((n) => n === name), [ + tabNames, + name, + ]) + + const scrollTo = useScroller() + const tryToSnap = useSnap() + const onScroll = useOnScroll() + + const onMomentumEnd = () => { + 'worklet' + if (!enabled.value) return + + tryToSnap() isGliding.value = false } @@ -365,7 +455,7 @@ export const useScrollHandlerY = (name: TabName) => { const scrollHandler = useAnimatedScrollHandler( { onScroll: (event) => { - if (!enabled.value) return + if (!enabled.value || isSlidingTopContainer.value) return if (focusedTab.value === name) { if (IS_IOS) { @@ -385,24 +475,7 @@ export const useScrollHandlerY = (name: TabName) => { scrollYCurrent.value = y } - scrollY.value[index.value] = scrollYCurrent.value - oldAccScrollY.value = accScrollY.value - accScrollY.value = scrollY.value[index.value] + offset.value - - if (!isSnapping.value && revealHeaderOnScroll) { - const delta = accScrollY.value - oldAccScrollY.value - const nextValue = accDiffClamp.value + delta - if (delta > 0) { - // scrolling down - accDiffClamp.value = Math.min( - headerScrollDistance.value, - nextValue - ) - } else if (delta < 0) { - // scrolling up - accDiffClamp.value = Math.max(0, nextValue) - } - } + onScroll() isScrolling.value = 1 @@ -421,6 +494,8 @@ export const useScrollHandlerY = (name: TabName) => { // ensure the header stops snapping cancelAnimation(accDiffClamp) + // ensure decaying header animation stops + cancelAnimation(scrollYCurrent) isSnapping.value = false isScrolling.value = 0 @@ -477,7 +552,8 @@ export const useScrollHandlerY = (name: TabName) => { !isSnapping.value && !isScrolling.value && !isGliding.value && - !enabled.value + !enabled.value && + !isSlidingTopContainer.value ) { return false } diff --git a/src/types.ts b/src/types.ts index 363ea968..2d4e2591 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export type TabBarProps = { containerRef: React.RefObject onTabPress: (name: T) => void tabProps: TabsWithProps + width?: number } export type IndexChangeEventData = { @@ -225,6 +226,7 @@ export type ContextType = { */ scrollX: Animated.SharedValue isGliding: Animated.SharedValue + isSlidingTopContainer: Animated.SharedValue isSnapping: Animated.SharedValue /** * The next snapping value, used only with diffClamp.