facebook / react-native

A framework for building native applications using React
https://reactnative.dev
MIT License
119.16k stars 24.32k forks source link

[0.61][iOS 13] pageSheet/formSheet dismissal from swipe not propagated #26892

Closed ancyrweb closed 4 years ago

ancyrweb commented 5 years ago

When the user dismisses the modal by a swipe gesture using a custom presentation style, the event isn't caught by onDismiss.

React Native version: 0.61.0

Sample code :

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  Button,
  View,
  Modal as RNModal,
} from 'react-native';

export default class Example extends Component {
  state = {
    visible: false,
  };

  render() {
    return (
      <View style={styles.container}>
        <Button
          onPress={() => this.setState({ visible: true })}
          title="Default"
        />
        <RNModal
          visible={this.state.visible}
          onDismiss={() => console.log("on dismiss")}
          onRequestClose={() => console.log("on dismiss")}
          presentationStyle={"pageSheet"}>
          <View style={styles.content}>
            <Text style={styles.contentTitle}>Open</Text>
            <Button
              onPress={() => this.setState({ visible: false })}
              title="Close"
            />
          </View>
        </RNModal>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'white',
  },
  content: {
    backgroundColor: 'white',
    padding: 22,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 4,
    borderColor: 'rgba(0, 0, 0, 0.1)',
  },
  contentTitle: {
    fontSize: 20,
    marginBottom: 12,
  },
});

I'm no expert in iOS, but this article might give a hint.

I can fill in a PR with some help.

dan-fein commented 5 years ago

Also experiencing this.

tomeberle commented 5 years ago

Same here.

janpe commented 5 years ago

Having this problem as well.

epicbytes commented 5 years ago

same problem on iOS 13 I tried using the module "react-native-swipe-gestures", but it was a terrible experience. Now at all there is no way to swipe to set the activity flag at the modal window, it is always true.

akondo06 commented 4 years ago

any update on this? I was just about to use it when noticed the swipe down problem.

AppKidd commented 4 years ago

Also experiencing. So frustrating.

jimcamut commented 4 years ago

I had to change the presentationStyle to 'overFullScreen' to prevent swiping down. It's certainly no replacement for pageSheet, but if you need an onDismiss function to trigger it can be a temporary solution until this is fixed.

kangfenmao commented 4 years ago

I am using Modal from react-native, presentationStyle ="pageSheet" which means I can slide to dismiss.

However when I do that, no function is fired.

onDismiss only fires when I close it from a button.

The modal won't open again if I do like this because it doesn't change it's state.

humphreyja commented 4 years ago

I've been running into this issue. I wrote a library (basically just copied over React Native's Modal code) to see if I could fix this issue. I hook into the viewDidDisappear function in the ModalHostViewController which does get called when the Native iOS gesture for dismissing the modal happens. I then manually call the onDismiss function. Here's the library: https://github.com/HarvestProfit/react-native-modal-patch

I'm not that familiar with Objective-C so I'm not sure if this is a great solution, but hopefully it helps someone. If someone more familiar thinks this is a valid solution, then I'll create a PR.

amidulanjana commented 4 years ago

I am also experiencing this. Please release a fix for this.

jpamarohorta commented 4 years ago

Same. Does anyone knows how to prevent the swipe? Not a solution but a possible workaround for now

beniaminrychter commented 4 years ago

Same issue here. Because of that it's Modal component is useless.

patrikmasiar commented 4 years ago

Same problem with dismissing modal in presentation style. When I swipe it down to close, then state is not changed.

lrholmes commented 4 years ago

Here is a (not-ideal/hacky!) workaround while awaiting a proper fix if anyone is also desperate for the pull-down modal behaviour. It does not solve the problem that there's no way to fire a callback when user dismisses the modal, but enables reopening the modal after being pulled-down.

The idea behind the logic is to check if an imperative modal-open is being attempted on an "already open" modal, and forcing a couple of re-renders to reset the value on the modal.

modal

import { useState, useEffect } from 'react';

