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(Android, Fabric): jumping content with native header #2169

Merged
merged 66 commits into from
Jun 18, 2024

Conversation

kkafar
Copy link
Member

@kkafar kkafar commented Jun 4, 2024

Description

This PR intents to solve the eternal issue with "jumping content" on Android & Fabric. The issue itself is present on every platform / RN architecture combination, however this PR scope is to solve situation only on Android + Fabric. Android + Paper, and iOS will be solved in separate PRs.

Note

These videos are recorded with topInsetEnabled: false, as this prop implementation causes another series of issues that will be handled separately.

Here is before & after comparison (best way to see is to go frame-by-frame)

Before After
before-topinsetenabled-false.mov
after-topinsetenabled-false.mov

This will even work with irregular font sizes!

Short issue genesis

Note

The flow described here below is a simplification, but should give you a good grasp on the issue.

Basically during the very first Yoga layout, that happens on secondary thread, React layout mechanism has no knowledge of the header size (there isn't even a node representing the header at appropriate tree-level present in shadow tree), thus the Screen content is layouted with more available space that it has in reality. These dimensions are then send to UI thread, and propagated bottom-up (children before parents) and Screen contents do receive too high frame. Then, when ScreenContainer / ScreenStack does receive its frame from RN, it triggers a fragmentary pass of native layout using CoordinatorLayout (the layout stops at Screen), offsetting the Screen by just-computed-header-height on Y axis, and in consequence pushing some of the Screen's contents off the screen (exactly a strip of header height). The situation is then salvaged by sending state update from Screen to React Native with new frame size.

Implemented solution

During the first Yoga layout pass (when there is not state from UI thread received yet) we utilise the fact that RN clones our ShadowNode & calls adapt on it. In the adapt method we call into JVM where we have set up a dummy view hierarchy with coordinator layout & header, we layout it & return result to C++ layer where we set bottom padding on the Screen so that its contents do have right amount of space.

Important

Downside of this solution is the fact, that the Yoga state / Shadow Tree still indicates that the Screen's origin is at (x, y) = (0, 0) and it still will have wrong dimensions.
Setting dummy dimension on HeaderConfig shadow node will improve situation only slightly, as the Screen will still have wrong origin, but it will have appropriate size immediately, hence Screen's state update might not trigger follow-up transaction. Thus I'm thinking now that I will update the solution.

Yet untested approaches

  • I want to try making custom descriptor for ScreenStack, and try to customise shadownode's layout method. <- tested this & I believe this will not work due to the fact, that ShadowNode.layout does not use layoutContext.viewportOffset at all (so we can not use this to offset our descendants). At the same time the layout method does not propagate layout metrics - they are extracted for each shadow node directly from it's yoga node and this process does not take into consideration parent's layout metrics. However, if the x, y view origin coordinates determined by yoga are in parent node coordinate system we can use HeaderConfig to ensure appropriate Screens size and at the same time set frame.y manually!

Test code and steps to reproduce

I've added TestHeader test case. It's best to run it with FabricTestExample, record it, and then see frame-by-frame that the content no longer jumps.

Checklist

  • Included code example that can be used to test this change
  • Ensured that CI passes

@kkafar kkafar marked this pull request as draft June 7, 2024 00:21
@kkafar kkafar marked this pull request as ready for review June 8, 2024 10:18
kkafar added a commit that referenced this pull request Aug 27, 2024
## Description

This PR applies modifications to a previous fix:
#2169 for
fabric only, which has stopped working since RN `0.75`.

In RN `0.74` the `adopt` in `RNSScreenComponentDescriptior.h` was once
called without `stateData` but with children and we could then check if
the `ScreenStackHeaderConfig` is present among them and make adjustments
based on it.

When working on
#2292 it
became clear that the fix does not work anymore. Now the `adopt` is
called either with no children and no `stateData` or with both.
The solution is to move the code to `appendChild` in
`RNSScreenShadowNode.cpp` so we can perform the adjustments as soon as
the children append.

## Changes

- moved code from `adopt` in `RNSScreenComponentDescriptior.h` to newly
added `appendChild` override in `RNSScreenShadowNode.cpp`

## Screenshots / GIFs

### Before


https://github.com/user-attachments/assets/6b76864b-58bb-4c6e-9f5b-a01bb0c88d2a

### After


https://github.com/user-attachments/assets/98931e77-3877-4f67-8b28-f49d2e0f42ff


## Test code and steps to reproduce

- Use `TestHeader` to test this change

## Checklist

- [ ] Included code example that can be used to test this change
- [ ] Updated TS types
- [ ] Updated documentation: <!-- For adding new props to native-stack
-->
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx
- [ ]
https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx
- [ ] Ensured that CI passes

---------

Co-authored-by: Kacper Kafara <kacperkafara@gmail.com>
kkafar added a commit that referenced this pull request Aug 27, 2024
…ement (#2292)

## Description

> [!important]
This PR aims to fix only pressables on screen components. This PR does
not fix similar pressable issue with pressables in native header. That
interaction will be fixed separately.

Pressable elements work just fine until there's a gesture involved. On
sensitive physical devices even a little movement during the press is
treated as a gesture.

When the `Pressable` element detects a gesture it calls
[onResponderMove](https://github.com/facebook/react-native/blob/82795715aefba07ae9d79278ce3fd4d2e9a928f2/packages/react-native/Libraries/Pressability/Pressability.js#L484)
which then checks wether the gesture happened within the element or went
outside by comparing the touch coordinates with coordinates of the
element using `_isTouchWithinResponderRegion`.

The `responderRegion` is obtained from `_responderID` and happens to
have unexpected values when the native header is present. It tuns out
that the Y origin is slightly off. After some further investigation and
comparison of coordinates it turned out that the height of the android
status bar is not well calculated in various scenarios:

<table>
<td>

`statusBarHidden: true`

</td>
<td>

`statusBarTranslucent: true`

</td>
<td>

`statusBarTranslucent: false`

</td>
</tr>
<tr>
<td>


![Screenshot_1723212300](https://github.com/user-attachments/assets/57e2f4a3-b002-4ca3-9519-45cfece860c4)

</td>
<td>


![Screenshot_1723212331](https://github.com/user-attachments/assets/bd46c8d1-8813-4fae-a8a9-0326193371d2)

</td>
<td>


![Screenshot_1723212382](https://github.com/user-attachments/assets/c7373437-524d-4a0f-951e-ce2689a4fe5c)

</td>
</tr>
</table>

The `calculateHeaderHeight` used for calculating the header and
statusBar height seems to be the problem. Luckily, we don't have to
calculate it by ourselves anymore, because the correct `t` value is
provided in the `onLayout` function of the `Screen`. Thus we can get rid
of the custom function.

Another issue found: after navigating to another screen the offset is
off again (exactly by 2x). It's caused by changes introduced in [this
PR](#2169),
which was supposed to prevent content jumps, but doesn't work since RN
`0.75` sadly.


![Screenshot_1723220034](https://github.com/user-attachments/assets/b0908c23-4667-4ccf-8e5e-5e7e11bca316)

I found out that `FrameOriginCorrection` is not being unset when
dimensions from JVM are received, while the `FrameHeightCorrection` is.
After adding the missing unset for `FrameOriginCorrection` I rolled back
to the commit with the mentioned PR merged and RN `0.74` and I can
confirm it works.

Fixes #1975 

## Changes

- removed `calculateHeaderHeight` function
- added unset for `FrameOriginCorrection` when dimensions from JVM are
received
- added `Test1975.tsx` repro
- moved code responsible for determining header height during the very
first render from component descriptor's `adopt` method to shadow node
`appendChild`.


## Test code and steps to reproduce

`TestHeader`, `Test1975`

## Checklist

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

---------

Co-authored-by: alduzy <alduzy@gmail.com>
Co-authored-by: Alex Duży <91994767+alduzy@users.noreply.github.com>
ja1ns pushed a commit to WiseOwlTech/react-native-screens that referenced this pull request Oct 9, 2024
…-mansion#2176)

## Description

Just noticed while working on software-mansion#2169 that we got a warning in
CustomToolbar.

Edit: followed review suggestions and suppressed lints for all our view,
where this was requried.

## Changes

Suppressed lint on missing constructors. 

We're safe to miss these there, as this view is constructed only
programatically (we do not inflate any of our views).

## Test code and steps to reproduce

N/A

## Checklist

- [ ] Ensured that CI passes
ja1ns pushed a commit to WiseOwlTech/react-native-screens that referenced this pull request Oct 9, 2024
…nsion#2169)

## Description

This PR intents to solve the *eternal* issue with "jumping content" on
Android & Fabric. The issue itself is present on every platform / RN
architecture combination, however this PR scope is to solve situation
only on Android + Fabric. Android + Paper, and iOS will be solved in
separate PRs.

> [!note]
> These videos are recorded with `topInsetEnabled: false`, as this prop
implementation causes another series of issues that will be handled
separately.

Here is before & after comparison (best way to see is to go
frame-by-frame)

| Before | After |
|--------|--------|
| <video width="320" height="240" controls
src="https://github.com/software-mansion/react-native-screens/assets/50801299/e1e995b5-885b-4bd4-941a-57cdc6b321b2"></video>
| <video width="320" height="240" controls
src="https://github.com/software-mansion/react-native-screens/assets/50801299/6ca87888-0f05-4dfc-b6db-bfd08e3735b3"></video>
|

This will even work with irregular font sizes!

### Short issue genesis

> [!note]
> The flow described here below is a simplification, but should give you
a good grasp on the issue.

Basically during the very first Yoga layout, that happens on secondary
thread, React layout mechanism has no knowledge of the header size
(there isn't even a node representing the header at appropriate
tree-level present in shadow tree), thus the `Screen` content is
layouted with more available space that it has in reality. These
dimensions are then send to UI thread, and propagated bottom-up
(children before parents) and `Screen` contents do receive too high
frame. Then, when `ScreenContainer` / `ScreenStack` does receive its
frame from RN, it triggers a fragmentary pass of native layout using
`CoordinatorLayout` (the layout stops at `Screen`), offsetting the
`Screen` by just-computed-header-height on Y axis, and in consequence
pushing some of the `Screen`'s contents off the screen (exactly a strip
of header height). The situation is then salvaged by sending state
update from `Screen` to React Native with new frame size.

### Implemented solution

During the first Yoga layout pass (when there is not state from UI
thread received yet) we utilise the fact that RN clones our ShadowNode &
calls `adapt` on it. In the adapt method we call into JVM where we have
set up a dummy view hierarchy with coordinator layout & header, we
layout it & return result to C++ layer where we set bottom padding on
the Screen so that its contents do have right amount of space.

> [!important]
> Downside of this solution is the fact, that the Yoga state / Shadow
Tree still indicates that the `Screen`'s origin is at `(x, y) = (0, 0)`
and it still will have wrong dimensions.
Setting dummy dimension on `HeaderConfig` shadow node will improve
situation only slightly, as the `Screen` will still have wrong origin,
but it will have appropriate size immediately, **hence `Screen`'s state
update might not trigger follow-up transaction**. Thus I'm thinking now
that I will update the solution.

### Yet ~un~tested approaches

* ~I want to try making custom descriptor for `ScreenStack`, and try to
customise shadownode's layout method.~ <- tested this & I believe this
will not work due to the fact, that `ShadowNode.layout` does not use
`layoutContext.viewportOffset` at all (so we can not use this to offset
our descendants). At the same time the `layout` method does not
propagate layout metrics - they are extracted for each shadow node
directly from it's yoga node and this process does not take into
consideration parent's layout metrics. **However, if the `x, y` view
origin coordinates determined by yoga are in parent node coordinate
system** we can use `HeaderConfig` to ensure appropriate `Screens` size
and at the same time set `frame.y` manually!


## Test code and steps to reproduce

I've added `TestHeader` test case. It's best to run it with
`FabricTestExample`, record it, and then see frame-by-frame that the
content no longer jumps.

## Checklist

- [x] Included code example that can be used to test this change
- [ ] Ensured that CI passes
ja1ns pushed a commit to WiseOwlTech/react-native-screens that referenced this pull request Oct 9, 2024
…text (software-mansion#2199)

## Description

My recent PR:

* software-mansion#2169

introduced creation of dummy layout, which requires react context to be
attached to activity, as we need access
to the activity. Unfortunately when running on Paper the context is not
attached to the activity yet, resulting
in exception being thrown.

<details>
    <summary>Exception</summary>

```
Failed to create NativeModule 'UIManager'
    java.lang.IllegalArgumentException: [RNScreens] Attempt to use context detached from activity
        at com.swmansion.rnscreens.utils.ScreenDummyLayoutHelper.ensureDummyLayoutWithHeader(ScreenDummyLayoutHelper.kt:68)
        at com.swmansion.rnscreens.utils.ScreenDummyLayoutHelper.<init>(ScreenDummyLayoutHelper.kt:53)
        at com.swmansion.rnscreens.RNScreensPackage.createViewManagers(RNScreensPackage.kt:28)
        at com.facebook.react.ReactInstanceManager.getOrCreateViewManagers(ReactInstanceManager.java:933)
        at com.swmansion.reanimated.ReanimatedPackage.createUIManager(ReanimatedPackage.java:78)
        at com.swmansion.reanimated.ReanimatedPackage.getModule(ReanimatedPackage.java:38)
        at com.facebook.react.BaseReactPackage$ModuleHolderProvider.get(BaseReactPackage.java:156)
        at com.facebook.react.BaseReactPackage$ModuleHolderProvider.get(BaseReactPackage.java:144)
        at com.facebook.react.bridge.ModuleHolder.create(ModuleHolder.java:186)
        at com.facebook.react.bridge.ModuleHolder.getModule(ModuleHolder.java:151)
        at com.facebook.react.bridge.NativeModuleRegistry.getModule(NativeModuleRegistry.java:148)
        at com.facebook.react.bridge.CatalystInstanceImpl.getNativeModule(CatalystInstanceImpl.java:469)
        at com.facebook.react.bridge.CatalystInstanceImpl.getNativeModule(CatalystInstanceImpl.java:445)
        at com.facebook.react.uimanager.UIManagerHelper.getUIManager(UIManagerHelper.java:88)
        at com.facebook.react.uimanager.UIManagerHelper.getUIManager(UIManagerHelper.java:46)
        at com.facebook.react.ReactInstanceManager.attachRootViewToInstance(ReactInstanceManager.java:1231)
        at com.facebook.react.ReactInstanceManager.setupReactContext(ReactInstanceManager.java:1180)
        at com.facebook.react.ReactInstanceManager.lambda$runCreateReactContextOnNewThread$1(ReactInstanceManager.java:1143)
        at com.facebook.react.ReactInstanceManager.$r8$lambda$FD-H2RG7CdgXPtYJUBikxLbd8MA(Unknown Source:0)
        at com.facebook.react.ReactInstanceManager$$ExternalSyntheticLambda4.run(Unknown Source:4)
        at android.os.Handler.handleCallback(Handler.java:958)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:27)
        at android.os.Looper.loopOnce(Looper.java:205)
        at android.os.Looper.loop(Looper.java:294)
        at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:233)
```

</details>

I'll need to sort that out when working on fix for jumping content on
Android + Paper combination, however right now
it is more important for examples to work correctly.

## Changes

Creating `ScreenDummyLayoutHelper` now only when running on new
architecture.

## Test code and steps to reproduce

Run `TestsExample` w/o this change, you will see the exception being
thrown -> resulting in freeze on whitescreen.
With this change the example runs normally.

## Checklist

- [x] Ensured that CI passes
ja1ns pushed a commit to WiseOwlTech/react-native-screens that referenced this pull request Oct 9, 2024
…ement (software-mansion#2292)

## Description

> [!important]
This PR aims to fix only pressables on screen components. This PR does
not fix similar pressable issue with pressables in native header. That
interaction will be fixed separately.

Pressable elements work just fine until there's a gesture involved. On
sensitive physical devices even a little movement during the press is
treated as a gesture.

When the `Pressable` element detects a gesture it calls
[onResponderMove](https://github.com/facebook/react-native/blob/82795715aefba07ae9d79278ce3fd4d2e9a928f2/packages/react-native/Libraries/Pressability/Pressability.js#L484)
which then checks wether the gesture happened within the element or went
outside by comparing the touch coordinates with coordinates of the
element using `_isTouchWithinResponderRegion`.

The `responderRegion` is obtained from `_responderID` and happens to
have unexpected values when the native header is present. It tuns out
that the Y origin is slightly off. After some further investigation and
comparison of coordinates it turned out that the height of the android
status bar is not well calculated in various scenarios:

<table>
<td>

`statusBarHidden: true`

</td>
<td>

`statusBarTranslucent: true`

</td>
<td>

`statusBarTranslucent: false`

</td>
</tr>
<tr>
<td>


![Screenshot_1723212300](https://github.com/user-attachments/assets/57e2f4a3-b002-4ca3-9519-45cfece860c4)

</td>
<td>


![Screenshot_1723212331](https://github.com/user-attachments/assets/bd46c8d1-8813-4fae-a8a9-0326193371d2)

</td>
<td>


![Screenshot_1723212382](https://github.com/user-attachments/assets/c7373437-524d-4a0f-951e-ce2689a4fe5c)

</td>
</tr>
</table>

The `calculateHeaderHeight` used for calculating the header and
statusBar height seems to be the problem. Luckily, we don't have to
calculate it by ourselves anymore, because the correct `t` value is
provided in the `onLayout` function of the `Screen`. Thus we can get rid
of the custom function.

Another issue found: after navigating to another screen the offset is
off again (exactly by 2x). It's caused by changes introduced in [this
PR](software-mansion#2169),
which was supposed to prevent content jumps, but doesn't work since RN
`0.75` sadly.


![Screenshot_1723220034](https://github.com/user-attachments/assets/b0908c23-4667-4ccf-8e5e-5e7e11bca316)

I found out that `FrameOriginCorrection` is not being unset when
dimensions from JVM are received, while the `FrameHeightCorrection` is.
After adding the missing unset for `FrameOriginCorrection` I rolled back
to the commit with the mentioned PR merged and RN `0.74` and I can
confirm it works.

Fixes software-mansion#1975 

## Changes

- removed `calculateHeaderHeight` function
- added unset for `FrameOriginCorrection` when dimensions from JVM are
received
- added `Test1975.tsx` repro
- moved code responsible for determining header height during the very
first render from component descriptor's `adopt` method to shadow node
`appendChild`.


## Test code and steps to reproduce

`TestHeader`, `Test1975`

## Checklist

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

---------

Co-authored-by: alduzy <alduzy@gmail.com>
Co-authored-by: Alex Duży <91994767+alduzy@users.noreply.github.com>
kkafar added a commit that referenced this pull request Oct 25, 2024
…ement (#2292)

> [!important]
This PR aims to fix only pressables on screen components. This PR does
not fix similar pressable issue with pressables in native header. That
interaction will be fixed separately.

Pressable elements work just fine until there's a gesture involved. On
sensitive physical devices even a little movement during the press is
treated as a gesture.

When the `Pressable` element detects a gesture it calls
[onResponderMove](https://github.com/facebook/react-native/blob/82795715aefba07ae9d79278ce3fd4d2e9a928f2/packages/react-native/Libraries/Pressability/Pressability.js#L484)
which then checks wether the gesture happened within the element or went
outside by comparing the touch coordinates with coordinates of the
element using `_isTouchWithinResponderRegion`.

The `responderRegion` is obtained from `_responderID` and happens to
have unexpected values when the native header is present. It tuns out
that the Y origin is slightly off. After some further investigation and
comparison of coordinates it turned out that the height of the android
status bar is not well calculated in various scenarios:

<table>
<td>

`statusBarHidden: true`

</td>
<td>

`statusBarTranslucent: true`

</td>
<td>

`statusBarTranslucent: false`

</td>
</tr>
<tr>
<td>

![Screenshot_1723212300](https://github.com/user-attachments/assets/57e2f4a3-b002-4ca3-9519-45cfece860c4)

</td>
<td>

![Screenshot_1723212331](https://github.com/user-attachments/assets/bd46c8d1-8813-4fae-a8a9-0326193371d2)

</td>
<td>

![Screenshot_1723212382](https://github.com/user-attachments/assets/c7373437-524d-4a0f-951e-ce2689a4fe5c)

</td>
</tr>
</table>

The `calculateHeaderHeight` used for calculating the header and
statusBar height seems to be the problem. Luckily, we don't have to
calculate it by ourselves anymore, because the correct `t` value is
provided in the `onLayout` function of the `Screen`. Thus we can get rid
of the custom function.

Another issue found: after navigating to another screen the offset is
off again (exactly by 2x). It's caused by changes introduced in [this
PR](#2169),
which was supposed to prevent content jumps, but doesn't work since RN
`0.75` sadly.

![Screenshot_1723220034](https://github.com/user-attachments/assets/b0908c23-4667-4ccf-8e5e-5e7e11bca316)

I found out that `FrameOriginCorrection` is not being unset when
dimensions from JVM are received, while the `FrameHeightCorrection` is.
After adding the missing unset for `FrameOriginCorrection` I rolled back
to the commit with the mentioned PR merged and RN `0.74` and I can
confirm it works.

Fixes #1975

- removed `calculateHeaderHeight` function
- added unset for `FrameOriginCorrection` when dimensions from JVM are
received
- added `Test1975.tsx` repro
- moved code responsible for determining header height during the very
first render from component descriptor's `adopt` method to shadow node
`appendChild`.

`TestHeader`, `Test1975`

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

---------

Co-authored-by: alduzy <alduzy@gmail.com>
Co-authored-by: Alex Duży <91994767+alduzy@users.noreply.github.com>
(cherry picked from commit 34c1ba8)
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.

3 participants