PedroBern / react-native-collapsible-tab-view

A cross-platform Collapsible Tab View component for React Native
MIT License
875 stars 171 forks source link

Scrolling over a Touchable component #144

Closed dan-fein closed 3 years ago

dan-fein commented 3 years ago

Hi is it possible to set (considering the pointerEvents we need) a touchable opacity that could exist in the header as both a scrollable area (hitting it when attempting to scroll) as well as being an active button?

I can't seem to get buttons to work in my header. They exist in either active button states or scrollable, but not bother at the same time.

dan-fein commented 3 years ago

Any thoughts on this? To be honest I fear it makes the header practically unusable if the two options are touchable or scrollable and they're mutually exclusive.

andreialecu commented 3 years ago

As long as the buttons are small enough, it can be scrolled from regions around them.

You need to experiment with pointerEvents box-none: https://reactnative.dev/docs/view#pointerevents

Scrolling from touchable regions I don't think is possible. FWIW the Reddit app on iOS has a similar collapsible header and initiating a scroll from the buttons in the header is impossible there either.

PedroBern commented 3 years ago

Did you try gesture handler buttons?

dan-fein commented 3 years ago

I just took the same header and same tab data and popped it out of the RN Collapsible Tab View component to see if ScrollView scrolling might work, and it does. I think this might be more of an implementation thing than something that has to do with the ability to scroll on a touchable component. Will keep exploring options and the gesture handler buttons.

andreialecu commented 3 years ago

If you put the header within the scrollview it will work, you can touch things just like you can in regular scrollview content.

