Skip to content

Return false if gesture recognizers are present but all disabled #3377

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

Merged
merged 6 commits into from
Feb 10, 2025

Conversation

jcolicchio
Copy link
Contributor

@jcolicchio jcolicchio commented Feb 4, 2025

Description

This PR adjusts shouldHandleTouch to check for enabled gesture recognizers instead of returning true if the count of gesture recognizers is greater than zero.

The motivation behind this PR is to address a bug where buttons become unresponsive if, I guess, iOS is inserting disabled accessibility gesture recognizers into the child views??

Fixes #3376

Test plan

I was able to reproduce this reliably on a private repo, and used the debugger to observe shouldHandleTouch returning too early from a descendant of the button meant to be tapped. With this fix, I confirmed that the correct button returns to handle to touch event and the buttons all behave as expected

I also was able to reproduce the issue with this sample code:

  1. Tap Courses
  2. Tap Hello World 1
  3. Tap All Courses

The All Courses button has an onPress which alerts, and in this snippet, I observed no alert occurring

Click to expand
import { StyleSheet, View, Text } from 'react-native';
import { BorderlessButton, FlatList } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import { useNavigation } from 'expo-router';
import { useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {useEffect} from 'react';
import {
    FadeInLeft,
    FadeInUp,
    FadeOutLeft,
    FadeOutUp,
} from 'react-native-reanimated';

const AnimatedBorderlessButton = Animated.createAnimatedComponent(BorderlessButton);

const OptionsScreen = ({
    options,
    onSelect
}: {
    options: string[];
    onSelect: (option?: string) => void;
}) => {
    const insets = useSafeAreaInsets();
    const navigation = useNavigation();
    const styles = StyleSheet.create({
        container: {
          paddingTop: insets.top,
            backgroundColor: 'rgba(0,0,0,0.7)',
            flex: 1,
            paddingHorizontal: 24,
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
        },
        text: {
            paddingTop: 16,
            color: 'white',
        },
    });
    useEffect(() => {
        navigation.setOptions({
          tabBarStyle: { display: 'none' },
        });
        return () => {
          navigation.setOptions({
            tabBarStyle: { display: 'flex' },
          });
        };
    }, [navigation]);
    return (
        <Animated.View
            style={styles.container}
            entering={FadeInUp}
            exiting={FadeOutUp}
        >
            <FlatList
                data={options}
                renderItem={({item}) => (
                    <AnimatedBorderlessButton onPress={() => onSelect(item)}>
                      <View accessible accessibilityRole="button">
                        <Text style={styles.text}>{item}</Text>
                      </View>
                    </AnimatedBorderlessButton>
                )}
            />
        </Animated.View>
    );
};

function HomeScreen() {
    const insets = useSafeAreaInsets();
    const [selectedCategory, setSelectedCategory] = useState('');
    const [options, setOptions] = useState<string[]>([]);

    const styles = StyleSheet.create({
        container: {
            flex: 1,
            paddingTop: insets.top,
            backgroundColor: 'white',
        },
        categoryContainer: {
            gap: 16,
            paddingHorizontal: 24,
        },
    });
    const dummyData = {
        categoryOptions: (Array.from({length: 14}, (_, i) => `Hello World ${i + 1}`)),
    };

    return (
        <View style={styles.container}>
            <AnimatedBorderlessButton
              onPress={() => setOptions(dummyData.categoryOptions)}
              entering={FadeInLeft}
              exiting={FadeOutLeft}
            >
                <View accessible accessibilityRole="button">
                    <Text>{selectedCategory.length > 0 ? selectedCategory : 'Courses'}</Text>
                </View>
            </AnimatedBorderlessButton>
            {selectedCategory.length > 0 && (
                <AnimatedBorderlessButton
                    onPress={() => alert('It Worked')}
                    entering={FadeInLeft}
                    exiting={FadeOutLeft}
                >
                  <View accessible accessibilityRole="button">
                      <Text>All Courses</Text>
                  </View>
                </AnimatedBorderlessButton>
            )}
            {options.length > 0 && (
                <OptionsScreen
                    options={options}
                    onSelect={(selectedOption) => {
                        setSelectedCategory(selectedOption ?? '');
                        setOptions([]);
                    }}
                />
            )}
        </View>
    );
}

export default function TabTwoScreen() {
  return (
    <HomeScreen />
  );
}

Copy link
Contributor

@m-bert m-bert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jcolicchio! Thanks for submitting this PR! I have some remarks though:

  1. Let's use YES and NO instead of true/false. I'm also not a fan of this, but for now I'd like to stay consistent with our codebase.
  2. I think that this PR will break macos.
  3. Before we merge it, we have to test those changes, so please provide a reproduction that we can use

// touch handling. Therefore, as a bandaid we can check to ensure we don't return
// true here if the only gesture recognizers we have are all disabled.
BOOL hasEnabledGestureRecognizer = false;
for (UIGestureRecognizer *gestureRecognizer in view.gestureRecognizers) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure that this will break on macos 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I go ahead and move this implementation into #if !TARGET_OS_OSX for the sake of this fix, which may be an iOS-specific issue to begin with?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I would prefer something like this:

#if !TARGET_OS_OSX
  for (UIGestureRecognizer *gestureRecognizer in view.gestureRecognizers) {
#else
  for (NSGestureRecognizer *gestureRecognizer in view.gestureRecognizers) {
#endif

Now when I checked this macos did compile without issues, but I'm not sure why it does recognize UIGestureRecognizer if it has its own NSGestureRecognizer, so I believe it is better to split it into two parts. Also making it iOS exclusive may not be the best as it may also occur on macos (but for that we would need a reproduction)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'll take a look at such an implementation tomorrow

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually went with an implementation using NSPredicate to filter on isEnabled == YES, which I believe should work equally well on OSX as iOS? I don't have a good test setup to validate on OSX though, so please take a look and let me know if it works alright.

I also updated the comment, and confirmed that my snippet indeed produces the bug on 2.22.0 (Gestures' Text was unfortunately a non-starter), and that this proposed change addresses the bug using Xcode's debugger, so I think this PR should be good to go! Requesting re-review

@@ -64,10 +64,24 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view
return button.userEnabled;
}

// Certain subviews such as RCTViewComponentView have been observed to have disabled
// accessibility gesture recognizers such as _UIAccessibilityHUDGateGestureRecognizer,
// ostensibly set by iOS. Such gesture recognizers cause us to return early, despite
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if "return early" is correct in that case

Copy link
Contributor Author

@jcolicchio jcolicchio Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is incorrect. In my case, the handler for a touch returned by this algorithm was the native representation of <View accessible accessibilityRole="button"><Text>Static Text</Text></View>. It didn't have any valid tap gesture recognizers to handle touch events, but it was still chosen by this algorithm to be the view which "shouldHandleTouch"

If the algorithm does not return this child view "early", it instead traverses up to the parent RNGestureHandlerButton and returns the parent view, which does have gesture handlers due to, in this case, my BorderlessButton's onPress, which faithfully gets invoked on tap

So, I think this algorithm should not return a child view which happens to have two disabled accessibility gesture recognizers, which were mysteriously added from some unknown source, just because the number of gesture recognizers is greater than zero. Instead, I think it should ignore these disabled gesture recognizers when determining which view should handle touch, so that the parent RNGestureHandlerButton can instead be returned and handle touches

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean. What I meant was that the way it is phrased in this comment suggests something like early return in this specific function, not in the whole process.

(...) view which happens to have two disabled accessibility gesture recognizers, which were mysteriously added from some unknown source (...)

My guess is that they are added because of View with accessibilityRole="button", but I have not checked that yet.

Anyway, I think that we should rephrase this comment when we have more information

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, do you use Text from React Native, or from Gesture Handler?

Copy link
Contributor Author

@jcolicchio jcolicchio Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rephrase the comment to clarify. I find it odd that only in certain scenarios do certain Views get these accessibility tap gestures added to them

And, I'm using Text from React Native, how would I import Text from Gesture Handler? I'm seeing Module '"react-native-gesture-handler"' has no exported member 'Text' when I try

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I remember Text component was introduced form 2.22. I asked because that could explain why those views have additional gesture recognizers.

I've checked example with only View with props accessible and accessibilityRole="button" and it seems that it doesn't have any additional recognizer by default

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... Using Text from RNGH 2.22 actually causes the button to not be tappable at all, as in the following:

<AnimatedBorderlessButton onPress={() => onSelect(item)}>
    <View accessible accessibilityRole="button">
        <Text style={styles.text}>{item}</Text>
    </View>
 </AnimatedBorderlessButton>

According to the debugger, the RCTParagraphTextView has a RNDummyGestureRecognizer initially, even before taking the steps which produce the bug in React Native's Text

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the debugger, the RCTParagraphTextView has a RNDummyGestureRecognizer initially, even before taking the steps which produce the bug in React Native's Text

That's exactly what I meant by:

I asked because that could explain why those views have additional gesture recognizers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — I’d thought you might have been suggesting that NOT using that particular Text might explain why I was seeing additional gesture recognizers

@jcolicchio
Copy link
Contributor Author

Hi @jcolicchio! Thanks for submitting this PR! I have some remarks though:

  1. Let's use YES and NO instead of true/false. I'm also not a fan of this, but for now I'd like to stay consistent with our codebase.
  2. I think that this PR will break macos.
  3. Before we merge it, we have to test those changes, so please provide a reproduction that we can use
  1. Got it, I'll make that change
  2. I'll move the fix into the iOS-specific check, if that sounds ok?
  3. I had trouble reliably reproducing this bug from scratch, it involved an amount of clicking to animate buttons in and out. Still, tomorrow I'll clean up the code I'm working with and see if the issue easily persists in a vanilla project. For what it's worth, my forked branch completely addresses the issue 👍

@jcolicchio jcolicchio requested a review from m-bert February 4, 2025 20:29
@m-bert
Copy link
Contributor

m-bert commented Feb 5, 2025

The code looks ok, have you been able to prepare a reproduction? It would be great to check what causes this issue 😅

@jcolicchio
Copy link
Contributor Author

The code looks ok, have you been able to prepare a reproduction? It would be great to check what causes this issue 😅

I added it to the description of the PR, at the bottom inside of Click to expand!

Copy link
Contributor

@m-bert m-bert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just tested that on the repro that you've provided and it indeed works 😅

What's interesting, it does work inside our example app without this fix. I thought it may be related to React old/new architectures, but it seems that it is not a case.

Also, I took the liberty of slighgtly changing comment, hope you don't mind 😅

@m-bert m-bert merged commit 7c3e002 into software-mansion:main Feb 10, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

RNGestureHandlerButton Stop Responding When Child View Contains Disabled Accessibility Gesture Recognizers
2 participants