Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ScrollView in modal prevents modal from dismissing sometimes #7154

Open
skhavari opened this issue Dec 2, 2018 · 31 comments
Open

ScrollView in modal prevents modal from dismissing sometimes #7154

skhavari opened this issue Dec 2, 2018 · 31 comments

Comments

@skhavari
Copy link

skhavari commented Dec 2, 2018

Current Behavior

  • Have a StackNavigator, modal mode
  • Expand the gesture response distance to the full height of the window
  • Have a modal containing a scroll view with content larger than the window height

If the scroll view is at the top (offset 0) and use user swipes down over the scroll view, sometimes the gesture triggers an over-scroll and sometimes a dismissal of the modal. A tap and brief hold followed by swipe down triggers a dismissal as expected. A tap and quick swipe down has variable results; sometimes a dismissal, sometimes an overs croll (bounce=true).

Expected Behavior

When the scroll offset is 0, every swipe down over the scroll view should being transitioning to dismiss the modal.

How to reproduce

Simple repro here: https://snack.expo.io/@skhavari/swipe-to-dismiss

Thanks

@brentvatne
Copy link
Member

it's not really possible for react-navigation to guess when you may or may not want to close the modal depending on other gestures you have inside of a scene, you need to define that behavior for yourself.

you can access the gesture context like this: https://github.com/react-navigation/react-navigation-stack/blob/5f157cbc16f2dd15363304ab3c78663b0ed36de0/example/src/GestureInteraction.js#L55-L56 then you can use react-native-gesture-handler tools to make this behave how you like.

@brentvatne brentvatne reopened this Dec 3, 2018
@brentvatne
Copy link
Member

reopening because i think it's important to have a good example of how to do this

@brentvatne brentvatne self-assigned this Dec 4, 2018
@RyanTimesTen
Copy link

Hey @skhavari! If you still need help with this, check out this snack: https://snack.expo.io/@rgilbert/scrollable-modal-with-dismissal

@satya164 satya164 transferred this issue from react-navigation/react-navigation May 19, 2019
@L-U-C-K-Y
Copy link

L-U-C-K-Y commented Oct 16, 2019

@ryan-gilb many thanks for your solution!

I just tried out your snack and maybe I'm mistaken, but does it not allow to dismiss the modal by pulling down in the scroll view itself as iOS 13 would do?
I think that this would be important for the user-experience to achieve.

As a reference @iamdavidmartin wrote this article that illustrates the problem (see the left demo): https://medium.com/@iamdavidmartin/react-native-navigation-gestures-could-become-a-major-problem-on-ios-13-5180d02a3cc8

Thanks for your help!

Edit: Similar to the workaround in the post, with this repo we should be able to circumvent this limitation: https://github.com/osdnk/react-native-reanimated-bottom-sheet

@RodolfoGS
Copy link

Hi guys, I have the same issue. Could you find a solution to this problem? I'm in iOS 13 with a modal and a ScrollView and I can't dismiss the modal swiping down.

@alexbchr
Copy link

alexbchr commented Jan 7, 2020

Same issue here, is this issue prioritized or should we rely on another lib like https://github.com/osdnk/react-native-reanimated-bottom-sheet ?

@satya164 satya164 transferred this issue from react-navigation/stack Feb 24, 2020
@ferrannp
Copy link

I've been trying to figure something here based on https://snack.expo.io/@rgilbert/scrollable-modal-with-dismissal.

Something like:

Screen.navigationOptions = ({ navigation }) => ({
  gestureEnabled: navigation.getParam('gestureEnabled', true),
  gestureResponseDistance: {
    vertical: screenHeight
  }
})
const isGestureEnabled = getParam('gestureEnabled')
const scrolledTop = useRef(true)
 ...