However the header needs to be floating over the scrollview in order for anything to work (sticky tab bar, sticky header on scroll, and the fact that the header stays pinned and doesn't swipe with the pane left/right).

Without those features there's not much point to this library.

dan-fein commented 3 years ago

Right, the point there is that in general an element being both touchable & scrollable simultaneously, so the question is just, why within this library are those mutually exclusive? Can that be achieved, where buttons are both usable and on touch can be scrollable? Seems it should be achievable since it's proven to work elsewhere

andreialecu commented 3 years ago

The header floats over the scrollview. The scrollview can only scroll if the touch is within it. The header can either catch the touch or become transparent to it via pointerEvents so that the scrollview underneath catches it.

In a purely native app this could be handled, via some effort, but with react native I don't know of a way to do it.

dan-fein commented 3 years ago

That makes sense, I'll see what can be done, I guess at this point it's my app that'll need reworking as I think this component is great and would like to continue to use it, thanks a lot!

robertpreoteasa commented 3 years ago

@danielfein did you find any solution? We have the same issue within our app and didn't manage to solve it yet.

pskryuchkov commented 3 years ago

Same problem

Aryk commented 2 years ago

I'm having the same issue, but I also want to be able to have a photo carousel as well on top, so seems like I have to choose between the two. Anyone know how IG does it? You can press on the buttons at the top and scroll the entire container along with having an infinite scroll list in each container.

nihilenz commented 2 years ago

Any updates? It would be the last feature missing for emulating apps like Instagram or Twitter 🙏

TimDR-1356 commented 2 years ago

I have the solution!

On your header place: pointerEvents="box-none" instead of pointerEvents="none"

Then use BaseButton from react-native-gesture-handler instead of normal button.

My header Code

import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header.

This worked for me.

nihilenz commented 2 years ago

I have the solution!

On your header place: pointerEvents="box-none" instead of pointerEvents="none"

Then use BaseButton from react-native-gesture-handler instead of normal button.

My header Code

import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header.

This worked for me.

Hello, are you sure if you touch up inside the BaseButton area you can also scroll the View by holding down your finger?

TimDR-1356 commented 2 years ago

I have the solution! On your header place: pointerEvents="box-none" instead of pointerEvents="none" Then use BaseButton from react-native-gesture-handler instead of normal button. My header Code import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header. This worked for me.

Hello, are you sure if you touch up inside the BaseButton area you can also scroll the View by holding down your finger?

For me it worked try it yourself with the code I gave. The code is my collapsible header.

Aryk commented 2 years ago

@TimDR-1356 How would you do other sort of touchable components like a horizontal scrollview? This seems more of a hack then a real solution...

TimDR-1356 commented 2 years ago

@TimDR-1356 How would you do other sort of touchable components like a horizontal scrollview? This seems more of a hack then a real solution...

Idk I tried it with scrollview but didn't work maybe flatlist?

abdifardin commented 2 years ago

I think its a deal breaker to use this package It makes this package totally useless as it doesn't support touchable elements at the header I just switched to another package

Aryk commented 2 years ago

I think its a deal breaker to use this package It makes this package totally useless as it doesn't support touchable elements at the header I just switched to another package

Which package did you try?

abdifardin commented 2 years ago

I think its a deal breaker to use this package It makes this package totally useless as it doesn't support touchable elements at the header I just switched to another package

Which package did you try?

I tried react-native-tab-view again You can use get ride of nesting virtualizedList inside scrollView error by using this hack

import React from 'react';
import { FlatList } from 'react-native';
import { Props } from './model';

const ScrollViewComponent: Props = props => {
return (
    <FlatList
    {...props}
    data={[]}
    keyExtractor={(e, i) => 'dom' + i.toString()}
    ListEmptyComponent={null}
    renderItem={null}
    ListHeaderComponent={() => (
        <>{props.children}</>
    )}
    />
);
};

export default ScrollViewComponent;

Then try using react-native-tab-view version 2.16.0 instead of the latest version because the latest version shrink the tab inside the scrollView

It will let you have a full functional header alongside tabs which contains Flatlist and other type of elements Its not 100% same as this package, but it works

nihilenz commented 2 years ago

I have the solution! On your header place: pointerEvents="box-none" instead of pointerEvents="none" Then use BaseButton from react-native-gesture-handler instead of normal button. My header Code import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header. This worked for me.

Hello, are you sure if you touch up inside the BaseButton area you can also scroll the View by holding down your finger?

For me it worked try it yourself with the code I gave. The code is my collapsible header.

Yes, I also tried your code, but:

TimDR-1356 commented 2 years ago

I have the solution! On your header place: pointerEvents="box-none" instead of pointerEvents="none" Then use BaseButton from react-native-gesture-handler instead of normal button. My header Code import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header. This worked for me.

Hello, are you sure if you touch up inside the BaseButton area you can also scroll the View by holding down your finger?

For me it worked try it yourself with the code I gave. The code is my collapsible header.

Yes, I also tried your code, but:

  • Text component is scrollable if I add pointerEvents="none" and it's ok
  • BaseButton component is not scrollable because the touch event prevails and does not allow to scroll the component I don't understand how it works for you 🤷

I am using V2 because I use react navigation, maybe that is the difference?

deflexable commented 1 year ago

has anyone found a solution to this yet? 👀

pratishshr commented 1 year ago

any update on this? can't seem to scroll with the buttons in the header, and our buttons are pretty large.

bpeck81 commented 1 year ago

I think its a deal breaker to use this package It makes this package totally useless as it doesn't support touchable elements at the header I just switched to another package

Which package did you try?

I tried react-native-tab-view again You can use get ride of nesting virtualizedList inside scrollView error by using this hack

import React from 'react';
import { FlatList } from 'react-native';
import { Props } from './model';

const ScrollViewComponent: Props = props => {
return (
    <FlatList
    {...props}
    data={[]}
    keyExtractor={(e, i) => 'dom' + i.toString()}
    ListEmptyComponent={null}
    renderItem={null}
    ListHeaderComponent={() => (
        <>{props.children}</>
    )}
    />
);
};

export default ScrollViewComponent;

Then try using react-native-tab-view version 2.16.0 instead of the latest version because the latest version shrink the tab inside the scrollView

It will let you have a full functional header alongside tabs which contains Flatlist and other type of elements Its not 100% same as this package, but it works
Adding on to this. It works with a simple scrollview. The only tradeoff is that the tab bar doesn't stick to the top. I'll take that over not being able to scroll or press things in the header. Hope I help save people some time and pain.

Use this: yarn add react-native-tab-view@2.16.0

My code ended up looking super simple:

import { TabBar, TabView } from 'react-native-tab-view'

const MyComponent = ()=>{

const [index, setIndex] = useState(0)
return(
<View style={{ flex: 1 }}>
<MyPermanentTopHeader />
<ScrollView>
    <MyCollapsibleHeader />
    <TabView
        navigationState={{ index, routes: [{ key: 'tab1', title: 'tab2' }, { key: 'tab1', title: 'tab2' }] }}
        renderScene={({ route }) => {
            switch (route.key) {
                case 'tab1':
                    return <FlatList (with all my data) />
                case 'tab2':
                    return <FlatList (with all my data ) />
                default:
                    return null;
            }
        }}
        onIndexChange={setIndex}
        renderTabBar={props => <TabBar
            {...props}
            renderLabel={({ route }) => (
                <Text style={{MyStyling}}>
                    {route.title}
                </Text>
            )}
            indicatorStyle={{ backgroundColor: 'blue' }}
            style={{ backgroundColor: 'white' }}
        />}
    />
</ScrollView>
</View>
)
see2ever commented 1 year ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}
senghuotlay commented 1 year ago

@see2ever it works to a certain extend, but it doesn't provides a snappy feels to it :(

see2ever commented 1 year ago

@senghuotlay ends up with react-native-tab-view at v2.16.0 which can wrap a flatlist without warning

senghuotlay commented 1 year ago

@see2ever could u share me your solution please

see2ever commented 1 year ago

@senghuotlay fyi, https://github.com/see2ever/react-native-old-tab-view

just copy the src folder, and modify it

anh-qali commented 10 months ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}