export const useModalState = initialState => {
  const [modalVisible, setModalVisible] = useState(initialState);
  const [forceModalVisible, setForceModalVisible] = useState(false);

  const setModal = modalState => {
    // tyring to open "already open" modal
    if (modalState && modalVisible) {
      setForceModalVisible(true);
    }
    setModalVisible(modalState);
  };

  useEffect(() => {
    if (forceModalVisible && modalVisible) {
      setModalVisible(false);
    }
    if (forceModalVisible && !modalVisible) {
      setForceModalVisible(false);
      setModalVisible(true);
    }
  }, [forceModalVisible, modalVisible]);

  return [modalVisible, setModal];
};

// use it the same way as before (as docs recommend)
const [modalVisible, setModalVisible] = useModalState(false)

Hope it may help some of you!

r4mdat commented 4 years ago

I wasn't able to get the workaround @lrholmes posted above to work. For whatever reason, the TouchableOpacity used to open the modal (and interestingly any adjacent TouchOpacity components) would no longer fire their onPress after the modal was swiped down. Desperate to get this working, I ended up putting a ScrollView around the entire modal content and used onDragEnd to flip the modal state variable. Works if your modal contents don't scroll. Not by any means ideal but was a reasonable trade-off in my situation.

deniscreamer commented 4 years ago

@r4mdat

Just try use Modal component inside TouchableWithoutFeedback. Then onPress will does not blocked. And use function like @lrholmes

<TouchableWithoutFeedback>
            <Modal
                visible={enable}
                presentationStyle={'formSheet'}
            </Modal>
</TouchableWithoutFeedback>

<TouchableOpacity
            onPress={() => {
                setModalSelectPhotos(false);
                setTimeout(() => {
                    setModalSelectPhotos(true);
                }, 50);
            }}>
.......
</TouchableOpacity>

It was help me

scarlac commented 4 years ago

Patch workaround for React Native 0.61.2 Here's a patch that triggers onDismiss callback when a modal is dismissed by swiping down, on iOS 13. However, there is a caveat: Once you patch the code, Xcode seems to change the default modal behavior for the entire app (at least for me), causing all modals to appear in the new style, on iOS 13. Depending on how your app is, this may be unwanted so please consider that before using the patch in production. Edit: There is no caveat anymore. Updated patch in link works as intended.

Download patch https://gist.github.com/scarlac/ec162221e11927c52cfc9c94e7252824

Installation You can apply it using either:

martsie commented 4 years ago

Same issue - even with the patched versions.

scarlac commented 4 years ago

@martsie You need to recompile your app. Set a breakpoints in the newly added lines to verify

scarlac commented 4 years ago

Sorry, @martsie there was a line missing from my diff. You'll need to say that you're implementing a delegate as well (UIAdaptivePresentationControllerDelegate). I've updated the link. Not sure why it worked for me - perhaps I had a local modification that I forgot to include in the patch

thomasttvo commented 4 years ago

I wasn't able to get the workaround @lrholmes posted above to work. For whatever reason, the TouchableOpacity used to open the modal (and interestingly any adjacent TouchOpacity components) would no longer fire their onPress after the modal was swiped down. Desperate to get this working, I ended up putting a ScrollView around the entire modal content and used onDragEnd to flip the modal state variable. Works if your modal contents don't scroll. Not by any means ideal but was a reasonable trade-off in my situation.

@r4mdat just put your Modal in a View with height: 0

<View style={{height:0}}><Modal>....</Modal></View>
bmkopp10 commented 4 years ago

@r4mdat

Just try use Modal component inside TouchableWithoutFeedback. Then onPress will does not blocked. And use function like @lrholmes

<TouchableWithoutFeedback>
            <Modal
                visible={enable}
                presentationStyle={'formSheet'}
            </Modal>
</TouchableWithoutFeedback>

<TouchableOpacity
            onPress={() => {
                setModalSelectPhotos(false);
                setTimeout(() => {
                    setModalSelectPhotos(true);
                }, 50);
            }}>
.......
</TouchableOpacity>

It was help me

This worked perfectly fine for me. I would suggest this over any other options as it takes the least amount of code and is the most understandable. Also, once the issue is fixed, it will be the simplest to revert.

aca-hakan-pinar commented 4 years ago

Hey everyone,

Im having the same issue. onDismiss property doesn't clear the Modal component (swipe down iOS). Can someone help me?

Thanks

jdanthinne commented 4 years ago