const onScroll = useCallback(
  ({ nativeEvent }) => {
    scrolledTop.current = nativeEvent.contentOffset.y <= 0
    if (isGestureEnabled !== scrolledTop.current) {
      setParams({ gestureEnabled: scrolledTop.current })
    }
  },
  [isGestureEnabled]
)
<GestureHandlerRefContext.Consumer>
  {ref => (
    <FlatList // from 'react-native-gesture-handler'
      waitFor={shouldWaitForGesture ? ref : undefined}
...

It kinda works (might fail sometimes). Obviously, you still need to lift your finger up and swipe again (which can be ok) to dismiss the modal but also because you need to update shouldWaitForGesture state (and because setParams also triggers some navigation hooks) when you scroll, it is also not very smooth sometimes. This is quite a common pattern, see this in Snapchat (video is on Android but I am actually trying to do it on iOS) for example:

modal-slide

Wondering if you figured out something @brentvatne (as you are assigned) or if you feel this is more a react-native-gesture-handler thing?

@mikedemarais
Copy link

hey all! we have been working with @osdnk on the slack-pan-sheet-react-native-experiment package to achieve the effect/requirements that yall have been discussing in this thread. https://github.com/osdnk/slack-pan-sheet-react-native-experiment

read his twitter thread for more context https://twitter.com/mosdnk/status/1233097798251425795

@art1373
Copy link

art1373 commented Sep 28, 2020

What I ended up doing was set position absolute on the scrollable content and then gave a top to it so the scroll event is not called when the user dismiss the modal from the top

@danielxander
Copy link

Hey @skhavari! If you still need help with this, check out this snack: https://snack.expo.io/@rgilbert/scrollable-modal-with-dismissal

Hey guys, I'm facing the same problem with modal screens from my stackNavigator. Can somebody help my with a short example of how am I supposed to do in react navigation 5? Many thanks in advance, it is really kind of a blocker...

@solominh
Copy link

solominh commented Mar 2, 2021

Just use DismissableFlatList as normal FLatList

// DismissableFlatList.js

import React, { useState, useCallback } from 'react';

import { GestureHandlerRefContext } from '@react-navigation/stack';
import { FlatList } from 'react-native-gesture-handler';

const DismissableFlatList = props => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({ nativeEvent }) => {
    const scrolledTop = nativeEvent.contentOffset.y <= 0;
    setScrolledTop(scrolledTop);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {ref => (
        <FlatList // from 'react-native-gesture-handler'
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

export default DismissableFlatList;

@andreialecu
Copy link

I've been using this with success as a replacement for ScrollView, based on the code by @solominh above.

import {GestureHandlerRefContext} from '@react-navigation/stack';
import React, {PropsWithChildren, useCallback, useState} from 'react';
import {ScrollViewProps} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';

export const DismissableScrollView = (
  props: PropsWithChildren<ScrollViewProps>,
) => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({nativeEvent}) => {
    setScrolledTop(nativeEvent.contentOffset.y <= 0);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {(ref) => (
        <ScrollView
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

@QuintonC
Copy link

I've been using this with success as a replacement for ScrollView, based on the code by @solominh above.

import {GestureHandlerRefContext} from '@react-navigation/stack';
import React, {PropsWithChildren, useCallback, useState} from 'react';
import {ScrollViewProps} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';

export const DismissableScrollView = (
  props: PropsWithChildren<ScrollViewProps>,
) => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({nativeEvent}) => {
    setScrolledTop(nativeEvent.contentOffset.y <= 0);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {(ref) => (
        <ScrollView
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

How are you using this to solve the problem? I get that this sets the scrolledTop to true. But what happens when that value is true? How are you consuming that value?

@vi07
Copy link

vi07 commented Jun 2, 2021

After implementing so many libraries, this one seems to provide a fluid scroll effect and close the modal: https://github.com/jeremybarbet/react-native-modalize

@whck6
Copy link

whck6 commented Aug 24, 2021

I've been using this with success as a replacement for ScrollView, based on the code by @solominh above.

import {GestureHandlerRefContext} from '@react-navigation/stack';
import React, {PropsWithChildren, useCallback, useState} from 'react';
import {ScrollViewProps} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';

export const DismissableScrollView = (
  props: PropsWithChildren<ScrollViewProps>,
) => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({nativeEvent}) => {
    setScrolledTop(nativeEvent.contentOffset.y <= 0);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {(ref) => (
        <ScrollView
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

It is work for me.

@raffibag
Copy link

raffibag commented Sep 9, 2021

I had been racking my brain on this for a while. The following solution works reliably for me, though it still doesn't allow for a nice "pull down" feel (where you can slowly pull the modal closed). While that's mostly a polish issue, this allows you to a) scroll within the modal; b) dismiss the modal by pulling down, provided you're at the top of the modal (the common/standard use case); and c) do it without requiring a gesture handler library:

import * as React from 'react'
import { ScrollView } from 'react-native'

function MyFunction({ navigation, route, ...props }) {
  _onScroll = ({ nativeEvent }) => {
    const offset = 0
    const currentOffset = nativeEvent.contentOffset.y;
    const scrollUp = currentOffset < offset;
    if (nativeEvent.contentOffset.y < offset && scrollUp) {
      // navigation event here (e.g., navigation.goBack())
    } 
  }

  return (
    <ScrollView
      onMomentumScrollBegin={ _onScroll }
    >
      ...
    </ScrollView>
  )
}

@andreialecu
Copy link

andreialecu commented Sep 11, 2021

@raffibag the code shared above still works as of React Navigation 6. And it has the nice pull down feel as well.

One important thing for everything to work is to set gestureResponseDistance: windowHeight in your modal navigator's screenOptions where windowHeight is const {height: windowHeight} = useWindowDimensions();

@trin4ik
Copy link

trin4ik commented Nov 20, 2021

same problem with scrollview/flatlist in modal, try to use this code, but my gesture ref always null

<GestureHandlerRefContext.Consumer>
      {ref => ... // ref is always null}
    </GestureHandlerRefContext.Consumer>

what i do wrong? navigation not used react-native-gesture-handler?

expo 43 (dev client)
react-native-gesture-handler 1.10.3
react-navigation/native 6.0.6
react-navigation/native-stack 6.2.5
react-navigation/stack 6.0.11
in App.js on top import 'react-native-gesture-handler'

@trin4ik
Copy link

trin4ik commented Nov 20, 2021

same problem with scrollview/flatlist in modal, try to use this code, but my gesture ref always null

<GestureHandlerRefContext.Consumer>
      {ref => ... // ref is always null}
    </GestureHandlerRefContext.Consumer>

what i do wrong? navigation not used react-native-gesture-handler?

expo 43 (dev client) react-native-gesture-handler 1.10.3 react-navigation/native 6.0.6 react-navigation/native-stack 6.2.5 react-navigation/stack 6.0.11 in App.js on top import 'react-native-gesture-handler'

oh, i see ))
in my NavigationContainer i use react-navigation/native-stack 2 create stack navigation )) @solominh ty for your tips

@vbylen
Copy link

vbylen commented Nov 21, 2021

@solominh @trin4ik is there a way to make it work with react-navigation/native-stack ?

@trin4ik
Copy link

trin4ik commented Nov 21, 2021

@solominh @trin4ik is there a way to make it work with react-navigation/native-stack ?

i dont know, in my case, i cant disabled gesture in native-stack. I was ready to give up modal swipe for the sake of swipe inside, but with native-stack it not working

@riancz
Copy link

riancz commented Nov 23, 2021

I used this and worked wonderfully. But now I have multiple content inside and sometimes it just tries to pull down the modal even though I am in the middle of the screen and I am trying to scroll to top. The scrollUp is false but it doesn't care. Any idea? I think some other elements (for example input) must top the gesture handler.

I had been racking my brain on this for a while. The following solution works reliably for me, though it still doesn't allow for a nice "pull down" feel (where you can slowly pull the modal closed). While that's mostly a polish issue, this allows you to a) scroll within the modal; b) dismiss the modal by pulling down, provided you're at the top of the modal (the common/standard use case); and c) do it without requiring a gesture handler library:

import * as React from 'react'
import { ScrollView } from 'react-native'

function MyFunction({ navigation, route, ...props }) {
  _onScroll = ({ nativeEvent }) => {
    const offset = 0
    const currentOffset = nativeEvent.contentOffset.y;
    const scrollUp = currentOffset < offset;
    if (nativeEvent.contentOffset.y < offset && scrollUp) {
      // navigation event here (e.g., navigation.goBack())
    } 
  }

  return (
    <ScrollView
      onMomentumScrollBegin={ _onScroll }
    >
      ...
    </ScrollView>
  )
}

@nixolas1
Copy link

My working (but non-optimal) solution:

const dismissOnOverscroll = ({nativeEvent: {contentOffset, velocity}}, action) => {
  if(contentOffset.y <= 0 && velocity.y > 2) {
    action()
  }
}

...

<ScrollView onScrollEndDrag={(e) => dismissOnOverscroll(e, () => navigation.pop())}>

It will close the modal when a drag down event at the top finishes, and the user has to use some extra drag speed to close it, not just bump the top lightly.

@brod-ie
Copy link

brod-ie commented Mar 17, 2022

I've been using this with success as a replacement for ScrollView, based on the code by @solominh above.

import {GestureHandlerRefContext} from '@react-navigation/stack';
import React, {PropsWithChildren, useCallback, useState} from 'react';
import {ScrollViewProps} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';

export const DismissableScrollView = (
  props: PropsWithChildren<ScrollViewProps>,
) => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({nativeEvent}) => {
    setScrolledTop(nativeEvent.contentOffset.y <= 0);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {(ref) => (
        <ScrollView
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

We're using a native stack navigator and can't get the above with GestureHandlerRefContext.Consumer to work. Does anyone have any workarounds?

@Jpunt
Copy link

Jpunt commented Apr 4, 2022

I can't get it to work either, and I don't really understand it to be honest. If we're waiting on the gesture handler from context when the offset is 0, how is it supposed to be able to scroll down? 🤔

@espenjanson
Copy link

espenjanson commented Apr 28, 2022

@Jpunt

The logic behind it is the following:

  1. ScrollView is higher in hierarchy, takes precedence over gesture handler from react-navigation
  2. When scrollPos is 0, we want the navigation gesture handler to be able to respond to touch as well
  3. HOWEVER (important), we expect the navigation gesture handler to fail if move our finger in the wrong direction as specified by minOffsetY (in react-navigation/packages/stack/src/views/Stack/Card.tsx, line 380), which is 5 in this case -> ScrollView retakes precedence when user swipes "up" (scrolling down, never reaching a minOffsetY > 0)

Number 3 doesn't work if you have a newer version of gesture handler installed since minOffsetY is deprecated. I think the only thing you can do for now is downgrade react-native-gesture-handler (to 2.2.0) or patch it manually in react-navigation/packages/stack/src/views/Stack/Card.tsx

And wait for react-navigation to be compatible with react-native-gesture-handler > 2.2.0

Update: I jumped the gun a bit here. It's not mainly the deprecated properties causing this, but the fact that there is no failOffsetY specified. I opened an issue about this here

In short, you can fix this by updating lines 392-406 in Card.tsx to the following, and then use something like npm patch package.

if (gestureDirection === 'vertical') {
  return {
    failOffsetX: 15,
    activeOffsetY: 5,
    failOffsetY: -5,
    hitSlop: { bottom: -layout.height + distance },
    enableTrackpadTwoFingerGesture,
  };
} else if (gestureDirection === 'vertical-inverted') {
  return {
    failOffsetX: 15,
    activeOffsetY: -5,
    failOffsetY: 5,
    hitSlop: { top: -layout.height + distance },
    enableTrackpadTwoFingerGesture,
  };
} 

@jacksonHendric
Copy link

jacksonHendric commented Sep 16, 2022

I tried using the gestureResponseDistance but it doesn't exist in the screen options.
Only gestureDirection and gestureEnabled exists.

I'm using native-stack

--Packages--
"expo": "^45.0.0",
"react-native": "0.68.2",
"react-native-toast-message": "^2.1.5",
"@react-navigation/drawer": "^6.4.3",
"@react-navigation/native": "^6.0.2",
"@react-navigation/native-stack": "^6.1.0",
"@react-navigation/stack": "^6.2.3",

@AuroPick
Copy link

@Jpunt

The logic behind it is the following:

  1. ScrollView is higher in hierarchy, takes precedence over gesture handler from react-navigation
  2. When scrollPos is 0, we want the navigation gesture handler to be able to respond to touch as well
  3. HOWEVER (important), we expect the navigation gesture handler to fail if move our finger in the wrong direction as specified by minOffsetY (in react-navigation/packages/stack/src/views/Stack/Card.tsx, line 380), which is 5 in this case -> ScrollView retakes precedence when user swipes "up" (scrolling down, never reaching a minOffsetY > 0)

Number 3 doesn't work if you have a newer version of gesture handler installed since minOffsetY is deprecated. I think the only thing you can do for now is downgrade react-native-gesture-handler (to 2.2.0) or patch it manually in react-navigation/packages/stack/src/views/Stack/Card.tsx

And wait for react-navigation to be compatible with react-native-gesture-handler > 2.2.0

Update: I jumped the gun a bit here. It's not mainly the deprecated properties causing this, but the fact that there is no failOffsetY specified. I opened an issue about this here

In short, you can fix this by updating lines 392-406 in Card.tsx to the following, and then use something like npm patch package.

if (gestureDirection === 'vertical') {
  return {
    failOffsetX: 15,
    activeOffsetY: 5,
    failOffsetY: -5,
    hitSlop: { bottom: -layout.height + distance },
    enableTrackpadTwoFingerGesture,
  };
} else if (gestureDirection === 'vertical-inverted') {
  return {
    failOffsetX: 15,
    activeOffsetY: -5,
    failOffsetY: 5,
    hitSlop: { top: -layout.height + distance },
    enableTrackpadTwoFingerGesture,
  };
} 

You saved my life

@harveyappleton
Copy link

It's a bodgey workaround but this is how I achieved it to make sure it dismiss the window and go back only if you are at the top of the scrollview:

  const isAtTopOfScrollview = useRef<boolean>(true);

  const onScrollBegin = useCallback<NonNullable<ScrollViewProps['onScrollBeginDrag']>>(({ nativeEvent }) => {
    isAtTopOfScrollview.current = nativeEvent.contentOffset.y < 30;
  }, []);

  const onScroll = useCallback<NonNullable<ScrollViewProps['onScroll']>>(
    ({ nativeEvent }) => {
      if (navigation.canGoBack() && nativeEvent.contentOffset.y < -40 && isAtTopOfScrollview.current) {
        navigation.goBack();
      }
    },
    [navigation]
  );

return (
      <ScrollView
        onScrollBeginDrag={onScrollBegin}
        onScroll={onScroll}
        scrollEventThrottle={16}
/>
)

@someSOAP
Copy link

someSOAP commented Jul 5, 2023

According to comments above, I've come to solution where you can scroll down from top and back, and close modal screen only when scroll position is reached the top.

...
import { ScrollView } from 'react-native-gesture-handler'
import { GestureHandlerRefContext } from '@react-navigation/stack'

....

const ModalScreen = () => {

...
    const [isOnTop, setIsOnTop] = useState(true)
    
    const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
        const offset = event.nativeEvent.contentOffset.y
        const newValue = offset <= 10

        if (isOnTop !== newValue) {
            setIsOnTop(newValue)
        }
    }

....
    return (
        <View>
        ...
        <GestureHandlerRefContext.Consumer>
            {(ref) => (
                <ScrollView
                    simultaneousHandlers={isOnTop ? ref : undefined}
                    bounces={false}
                    scrollEventThrottle={16}
                    onScroll={onScroll}
                >
                    ...
                </ScrollView>
            )}
            </GestureHandlerRefContext.Consumer>
       </View>
    )
}

The trick is that simultaneousHandlers property can help us provide the expected behaviour. With waitFor in examples above we block onScroll event and stuck at top.

@mauroiemboli
Copy link

Just use DismissableFlatList as normal FLatList

// DismissableFlatList.js

import React, { useState, useCallback } from 'react';

import { GestureHandlerRefContext } from '@react-navigation/stack';
import { FlatList } from 'react-native-gesture-handler';

const DismissableFlatList = props => {
  const [scrolledTop, setScrolledTop] = useState(true);
  const onScroll = useCallback(({ nativeEvent }) => {
    const scrolledTop = nativeEvent.contentOffset.y <= 0;
    setScrolledTop(scrolledTop);
  }, []);

  return (
    <GestureHandlerRefContext.Consumer>
      {ref => (
        <FlatList // from 'react-native-gesture-handler'
          waitFor={scrolledTop ? ref : undefined}
          onScroll={onScroll}
          scrollEventThrottle={16}
          {...props}
        />
      )}
    </GestureHandlerRefContext.Consumer>
  );
};

export default DismissableFlatList;

You are the man!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests