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

fix: clicking on Pressable located in screen header #2466

Merged
merged 46 commits into from
Jan 16, 2025

Conversation

coado
Copy link
Contributor

@coado coado commented Oct 29, 2024

Description

This PR fixes clicking on Pressables that are added to the native header. Previously, Yoga had incorrect information about the location of the content in the header.

The first step was to remove top: "-100%" style from the ScreenStackHeaderConfig which made Yoga think, that the content is pushed up by the size of its parent (Screen size).

The second step involved updating layoutMetrics of the RNSScreenStackHeaderConfigShadowNode. The entire app content is pushed down by the size of the header, so it also has an impact on the header config in Yoga metrics. To mitigate this, the origin of RNSScreenStackHeaderConfigShadowNode is decreased by the size of the header which will zero out eventually leaving the header content in the desired position. On iOS this position is actually moved by the top inset size, so we also have to take it into account when setting header config layout metrics.

Fixes #2219

Changes

Updated ScreenShadowNode to decrease origin.y of the HeaderConfigShadowNode by the size of the header. Added paddingTop to HeaderConfigState and set it as origin offset on iOS.

Screenshots / GIFs

Before

iOS Android
ios-before.mov
android-before.mov

After

iOs Android
ios-after.mov
android-after.mov

Test code and steps to reproduce

Tested on this example:

code
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator, NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { ForwardedRef, forwardRef } from "react";
import { findNodeHandle, Pressable, PressableProps, StyleSheet, Text, View, } from "react-native";

type StackParamList = {
  Home: undefined,
}

type RouteProps = {
  navigation: NativeStackNavigationProp<StackParamList>;
}

type PressableState = 'pressed-in' | 'pressed' | 'pressed-out'


const Stack = createNativeStackNavigator<StackParamList>();


const PressableWithFeedback = forwardRef((props: PressableProps, ref: ForwardedRef<View>): React.JSX.Element => {
  const [pressedState, setPressedState] = React.useState<PressableState>('pressed-out');

  const onPressInCallback = React.useCallback((e) => {
    console.log('Pressable onPressIn', {
      locationX: e.nativeEvent.locationX,
      locationY: e.nativeEvent.locationY,
      pageX: e.nativeEvent.pageX,
      pageY: e.nativeEvent.pageY,
    });
    setPressedState('pressed-in');
    props.onPressIn?.();
  }, []);

  const onPressCallback = React.useCallback(() => {
    console.log('Pressable onPress');
    setPressedState('pressed');
  }, []);

  const onPressOutCallback = React.useCallback(() => {
    console.log('Pressable onPressOut');
    setPressedState('pressed-out');
  }, []);

  const onResponderMoveCallback = React.useCallback(() => {
    console.log('Pressable onResponderMove');
  }, []);

  const contentsStyle = pressedState === 'pressed-out'
    ? styles.pressablePressedOut
    : (pressedState === 'pressed'
      ? styles.pressablePressed
      : styles.pressablePressedIn);

  return (
    <View ref={ref} style={[contentsStyle, { width: "100%"}]}>
      <Pressable
        onPressIn={onPressInCallback}
        onPress={onPressCallback}
        onPressOut={onPressOutCallback}
        onResponderMove={onResponderMoveCallback}
      >
        {props.children}
      </Pressable>
    </View>

  );
})