@aca-hakan-pinar: @scarlac solution (https://github.com/facebook/react-native/issues/26892#issuecomment-581198937) works fine for me.

noambonnie commented 4 years ago

Has this patch been merged? I'm on 0.61.5 and still experiencing the issue.

annjawn commented 4 years ago

This still persists on 0.61.5. This bug is the entire reason why I am resorting to using a third party library for Modal.

CodingByJerez commented 4 years ago

until the problem is fixed:

import React, { useState } from 'react';
import { SafeAreaView, Modal, TouchableWithoutFeedback, Keyboard, Dimensions } from 'react-native';
import { Button } from 'react-native-elements';

const MyComponent = () => {

    const [modalView, setModalView] = useState<null|SafeAreaView>(null);
    const [isOpen, setIsOpen] = useState<boolean>(false);

    return (
        <>
            <Modal
                presentationStyle="formSheet"
                animationType="slide"
                transparent={false}
                visible={isOpen}
            >
                <TouchableWithoutFeedback
                    onPressOut={() => {
                        if(!modalView)
                            return;

                        modalView.measure((fx, fy, width, height, px, py) => {
                            if(py === Dimensions.get('window').height)
                                setIsOpen(false);
                        });
                }}>
                    <SafeAreaView ref={(ref) => setModalView(ref)}>

                    </SafeAreaView>
                </TouchableWithoutFeedback>
            </Modal>

            <Button title={'demo'} onPress={() => setIsOpen(true)} />
        </>
    );
};

and if you use focus (textInput...):

import React, { useEffect, useRef, useState } from 'react';
import { SafeAreaView, Modal, TouchableWithoutFeedback, Dimensions } from 'react-native';
import { Button } from 'react-native-elements';

const MyComponent = () => {

    const [modalView, setModalView] = useState<null|SafeAreaView>(null);
    const [isOpen, setIsOpen] = useState<boolean>(false);
    const [keyboardOpen, setKeyboardOpen] = useState<boolean>(false);

    const intervalRef = useRef<null|number>(null);

    useEffect(() => {

        if (keyboardOpen)
            {intervalRef.current = setInterval(() => {
                tryClose();
            }, 500);}

        else if (intervalRef.current != null) {
            clearInterval(intervalRef.current);
            intervalRef.current = null;
        }

        return () => {
            if (intervalRef.current)
                {clearInterval(intervalRef.current);}
        };

    },[keyboardOpen]);

    const tryClose = () => {
        if (!modalView)
            {return;}

        modalView.measure((fx, fy, width, height, px, py) => {
            if (py === Dimensions.get('window').height){
                setIsOpen(false);
                setKeyboardOpen(false);
            }
        });
    };

    return (
        <>
            <Modal
                presentationStyle="formSheet"
                animationType="slide"
                transparent={false}
                visible={isOpen}
            >
                <TouchableWithoutFeedback
                    onBlur={() => setKeyboardOpen(false)}
                    onFocus={() => setKeyboardOpen(true)}
                    onPressOut={tryClose}>
                    <SafeAreaView ref={(ref) => setModalView(ref)}>

                    </SafeAreaView>
                </TouchableWithoutFeedback>
            </Modal>

            <Button title={'demo'} onPress={() => setIsOpen(true)} />
        </>
    );
};

or:

import React, { useEffect, useRef, useState } from 'react';
import { SafeAreaView, Modal, Dimensions } from 'react-native';
import { Button } from 'react-native-elements';

const MyComponent = () => {

    const [modalView, setModalView] = useState<null|SafeAreaView>(null);
    const [isOpen, setIsOpen] = useState<boolean>(false);
    const intervalRef = useRef<null|number>(null);

    useEffect(() => {

        const clearLoop = () => {
            if(intervalRef.current)
                clearInterval(intervalRef.current);
            intervalRef.current = null;
        };

        if(isOpen && modalView)
            intervalRef.current = setInterval(() => tryClose(), 500);
        else if (intervalRef.current != null)
            clearLoop();

        return () => clearLoop();

    },[isOpen, modalView]);

    const tryClose = () => {
        if(!modalView)
            return;

        modalView.measure((fx, fy, width, height, px, py) => {
            if (py === Dimensions.get('window').height)
                setIsOpen(false);
        });
    };

    return (
        <>
            <Modal
                presentationStyle="formSheet"
                animationType="slide"
                transparent={false}
                visible={isOpen}>
                <SafeAreaView ref={(ref) => setModalView(ref)}>

                </SafeAreaView>
            </Modal>

            <Button title={'demo'} onPress={() => setIsOpen(true)} />
        </>
    );
};
s-h-a-d-o-w commented 4 years ago

@CodingByJerez

Thanks a lot for posting this but when it comes to the use of SafeAreaView - that sure isn't working for me on iOS 13.2.3. It has to be a regular View. (Possibly related to this?)

Plus - if the user swipes down immediately before tapping on the modal, it seems that onPressOut is not triggered. Which apparently can be resolved by using TouchableOpacity.

And so overall, I ended up with this working for me (since it's possible to have the ref on TouchableOpacity, I figured why not. One just has to watch out that it spans the whole modal, like with flex: 1. And the View apparently becomes unnecessary in this case.):

  const [modalView, setModalView] = useState<null | TouchableOpacity>(null);

  return (
    <Modal
      animationType="slide"
      presentationStyle="pageSheet"
      visible={isVisible}
      onRequestClose={onClose}
    >
      <TouchableOpacity
        activeOpacity={1}
        onPressOut={() => {
          if (!modalView) return;

          modalView.measure((fx, fy, width, height, px, py) => {
            if (py === Dimensions.get('window').height) onClose();
          });
        }}
        ref={(ref) => setModalView(ref)}
        style={{
          flex: 1,
        }}
      >
        <Text>Hello World!</Text>
        <Button onPress={onClose} title="Hide Modal" />
      </TouchableOpacity>
    </Modal>
  );
CodingByJerez commented 4 years ago

@s-h-a-d-o-w I run it on iOS 13.3, and I don't have a problem. Have you tried the loop? (This is the safest way) (Check 'PY' It may vary depending on your use in 0 and Dimensions.get('window').height)

import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
import { SafeAreaView, Modal } from 'react-native';

interface IProps {
    visible:boolean,
    onVisible(bool:boolean):void,
}

const MyComponent:FunctionComponent<IProps> = ({visible, onVisible, children}) => {

    const [modalView, setModalView] = useState<null|SafeAreaView>(null);
    const [isOpen, setIsOpen] = useState<boolean>(false);
    const intervalRef = useRef<null|number>(null);

    useEffect(() => setIsOpen(visible),[visible]);

    useEffect(() => {
        if(visible !== isOpen)
            onVisible(isOpen);

        const clearLoop = () => {
            if(intervalRef.current){
                clearInterval(intervalRef.current);
                intervalRef.current = null;
            }
        };

        if(isOpen && modalView)
            intervalRef.current = setInterval(() => tryClose(), 500);
        else if (intervalRef.current != null)
            clearLoop();

        return () => clearLoop();

    },[isOpen, modalView]);

    const tryClose = () => {
        if(!modalView)
            return;

        modalView.measure((fx, fy, width, height, px, py) => {
            if (py === 0)
                setIsOpen(false);
        });
    };

    return (
        <Modal
            presentationStyle={'formSheet'}
            animationType={'slide'}
            transparent={false}
            visible={isOpen}
            onRequestClose={() => setIsOpen(false)}>
            <SafeAreaView ref={(ref) => setModalView(ref)}>
                {children}
            </SafeAreaView>
        </Modal>
    );

};
levelingup commented 4 years ago

I'm on react native 0.62.0 and its still not working for me

annjawn commented 4 years ago

Yes, this still continues to be a bug. I am thinking that RN core is planning to get rid of Modal from core altogether and move it to react-native-community and thus there has been no interest in fixing this?? still not sure...

jdanthinne commented 4 years ago

@scarlac Patch workaround doesn't work anymore in RN 0.62.0… RCTModalManager.h file not found in RCTModalHostView.m.

r0b0t3d commented 4 years ago

@jdanthinne you could try this gist https://gist.github.com/r0b0t3d/3c9f77434e6fbcfa78698dcf57614fad

<Modal
      visible={visible}
      animationType="slide"
      presentationStyle="pageSheet"
      onRequestClose={onClose}
    >
</Modal>
jdanthinne commented 4 years ago

@r0b0t3d Working fine. Thanks!

noambonnie commented 4 years ago

Anyone knows why this issue is still marked as closed despite the issue still happening? @r0b0t3d do you know if there's a pull request for the gist you posted?

annjawn commented 4 years ago

@noambonnie i think that's because they are planning to remove Modal out of core and move it to react-native-community; there's already a lib out there react-native-community/react-native-modal.

I am not a 100% sure if this is true though.

scarlac commented 4 years ago

@annjawn I see no indication that they'll move it out. That library relies on the modal component, does not provide the same default look, and has no native integrations like page sheets. Is there a discussion where they are considering it?

annjawn commented 4 years ago

@scarlac as I said, I am not 100% certain that's the plan but seeing how many of the things are with core, this could very well be a possibility. Yes, the community modal doesn't closely support the navigations and native integrations but I would venture a guess that it won't be a huge deal to support native view controller integrations there for both iOS and Android. But again , all of this is a big speculation on my side.

amidulanjana commented 4 years ago

@jdanthinne Does @r0b0t3d ’s solution working ? Would you be able to close the modal programmatically ?

jdanthinne commented 4 years ago

@amilaDulanjana Yes, working fine for me.

jacobp100 commented 4 years ago

@annjawn that package re-uses the existing Modal from react-native, and adds styling on top. It's purely JS and has no native code.

esmailbenmoussa commented 4 years ago

@jdanthinne Does @r0b0t3d ’s solution working ? Would you be able to close the modal programmatically ?

not working for me..any other ideas so far?

WonSong commented 4 years ago

I have it working with combination of two solutions mentioned in this thread..

  1. Wrap <Modal /> with <View style={{ height: 0}} /> This allows the button triggering the modal to be clicked again when the PageSheet modal is swiped down

  2. Ensure that you always set isVisible to false first, then set to true to "refresh" the modal state.

I guess this will leave the modal instance hanging around when swiped down, but setting setModalOpen to false on the unmount of component containing the <PageSheetModal /> should help?

const [isModalOpen, setModalOpen] = React.useState<boolean>(false);

const showModal = (): void => {
    setModalOpen(false);
    requestAnimationFrame(() => setModalOpen(true));
};

// React.useEffect() to setModalOpen to false on component unmount

Modal example

import * as React from 'react';
import { Modal, View } from 'react-native';
import { IPageSheetModalProps } from './PageSheetModal.types';

export function PageSheetModal(
    props: React.PropsWithChildren<IPageSheetModalProps>
): React.ReactElement {
    const { isVisible, children } = props;

    return (
        <View style={{ height: 0 }}>
            <Modal presentationStyle="pageSheet" visible={isVisible} animationType="slide">
                {children}
            </Modal>
        </View>
    );
}

I've also tried using <TouchableWithoutFeedback />, but it caused issues if I had <ScrollView /> inside. The above example also works with <ScrollView /> inside the modal.

LMestre14 commented 4 years ago

Patch workaround for React Native 0.61.2 Here's a patch that triggers onDismiss callback when a modal is dismissed by swiping down, on iOS 13. ~However, there is a caveat: Once you patch the code, Xcode seems to change the default modal behavior for the entire app (at least for me), causing all modals to appear in the new style, on iOS 13. Depending on how your app is, this may be unwanted so please consider that before using the patch in production.~ Edit: There is no caveat anymore. Updated patch in link works as intended.

Download patch https://gist.github.com/scarlac/ec162221e11927c52cfc9c94e7252824

Installation You can apply it using either:

* Manually (which is temporary - yarn may remove it) using:
  `patch < react-native+0.61.2.patch` in your project root folder or...

* Automatically using [patch-package](https://www.npmjs.com/package/patch-package) (recommended)

This was the solution for me I'm using react-native 0.61.5

ashkan-yazdani commented 4 years ago

before opening modal, set visible state to false, then set visible state to true again, because if modal already open, closed and open again

showModal = () => { this.setState({ modalVisible:false},function(){ this.setState({ modalVisible: true }) }) }

dcosmin2003 commented 4 years ago

I've updated to RN 0.62.2 and the bug persist.

mydesweb commented 4 years ago

still not working in RN 0.62.2 onDismiss is never called

budet-b commented 4 years ago

I used react-native-modal as a small workaround for this issue https://github.com/react-native-community/react-native-modal. It works as expected.

cuddeford commented 4 years ago

Any solution for Expo users?

alloy commented 4 years ago

Will somebody create a PR with @scarlac’s patch?