you saved my ass bro. thanks a lot 👍

marcos-vinicius-mafei commented 9 months ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}

That is a pretty good solution, but it kind of disables the pull-to-refresh 😕

antochan commented 7 months ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}

Pretty awesome, definitely works but like others have mentioned it isn't the smoothest experience. I'm surprised there isn't more focus on this issue as it seems to be a deal breaker for many. I also didn't really like react-native-tab-view because you're forced to use an older version and the content sizes across the different tabs are also a bit janky.

Anyone else have an implementation that worked well for them?

VictorioMolina commented 6 months ago

@antochan In my case, I had this issue solved with an old version of react-native-tab-views. But after migrating to the new React Native arch and upgrading reanimated, due to incompatibilities, I ended up trying this lib.

The error you mention is called 'flexbox equal heights'. To solve it, just apply a display: none to the inactive tab scenes.

Btw, this library needs a way to handle this problem. It sucks.

mrtawil commented 5 months ago

+1

anasvemmully commented 5 months ago

I have the solution!

On your header place: pointerEvents="box-none" instead of pointerEvents="none"

Then use BaseButton from react-native-gesture-handler instead of normal button.

My header Code

import {BaseButton} from 'react-native-gesture-handler';

  <View style={{backgroundColor: 'white'}} pointerEvents="box-none">
    <Text>COLLAPSIBLE</Text>
    <BaseButton onPress={()=> {'your code'}} rippleColor='transparent'>
      <Text style={{fontSize: 40}}>Test Button</Text>
    </BaseButton>
  </View>

Now you can scroll and use the buttons in the header.

This worked for me.

This thing works for me I put pointeEvent="box-none" to every child View and replaced TouchableOpacity with BaseButton. now it works fine. I thought it works just by wrapping the Parent View only. :grin:

VovaParamonov commented 3 months ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}

Works fine! You should use it with version 6.1.4

rahul4452 commented 2 months ago

in src/Container.tsx patch

diff --git a/node_modules/react-native-collapsible-tab-view/src/Container.tsx b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
index 1023290..403432e 100644
--- a/node_modules/react-native-collapsible-tab-view/src/Container.tsx
+++ b/node_modules/react-native-collapsible-tab-view/src/Container.tsx
@@ -5,16 +5,25 @@ import {
   useWindowDimensions,
   View,
 } from 'react-native'