function HeaderTitle(): React.JSX.Element {

  return (
    <PressableWithFeedback
      onPressIn={() => {
        console.log('Pressable onPressIn')
      }}
      onPress={() => console.log('Pressable onPress')}
      onPressOut={() => console.log('Pressable onPressOut')}
      onResponderMove={() => console.log('Pressable onResponderMove')}
      ref={ref => {
        console.log(findNodeHandle(ref));
        ref?.measure((x, y, width, height, pageX, pageY) => {
          console.log('header component measure', { x, y, width, height, pageX, pageY });
        })
      }}
    >
      <View style={{ height: 40, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
      </View>
    </PressableWithFeedback>
  )
}

function Home(_: RouteProps): React.JSX.Element {
  return (
    <View style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, .8)' }}
    >
      <View style={{ flex: 1, alignItems: 'center', marginTop: 48 }}>
        <PressableWithFeedback
          onPressIn={() => console.log('Pressable onPressIn')}
          onPress={() => console.log('Pressable onPress')}
          onPressOut={() => console.log('Pressable onPressOut')}
        >
          <View style={{ height: 40, width: 200, justifyContent: 'center', alignItems: 'center' }}>
            <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
          </View>
        </PressableWithFeedback>
      </View>
    </View>
  );
}

function App(): React.JSX.Element {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen
          name="Home"
          component={Home}
          options={{
            headerTitle: HeaderTitle,
          }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

const styles = StyleSheet.create({
  pressablePressedIn: {
    backgroundColor: 'lightsalmon',
  },
  pressablePressed: {
    backgroundColor: 'crimson',
  },
  pressablePressedOut: {
    backgroundColor: 'lightseagreen',
  }
});


export default App;

@alduzy
Copy link
Member

alduzy commented Oct 30, 2024

Hi @coado I like this approach. However, I'm afraid in current form it may not respect the placement of the elements in regards to flex layout of the header. Have you tested it with smaller elements, headerLeft and/or headerRight for example?

You should be able to use the Element Inspector from the dev menu to inspect the actual placement of the pressables laid out by yoga. I remember using it in #2292

@kkafar kkafar self-requested a review October 30, 2024 11:51
@coado
Copy link
Contributor Author

coado commented Oct 31, 2024

Hi @coado I like this approach. However, I'm afraid in current form it may not respect the placement of the elements in regards to flex layout of the header. Have you tested it with smaller elements, headerLeft and/or headerRight for example?

You should be able to use the Element Inspector from the dev menu to inspect the actual placement of the pressables laid out by yoga. I remember using it in #2292

Hey @alduzy, thanks for the reply! This is how it looks when I set Pressable on headerRight only or on both:

right both

Please let me know if this is desired behaviour.

Also I've checked a placement using inspector as you proposed and it seems like Pressable boundary in Yoga is not perfectly aligned with what is displayed.

with-inspector

This is something that I will have a closer look at!

code
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator, NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { ForwardedRef, forwardRef } from "react";
import { findNodeHandle, Pressable, PressableProps, StyleSheet, Text, View, Button } from "react-native";

type StackParamList = {
  Home: undefined,
}

type RouteProps = {
  navigation: NativeStackNavigationProp<StackParamList>;
}

type PressableState = 'pressed-in' | 'pressed' | 'pressed-out'


const Stack = createNativeStackNavigator<StackParamList>();


const PressableWithFeedback = forwardRef((props: PressableProps, ref: ForwardedRef<View>): React.JSX.Element => {
  const [pressedState, setPressedState] = React.useState<PressableState>('pressed-out');

  const onPressInCallback = React.useCallback((e) => {
    console.log('Pressable onPressIn', {
      locationX: e.nativeEvent.locationX,
      locationY: e.nativeEvent.locationY,
      pageX: e.nativeEvent.pageX,
      pageY: e.nativeEvent.pageY,
    });
    setPressedState('pressed-in');
    props.onPressIn?.();
  }, []);

  const onPressCallback = React.useCallback(() => {
    console.log('Pressable onPress');
    setPressedState('pressed');
  }, []);

  const onPressOutCallback = React.useCallback(() => {
    console.log('Pressable onPressOut');
    setPressedState('pressed-out');
  }, []);

  const onResponderMoveCallback = React.useCallback(() => {
    console.log('Pressable onResponderMove');
  }, []);

  const contentsStyle = pressedState === 'pressed-out'
    ? styles.pressablePressedOut
    : (pressedState === 'pressed'
      ? styles.pressablePressed
      : styles.pressablePressedIn);

  return (
    <View ref={ref} style={[contentsStyle]}>
      <Pressable
        onPressIn={onPressInCallback}
        onPress={onPressCallback}
        onPressOut={onPressOutCallback}
        onResponderMove={onResponderMoveCallback}
      >
        {props.children}
      </Pressable>
    </View>

  );
})

function HeaderTitle(): React.JSX.Element {

  return (
    <PressableWithFeedback
      onPressIn={() => {
        console.log('Pressable onPressIn')
      }}
      onPress={() => console.log('Pressable onPress')}
      onPressOut={() => console.log('Pressable onPressOut')}
      onResponderMove={() => console.log('Pressable onResponderMove')}
      ref={ref => {
        console.log(findNodeHandle(ref));
        ref?.measure((x, y, width, height, pageX, pageY) => {
          console.log('header component measure', { x, y, width, height, pageX, pageY });
        })
      }}
    >
      <View style={{ height: 40, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
      </View>
    </PressableWithFeedback>
  )
}

function Home(_: RouteProps): React.JSX.Element {
  return (
    <View style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, .8)' }}
    >
      <View style={{ flex: 1, alignItems: 'center', marginTop: 48 }}>
        <PressableWithFeedback
          onPressIn={() => console.log('Pressable onPressIn')}
          onPress={() => console.log('Pressable onPress')}
          onPressOut={() => console.log('Pressable onPressOut')}
        >
          <View style={{ height: 40, width: 200, justifyContent: 'center', alignItems: 'center' }}>
            <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
          </View>
        </PressableWithFeedback>
      </View>
    </View>
  );
}

function App(): React.JSX.Element {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen
          name="Home"
          component={Home}
          options={{
            // headerTitle: HeaderTitle,
            headerRight: HeaderTitle,
            // headerLeft: HeaderTitle
          }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

const styles = StyleSheet.create({
  pressablePressedIn: {
    backgroundColor: 'lightsalmon',
  },
  pressablePressed: {
    backgroundColor: 'crimson',
  },
  pressablePressedOut: {
    backgroundColor: 'lightseagreen',
  }
});


export default App;

@coado
Copy link
Contributor Author

coado commented Oct 31, 2024

Actually, when the Pressable is set as the headerRight only it doesn't work 🤔

@jiroscripts
Copy link

Hello @coado

Any news on this issue?

I started upgrading my project to RN 0.76 and this issue is by far the most problematic

@coado
Copy link
Contributor Author

coado commented Nov 13, 2024

Hey @thibaultcapelli
Sorry for the delay. I will get back to this issue shortly.

@kkafar
Copy link
Member

kkafar commented Nov 13, 2024

I was just looking through the code and determined that the only reliable solution would be to update position of header elements in ShadowTree (ST) based on their position in HostTree (HT), i. e. you do send additional information on topinset now - maybe let's send whole frame instead and update headersubviews layout metrics in shadow node? I think this is only way to get this at least partially consistent.

@coado coado marked this pull request as draft November 14, 2024 14:31
@acrabb
Copy link

acrabb commented Nov 18, 2024

+1 for this issue 🙏

Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

iOS & final notes

Comment on lines 117 to 120
for (RNSScreenStackHeaderSubview *subview in headerConfig.reactSubviews) {
CGRect frameToNavigationBar = [subview convertRect:subview.frame toView:self.navigationBar];
[subview updateHeaderSubviewFrame:frameToNavigationBar];
}
Copy link
Member

Choose a reason for hiding this comment

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

It seems that we do not need to send paddings here. I admit, I do not understand the mechanism completely, but my testing on TestHeaderTitle shows that it works just fine, when we update frame for each subview separately.

@kkafar
Copy link
Member

kkafar commented Jan 15, 2025

I believe there is one last thing to sort out:

the clicks still do not work on headerRight

Screen.Recording.2025-01-15.at.22.04.59.mov

@wal3d122
Copy link

@kkafar two interesting observations that might help you out with this:

  1. on a physical iPhone 15pro the pressables actually work as expected, but on a physical iPhone X, it behaves exactly as in your video. Likely to be related to the screen size.

  2. on Android, alongside all header buttons not working, some pages have an added white strip with a height which I assume is the header height. This happens only when new architecture enabled (see attached image) image

@kkafar
Copy link
Member

kkafar commented Jan 15, 2025

@wal3d122 thanks for the info!

Do you run your app with this PR applied or just recent screens?

Another note from me: I've made fresh build, restarted the app & it works as expected on iOS 🤷🏻 Dunno in what exact conditions the headerRight still breaks.

@kkafar
Copy link
Member

kkafar commented Jan 15, 2025

Okay, so the headerRight breaks in cases when it is added after the component is initially mounted.
If the screen loads with headerRight set it works fine. Another thing is that this only happens for headerRight.

@wal3d122
Copy link

@kkafar no still using the 4.5.0.

Tomorrow I'll try the PR and give feedback if needed.
I have never tried this before, would a special access be required or simply the PR hash would do?

@kkafar
Copy link
Member

kkafar commented Jan 15, 2025

@wal3d122 PR hash should be sufficient. However I recently received some reports that when installing from hash the android directory is not cloned properly (comes empty), therefore these steps might turn out useful

@wal3d122
Copy link

wal3d122 commented Jan 16, 2025

@kkafar
Just tried the PR on both android and iOS.
Seems to be more responsive yet the pressables still respond randomly.

On the debug version of the android, the behaviour is similar to what’s shown on the iOS device video below. Though on the release version, all seems to be responsive except for deeply nested pages where the button is responsive only when I aim to click it at the top half, when I press the bottom half, it doesn’t respond at all.

RPReplay_Final1737015042.mov

@kkafar
Copy link
Member

kkafar commented Jan 16, 2025

@wal3d122 thanks for testing this!

I'm trying now to reproduce the behaviour you showcased on the video but I fail to. On my end the pressables now work reliably - moreover, their reliability was never the problem for me, but rather they could not be pressed at all (only onPressIn would be dispatched).

This PR adds Test2466, would you be able to modify it (or create a snippet from scratch) so that I can reproduce the problem? That would be a huge help

I moved the state update logic from screen stack to config & subviews.
I've also made HeaderConfig implement `RCTMountingTransactionObserving` protocol
where it now requests layout if necessary.

I've also discovered a bug where after removal of one of header subviews
the layout is not reset to the same one as it would be when rendered w/o
the removed subview in the first place.
@kkafar
Copy link
Member

kkafar commented Jan 16, 2025

I think this PR is ready to roll. I'll release beta / rc version today's evening or tomorrow forenoon.

@kkafar kkafar merged commit e741b8e into main Jan 16, 2025
5 of 8 checks passed
@kkafar kkafar deleted the @dmalecki/pressable-in-header branch January 16, 2025 13:42
@wal3d122
Copy link

@kkafar tested Test2466 on both iOS and Android. Seem to work perfectly.
Our App buttons' use Touchable Opacities instead and they seem to still have the bug.

I'll update our header and test again with a pressable instead and see if that behaviour still persists.

@kkafar
Copy link
Member

kkafar commented Jan 16, 2025

@wal3d122 thanks for the info! I'll test Touchable component later today to see whether I can reproduce any issues

kkafar added a commit that referenced this pull request Jan 16, 2025
…rch only (#2624)

## Description

I've made mistake in #2466 - the aforementioned protocol does not exist
on Paper.

## Changes

Use ifdef directive to prevent build errors.


## Test code and steps to reproduce

iOS + old-arch should just build.

## Checklist

- [ ] Ensured that CI passes
@kkafar
Copy link
Member

kkafar commented Jan 16, 2025

I've released 4.6.0-beta.0

kkafar added a commit that referenced this pull request Jan 21, 2025
…g header subviews (#2623)

## Description

In #2466 I've introduced `RCTMountingTransactionObserving` for
`RNSScreenStackHeaderConfig` component.
Now we can use this to reduce amount of calls to
`updateViewControllerIfNeeded` (causing layout passes) when adding
subviews.

I'm not changing the `unmount` path of the code as we have some more
additional logic there which requires some more
careful consideration. 

This also aligns us with Paper implementation. Note that it has been
only implemented this way, because at the implementation time there was
no way
to run this code on transaction completion.

## Changes

`- [RNSScreenStackHeaderConfig updateViewControllerIfNeeded]` is now
called only once per transaction when adding subviews to header config.

## Test code and steps to reproduce

Test432, TestHeaderTitle, Test2552 - see that there are no regressions. 

> [!note]
During testing I've found that there is a bug with subview layout when
modifying subviews after first render. This is not a regression however.
Notice the wrong position of header right on video below 👇🏻 (Test432)



https://github.com/user-attachments/assets/7f8653e8-a7d9-4fb8-a875-182e7deb0495



## Checklist

- [x] Included code example that can be used to test this change
- [x] Ensured that CI passes
@JcbPrn
Copy link

JcbPrn commented Jan 22, 2025

@kkafar Thank you very much for the effort! Seems like header right still does not work on iPhone XS. I am using TouchableOpacity and 4.6.0-beta.0

@kkafar
Copy link
Member

kkafar commented Jan 22, 2025

@JcbPrn thanks for the report, I'll look into this. If you had capacity to provide me with snack/repo/snippet where I can reproduce the issue directly, that would be awesome.

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.

Pressable elements not working in native-stack on Android devices with new architecture