+import {
+  PanGestureHandler,
+  GestureHandlerRootView,
+  PanGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   runOnJS,
   runOnUI,
+  Extrapolate,
+  interpolate,
   useAnimatedReaction,
   useAnimatedStyle,
   useDerivedValue,
   useSharedValue,
   withDelay,
   withTiming,
+  withDecay,
+  useAnimatedGestureHandler,
 } from 'react-native-reanimated'

 import { Context, TabNameContext } from './Context'
@@ -119,6 +128,7 @@ export const Container = React.memo(
       const oldAccScrollY: ContextType['oldAccScrollY'] = useSharedValue(0)
       const accDiffClamp: ContextType['accDiffClamp'] = useSharedValue(0)
       const scrollYCurrent: ContextType['scrollYCurrent'] = useSharedValue(0)
+      const isTopContainerSynced = useSharedValue(true)
       const scrollY: ContextType['scrollY'] = useSharedValue(
         tabNamesArray.map(() => 0)
       )
@@ -346,6 +356,63 @@ export const Container = React.memo(
         [onTabPress]
       )

+      const headerYCurrent = useSharedValue(0)
+
+      const gestureHandler = useAnimatedGestureHandler<
+        PanGestureHandlerGestureEvent,
+        { start: number }
+      >({
+        onStart: (_, ctx) => {
+          ctx.start = scrollY.value[index.value]
+        },
+        onActive: (event, ctx) => {
+          headerYCurrent.value = interpolate(
+            ctx.start - event.translationY,
+            [0, headerScrollDistance.value],
+            [0, headerScrollDistance.value],
+            Extrapolate.CLAMP
+          )
+        },
+        onEnd: (_) => {
+          headerYCurrent.value = withDecay(
+            {
+              velocity: -_.velocityY,
+              clamp: [0, headerScrollDistance.value],
+              deceleration: IS_IOS ? 0.998 : 0.99,
+            },
+            (finished) => {
+              isTopContainerSynced.value = finished || false
+            }
+          )
+        },
+      })
+
+      /* 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 isTopContainerSynced.value
+        },
+        (result) => {
+          if (!result) {
+            resyncTabScroll()
+          }
+        }
+      )
+
+      useAnimatedReaction(
+        () => headerYCurrent.value,
+        (y) => {
+          scrollY.value[index.value] = y
+          scrollYCurrent.value = y
+
+          for (const name of tabNamesArray) {
+            scrollToImpl(refMap[name], 0, y - contentInset.value, false)
+          }
+        }
+      )
+
       return (
         <Context.Provider
           value={{
@@ -389,22 +456,29 @@ export const Container = React.memo(
                 !cancelTranslation && stylez,
               ]}
             >
-              <View
-                style={[styles.container, styles.headerContainer]}
-                onLayout={getHeaderHeight}
-                pointerEvents="box-none"
-              >
-                {renderHeader &&
-                  renderHeader({
-                    containerRef,
-                    index,
-                    tabNames: tabNamesArray,
-                    focusedTab,
-                    indexDecimal,
-                    onTabPress,
-                    tabProps,
-                  })}
-              </View>
+              <GestureHandlerRootView>
+                <PanGestureHandler
+                  onGestureEvent={gestureHandler}
+                  hitSlop={{ left: -20 }}
+                >
+                  <Animated.View
+                    style={[styles.container, styles.headerContainer]}
+                    onLayout={getHeaderHeight}
+                    pointerEvents="box-none"
+                  >
+                    {renderHeader &&
+                      renderHeader({
+                        containerRef,
+                        index,
+                        tabNames: tabNamesArray,
+                        focusedTab,
+                        indexDecimal,
+                        onTabPress,
+                        tabProps,
+                      })}
+                  </Animated.View>
+                </PanGestureHandler>
+              </GestureHandlerRootView>
               <View
                 style={[styles.container, styles.tabBarContainer]}
                 onLayout={getTabBarHeight}

Hey thanks for the patch. It is not that smooth. Any of you have any working code example in which it work good ? @nihilenz @VovaParamonov