-
Notifications
You must be signed in to change notification settings - Fork 24.4k
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 handling removal of transitioning views #47634
Conversation
This is interesting! It looks like there could certainly be issues with the current code largely ignoring transitions. The current commit looks a little too simple, since this is the only call to The "first approach" seems like it should work, as long as the implementation is careful not to hold onto views after they're otherwise removed. I'm not quite clear on how you envision the second approach - where/how would you be checking parent vs. child? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tdn120 thanks for taking a look.
I've incorporated your recent changes: 6a472c5, 0b22b95, 3d1a505 & f1d7016 and made the implementation more general.
We now track whether the view is transitioning or not by overriding the startViewTransition
& endViewTransition
methods. When removing a child from parent with removeClippedSubviews == true
we now check whether the view was clipped: whether it is effectively detached from parent OR removal was requested during transition.
There is an alternative to holding the state - we could write isViewClipped(child)
as follows:
return child.getParent() == null || this.indexOfChild(child) == -1;
^^ this could also detect whether the view is transitioning or not.
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
@@ -133,6 +133,8 @@ public void shutdown() { | |||
private @Nullable ViewGroupDrawingOrderHelper mDrawingOrderHelper; | |||
private float mBackfaceOpacity; | |||
private String mBackfaceVisibility; | |||
private boolean mIsTransitioning = false; | |||
private @Nullable Set<Integer> mChildrenRemovedWhileTransitioning = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm using Set<Integer>
(holding only the view tag) instead of Set<View>
to avoid any retain issues.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using androidx.collection.MutableIntSet instead to avoid Integer boxing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I was not aware of this API! Hover it seems that it has been added few months ago in androidx.collection.MutableIntSet
1.4.0, while my development project (using recent react-native 0.76) uses version 1.1.0. I'm not sure where would I need to bump / add this dependency in RN to make use of it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this would need to be a new dependency in https://github.com/facebook/react-native/blob/main/packages/react-native/gradle/libs.versions.toml#L45 - let's add a comment to change this in the future.
if (child.parent != null) { | ||
parent.removeView(child) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, this is really interesting, and may explain a crash we're seeing. I wonder why we were previously removing this subview twice - potentially messing up some state in ReactViewGroup.
What's your reasoning for removing this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@javache thanks for review. My logic was as follows:
before 0b22b95 you relied on handleRemoveView
being called to perform side effects regarding child drawing order. Implementation of ReactViewgroup.removeView
took care of handling these sideeffects and detaching child view from parent.
On the other hand ReactViewGroup.removeViewWithSubviewClippingEnabled
had also two responsibilities:
- detach child from parent if it has not been detached yet (notice the guard here!)
- remove child from
mAllChildren
array, which also retained children already detached due to clipping.
Notice, that it had not performed handleRemoveView
.
My theory is that in clipping view manager you had call to parent.removeView(child)
to ensure sideeffects of handleRemoveView
were performed and then you called parent.removeViewWithSubviewClippingEnabled(child)
to remove child from mAllChildren
array, relying on the guard from (1), which prevented Android crash potentially caused by removing twice the same child view.
After 0b22b95 side effects of handleRemoveView
have been moved to onViewRemoved
which is called on any attempt of child removal by the Android framework. Therefore we can remove the call to parent.removeView
as all required side effects will be performed by the call to parent.removeViewWithSubviewClippingEnabled(child)
- drawing order code, removal from mAllChildren
and child detachment. I've left the check for nullish child, as I couldn't determine what was the logic behind it.
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
@@ -133,6 +133,8 @@ public void shutdown() { | |||
private @Nullable ViewGroupDrawingOrderHelper mDrawingOrderHelper; | |||
private float mBackfaceOpacity; | |||
private String mBackfaceVisibility; | |||
private boolean mIsTransitioning = false; | |||
private @Nullable Set<Integer> mChildrenRemovedWhileTransitioning = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using androidx.collection.MutableIntSet instead to avoid Integer boxing.
Thanks for review! I'll update the code in few hours |
This comment was marked as duplicate.
This comment was marked as duplicate.
@javache has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator. |
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
mIsTransitioning = false; | ||
mChildrenRemovedWhileTransitioning = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this account for multiple views/transitions? Seems like we'd need to remove the id(s) instead, and then:
mIsTransitioning = mChildrenRemovedWhileTransitioning.isEmpty();
Also probably worth keeping mChildrenRemovedWhileTransitioning
once we create it, we just don't want to create on init, it in cases where transitions aren't used at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've changed nulling the hashset to clearing it here: a7c7af3.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tdn120 I'm not sure though, what you mean by this:
Does this account for multiple views/transitions? Seems like we'd need to remove the id(s) instead [...]
Could you elaborate here?
The ReactViewGroup
instance is responsible only of tracking its transitioning state & children. I wrote it so to align it with Android parent.startViewTransition(child)
behaviour - it modifies only the parent
behaviour with respect to this single child
. The child
is unaware of any changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just at a basic level, if you have a timeline of:
startViewTransition(viewA)
startViewTransition(viewB)
endViewTransition(viewA)
At this point you would clear mIsTransitioning
and mChildrenRemovedWhileTransitioning
, so wouldn't viewB
be out of sync until its transition ends?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was OOO by the end of last week. Back here now 👋🏻
I think I still don't understand the issue here.
Android docs explicitly state that a call to startViewTransition
should be accompanied by call to endViewTransition
. It is left up to the programmer to ensure this.
If you - as a API user - fail to fulfil this contract you'll get inconsistent (possibly buggy) behaviour and I believe there is not much we can do on React Native end to prevent that.
Regarding the example:
this.startViewTransition(viewA)
this.startViewTransition(viewB)
this.endViewTransition(viewA)
...
Oh, it just got to me. Yeah, I think you're right and that is a very good catch. Thanks. I'll update the code in a bit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should keep track of which children are transitioning and clear the state only if there are no more such children.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tdn120 I am thinking about various approaches, trying to avoid allocating additional hash set as much as possible. I would need it to store all the children for which startViewTransition
has been called and then remove them from the set in endViewTransition
. The working approach would look somehow like that:
public boolean isChildTransitioning(View child) {
return mTransitioningChildren != null && mTransitioningChildren.contains(child.getid());
}
public void onViewRemoved(View child) {
...
if (isChildTransitioning(child)) {
// This call would allocate
ensureChildrenRemovedWhileTransitioning.add(child.getId());
}
...
}
public void startViewTransition(View view) {
super.startViewTransition(view);
ensureTransitioningChildren().add(view.getId());
}
public void endViewTransition(View view) {
super.endViewTransition(view);
ensureTransitioningChildren().remove(view.getId());
if (mChildrenRemovedWhileTransitioning != null) {
mChildrenRemovedWhileTransitioning.remove(view.getId());
}
}
However, this requires 2 hash sets...
Only way to improve I see here is to rely in onViewRemoved
on the fact, that if children is transitioning and it has been removed then its parent won't be nulled out.
Hence we could check it like this:
public void onViewRemoved(View child) {
...
if (child.getParent() != null) {
ensureChildrenRemovedWhileTransitioning.add(child.getId());
}
...
}
Looking at Android code (link) this would be reliable, but we would be relaying on implementation detail. I feel like this behaviour is implicitly suggested in startViewTransition
& endViewTransition
docs, by stating that these remove child from parent state but noting about removing parent from child state, however this is not explicit.
Even if I'm overlooking some case this still would solve the initial issue, so we should be fine on this front.
Another possibility is to store the information on whether the view is transitioning or not directly on the child view itself, by using a tag as you suggested.
Lemme know which behavior you would agree for.
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
...es/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java
Outdated
Show resolved
Hide resolved
Just for transparency, I'm also working on an alternative that might handle transitions as a side effect: But still iterating, so I don't want to block this in the meantime. |
Latest version lgtm, but I'm going to be landing #47987. If you don't mind, I'll rebase this and update the PR so we can land it afterwards. |
@tdn120 has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator. |
Whoops, I can't update your PR. Rebased and made a couple small tweaks, you should be able to see the changes once it lands. |
This pull request was successfully merged by @kkafar in f402ed1 When will my fix make it into a release? | How to file a pick request? |
Thanks! |
Summary: Related PR in `react-native-screens`: * software-mansion/react-native-screens#2495 Additional context: * [my detailed explanation of **one of the issues**](software-mansion/react-native-screens#2495 (comment)) * [Android Developer: ViewGroup.startViewTransition docs](https://developer.android.com/reference/android/view/ViewGroup#startViewTransition(android.view.View)) On Android view groups can be marked as "transitioning" with a `ViewGroup.startViewTransition` call. This effectively ensures, that in case a view group is marked with this call and its children are removed, they will be still drawn until `endViewTransition` is not called. This mechanism is implemented in Android by [keeping track of "transitioning" children in auxiliary `mTransitioningViews` array](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewGroup.java#7178). Then when such "transitioning" child is removed, [it is removed from children array](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewGroup.java#5595) but it's [parent-child relationship is not cleared](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewGroup.java#5397) and it is still retained in the auxiliary array. Having that established we can proceed with problem description. https://github.com/user-attachments/assets/d0356bf5-2f17-4b06-ba53-bfca659a1071 <details> <summary>Full code</summary> ```javascript import { NavigationContainer } from 'react-navigation/native'; import React from 'react'; import { createNativeStackNavigator } from 'react-navigation/native-stack'; import { enableScreens } from 'react-native-screens'; import { StyleSheet, Text, View, FlatList, Button, ViewProps, Image, FlatListProps, findNodeHandle, } from 'react-native'; enableScreens(true); function Item({ children, ...props }: ViewProps) { return ( <View style={styles.item} {...props}> <Image source={require('../assets/trees.jpg')} style={styles.image} /> <Text style={styles.text}>{children}</Text> </View> ); } function Home({ navigation }: any) { return ( <View style={styles.container}> <Button title="Go to List" onPress={() => navigation.navigate('List')} /> </View> ); } function ListScreenSimplified({secondVisible}: {secondVisible?: (visible: boolean) => void}) { const containerRef = React.useRef<View>(null); const innerViewRef = React.useRef<View>(null); const childViewRef = React.useRef<View>(null); React.useEffect(() => { if (containerRef.current != null) { const tag = findNodeHandle(containerRef.current); console.log(`Container has tag [${tag}]`); } if (innerViewRef.current != null) { const tag = findNodeHandle(innerViewRef.current); console.log(`InnerView has tag [${tag}]`); } if (childViewRef.current != null) { const tag = findNodeHandle(childViewRef.current); console.log(`ChildView has tag [${tag}]`); } }, [containerRef.current, innerViewRef.current, childViewRef.current]); return ( <View ref={containerRef} style={{ flex: 1, backgroundColor: 'slateblue', overflow: 'hidden' }} removeClippedSubviews={false}> <View ref={innerViewRef} removeClippedSubviews style={{ height: '100%' }}> <View ref={childViewRef} style={{ backgroundColor: 'pink', width: '100%', height: 50 }} removeClippedSubviews={false}> {secondVisible && (<Button title='Hide second' onPress={() => secondVisible(false)} />)} </View> </View> </View> ); } function ParentFlatlist(props: Partial<FlatListProps<number>>) { return ( <FlatList data={Array.from({ length: 30 }).fill(0) as number[]} renderItem={({ index }) => { if (index === 10) { return <NestedFlatlist key={index} />; } else if (index === 15) { return <ExtraNestedFlatlist key={index} />; } else if (index === 20) { return <NestedFlatlist key={index} horizontal />; } else if (index === 25) { return <ExtraNestedFlatlist key={index} horizontal />; } else { return <Item key={index}>List item {index + 1}</Item>; } }} {...props} /> ); } function NestedFlatlist(props: Partial<FlatListProps<number>>) { return ( <FlatList style={[styles.nestedList, props.style]} data={Array.from({ length: 10 }).fill(0) as number[]} renderItem={({ index }) => ( <Item key={'nested' + index}>Nested list item {index + 1}</Item> )} {...props} /> ); } function ExtraNestedFlatlist(props: Partial<FlatListProps<number>>) { return ( <FlatList style={styles.nestedList} data={Array.from({ length: 10 }).fill(0) as number[]} renderItem={({ index }) => index === 4 ? ( <NestedFlatlist key={index} style={{ backgroundColor: '#d24729' }} /> ) : ( <Item key={'nested' + index}>Nested list item {index + 1}</Item> ) } {...props} /> ); } const Stack = createNativeStackNavigator(); export default function App(): React.JSX.Element { return ( <NavigationContainer> <Stack.Navigator screenOptions={{ animation: 'slide_from_right' }}> <Stack.Screen name="Home" component={Home} /> <Stack.Screen name="List" component={ListScreenSimplified}/> </Stack.Navigator> </NavigationContainer> ); } export function AppSimple(): React.JSX.Element { const [secondVisible, setSecondVisible] = React.useState(false); return ( <View style={{ flex: 1, backgroundColor: 'lightsalmon' }}> {!secondVisible && ( <View style={{ flex: 1, backgroundColor: 'lightblue' }} > <Button title='Show second' onPress={() => setSecondVisible(true)} /> </View> )} {secondVisible && ( <ListScreenSimplified secondVisible={setSecondVisible} /> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, nestedList: { backgroundColor: '#FFA07A', }, item: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 10, }, text: { fontSize: 24, fontWeight: 'bold', color: 'black', }, image: { width: 50, height: 50, }, }); ``` </details> Explanation (copied from [here](software-mansion/react-native-screens#2495 (comment))): I've debugged this for a while now & I have good understanding of what's going on. This bug is caused by our usage of `startViewTransition` and its implications. We use it well, however React does not account for case that some view might be in transition. Error mechanism is as follows: 1. Let's have initially simple stack with two screens: "A, B". This is component rendered under "B": ```javascript <View //<-- ContainerView (CV) removeClippedSubviews={false} style={{ flex: 1, backgroundColor: 'slateblue', overflow: 'hidden' }}> <View removeClippedSubviews style={{ height: '100%' }}> // <--- IntermediateView (IV) <View removeClippedSubviews={false} style={{ backgroundColor: 'pink', width: '100%', height: 50 }} /> // <--- ChildView (ChV) </View> </View> ``` 2. We press the back button. 3. We're on Fabric, therefore subtree of B gets destroyed before B itself is unmounted -> in our commit hook we detect that the screen B will be unmounted & we mark every node under B as transitioning by calling `startViewTransition`. 4. React Mounting stage starts, view hierarchy is disassembled in bottom-up fashion (leafs first). 5. ReactViewGroupManager receives MountItem to detach ChV from IV. 6. A call to [`IV.removeView(ChV)` is made](https://github.com/facebook/react-native/blob/9c11d7ca68c5c62ab7bab9919161d8417e96b28b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactClippingViewManager.kt#L58-L73), which effectively removes ChV from `IV.children`, ***HOWEVER*** it does not clear `ChV.parent`, meaning that after the call, `ChV.parent == IV`. This happens, due to view being marked as in-transition by our call to `startViewTransition`. If the view is not marked as in-transition this parent-child relationship is removed. 7. IV has `removeClippedSubviews` enabled, therefore a [call to `IV.removeViewWithSubviewsClippingEnabled(ChV)` is made](https://github.com/facebook/react-native/blob/9c11d7ca68c5c62ab7bab9919161d8417e96b28b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactClippingViewManager.kt#L68). [This function](https://github.com/facebook/react-native/blob/9c11d7ca68c5c62ab7bab9919161d8417e96b28b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java#L726-L744) does effectively two things: 1. if the ChV has parent (interpretation: it has not yet been detached from parent), we compute it's index in `IV.children` (Android.ViewGroup's state) and remove it from the array, 2. remove the ChV from `mAllChildren` array (this is state maintained by ReactViewGroup for purposes of implementing the "subview clipping" mechanism". The crash happens in 7.1, because ChV has been removed from `IV.children` in step 6, but the parent-child relationship has not been broken up there. Under usual circumstances (this is my hypothesis now, yet unconfirmed) 7.1 does not execute, because `ChV.parent` is nulled in step no. 6. Transitions. On Fabric, when some subtree is unmounted, views in the subtree are unmounted in bottom-up order. This leads to uncomfortable situation, where our components (react-native-screens), who want to drive & manage transitions are notified that their children will be removed after the subtrees mounted in screen subviews are already disassembled. **If we start animation in this very moment we will have staggering effect of white flash** [(issue)](software-mansion/react-native-screens#1685) (we animate just the screen with white background without it's children). This was not a problem on Paper, because the order of subtree disassembling was opposite - top-down. While we've managed to workaround this issue on Fabric using `MountingTransactionObserving` protocol on iOS and a commit hook on Android (we can inspect mutations in incoming transaction before it starts being applied) we still need to prevent view hierarchy from being disassembled in the middle of transition (on Paper this has also been less of an issue) - and this is where `startViewTransition` comes in. It allows us to draw views throughout transition after React Native removes them from HostTree model. On iOS we exchange subtree for its snapshot for transition time, however this approach isn't feasible on Android, because [snapshots do not capture shadows](https://stackoverflow.com/questions/42212600/android-screenshot-of-view-with-shadow). [Android does not expose a method to verify whether a view is in transition](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewGroup.java#7162) (it has `package` visibility), therefore we need to retrieve this information with some workaround. I see two posibilities: * first approach would be to override `startViewTransition` & `endViewTransition` in ReactViewGroup and keep the state on whether the view is transitioning there, * second possible approach would be as follows: we can check for "transitioning" view by checking whether a view has parent but is not it's parent child (this **should** be reliable), Having information on whether the view is in transition or not, we can prevent multiple removals of the same view in every call site (currently only in `removeViewAt` if `parent.removeClippingSubviews == true`). Another option would be to do just as this PR does: having in mind this "transitioning" state we can pass a flag to `removeViewWithSubviewClippingEnabled` and prevent duplicated removal from parent if we already know that this has been requested. I can also add override of this method: ```java /*package*/ void removeViewWithSubviewClippingEnabled(View view) { this.removeViewWithSubviewClippingEnabled(view, false); } ``` to make this parameter optional. [ANDROID] [FIXED] - Handle removal of in-transition views. Pull Request resolved: #47634 Test Plan: WIP WIP Reviewed By: javache Differential Revision: D66539065 Pulled By: tdn120 fbshipit-source-id: cf1add67000ebd1b5dfdb2048461a55deac10b16
Summary: With the call to `removeView()` removed from `ReactViewClippingManager` in facebook#47634, we're seeing views erroneously sticking around in the layout. While `removeViewSithSubviewClippingEnabled()` calls `removeViewsInLayout()`, it does not trigger the corresponding side effects of [removeView()](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5501): ``` public void removeView(View view) { if (removeViewInternal(view)) { --> requestLayout(); --> invalidate(true); } } ``` To compensate, flip `removeViewsInLayout()` to [`removeViews()`](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5562), which will ensure layout. Changelog: [Android][Fixed] Restore layout/invalidate during ReactViewClippingManager.removeViewAt() Differential Revision: D67398971
Summary: With the call to `removeView()` removed from `ReactViewClippingManager` in facebook#47634, we're seeing views erroneously sticking around in the layout. While `removeViewWithSubviewClippingEnabled()` calls `removeViewsInLayout()`, it does not trigger the corresponding side effects of [removeView()](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5501): ``` public void removeView(View view) { if (removeViewInternal(view)) { --> requestLayout(); --> invalidate(true); } } ``` To compensate, flip `removeViewsInLayout()` to [`removeViews()`](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5562), which will ensure layout. Changelog: [Android][Fixed] Restore layout/invalidate during ReactViewClippingManager.removeViewAt() Reviewed By: javache Differential Revision: D67398971
Summary: Pull Request resolved: #48329 With the call to `removeView()` removed from `ReactViewClippingManager` in #47634, we're seeing views erroneously sticking around in the layout. While `removeViewWithSubviewClippingEnabled()` calls `removeViewsInLayout()`, it does not trigger the corresponding side effects of [removeView()](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5501): ``` public void removeView(View view) { if (removeViewInternal(view)) { --> requestLayout(); --> invalidate(true); } } ``` To compensate, flip `removeViewsInLayout()` to [`removeViews()`](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5562), which will ensure layout. Changelog: [Android][Fixed] Restore layout/invalidate during ReactViewClippingManager.removeViewAt() Reviewed By: javache Differential Revision: D67398971 fbshipit-source-id: b100db468cc3be6ddc6edd6c6d078a8a0b59a2c1
Summary: Pull Request resolved: #48329 With the call to `removeView()` removed from `ReactViewClippingManager` in #47634, we're seeing views erroneously sticking around in the layout. While `removeViewWithSubviewClippingEnabled()` calls `removeViewsInLayout()`, it does not trigger the corresponding side effects of [removeView()](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5501): ``` public void removeView(View view) { if (removeViewInternal(view)) { --> requestLayout(); --> invalidate(true); } } ``` To compensate, flip `removeViewsInLayout()` to [`removeViews()`](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5562), which will ensure layout. Changelog: [Android][Fixed] Restore layout/invalidate during ReactViewClippingManager.removeViewAt() Reviewed By: javache Differential Revision: D67398971 fbshipit-source-id: b100db468cc3be6ddc6edd6c6d078a8a0b59a2c1
Summary: Pull Request resolved: #48329 With the call to `removeView()` removed from `ReactViewClippingManager` in #47634, we're seeing views erroneously sticking around in the layout. While `removeViewWithSubviewClippingEnabled()` calls `removeViewsInLayout()`, it does not trigger the corresponding side effects of [removeView()](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5501): ``` public void removeView(View view) { if (removeViewInternal(view)) { --> requestLayout(); --> invalidate(true); } } ``` To compensate, flip `removeViewsInLayout()` to [`removeViews()`](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r9/core/java/android/view/ViewGroup.java#5562), which will ensure layout. Changelog: [Android][Fixed] Restore layout/invalidate during ReactViewClippingManager.removeViewAt() Reviewed By: javache Differential Revision: D67398971 fbshipit-source-id: b100db468cc3be6ddc6edd6c6d078a8a0b59a2c1
Summary:
Related PR in
react-native-screens
:Additional context:
Background
On Android view groups can be marked as "transitioning" with a
ViewGroup.startViewTransition
call. This effectively ensures, that in case a view group is marked with this call and its children are removed, they will be still drawn untilendViewTransition
is not called.This mechanism is implemented in Android by keeping track of "transitioning" children in auxiliary
mTransitioningViews
array. Then when such "transitioning" child is removed, it is removed from children array but it's parent-child relationship is not cleared and it is still retained in the auxiliary array.Having that established we can proceed with problem description.
Problem
Screen.Recording.2024-11-15.at.16.14.57.mov
Full code
Explanation (copied from here):
I've debugged this for a while now & I have good understanding of what's going on. This bug is caused by our usage of
startViewTransition
and its implications. We use it well, however React does not account for case that some view might be in transition. Error mechanism is as follows:startViewTransition
.IV.removeView(ChV)
is made, which effectively removes ChV fromIV.children
, HOWEVER it does not clearChV.parent
, meaning that after the call,ChV.parent == IV
. This happens, due to view being marked as in-transition by our call tostartViewTransition
. If the view is not marked as in-transition this parent-child relationship is removed.removeClippedSubviews
enabled, therefore a call toIV.removeViewWithSubviewsClippingEnabled(ChV)
is made. This function does effectively two things:IV.children
(Android.ViewGroup's state) and remove it from the array,mAllChildren
array (this is state maintained by ReactViewGroup for purposes of implementing the "subview clipping" mechanism".The crash happens in 7.1, because ChV has been removed from
IV.children
in step 6, but the parent-child relationship has not been broken up there. Under usual circumstances (this is my hypothesis now, yet unconfirmed) 7.1 does not execute, becauseChV.parent
is nulled in step no. 6.Rationale for
startViewTransition
usageTransitions. On Fabric, when some subtree is unmounted, views in the subtree are unmounted in bottom-up order. This leads to uncomfortable situation, where our components (react-native-screens), who want to drive & manage transitions are notified that their children will be removed after the subtrees mounted in screen subviews are already disassembled. If we start animation in this very moment we will have staggering effect of white flash (issue) (we animate just the screen with white background without it's children). This was not a problem on Paper, because the order of subtree disassembling was opposite - top-down. While we've managed to workaround this issue on Fabric using
MountingTransactionObserving
protocol on iOS and a commit hook on Android (we can inspect mutations in incoming transaction before it starts being applied) we still need to prevent view hierarchy from being disassembled in the middle of transition (on Paper this has also been less of an issue) - and this is wherestartViewTransition
comes in. It allows us to draw views throughout transition after React Native removes them from HostTree model. On iOS we exchange subtree for its snapshot for transition time, however this approach isn't feasible on Android, because snapshots do not capture shadows.Possible solutions
Android does not expose a method to verify whether a view is in transition (it has
package
visibility), therefore we need to retrieve this information with some workaround. I see two posibilities:startViewTransition
&endViewTransition
in ReactViewGroup and keep the state on whether the view is transitioning there,Having information on whether the view is in transition or not, we can prevent multiple removals of the same view in every call site (currently only in
removeViewAt
ifparent.removeClippingSubviews == true
).Another option would be to do just as this PR does: having in mind this "transitioning" state we can pass a flag to
removeViewWithSubviewClippingEnabled
and prevent duplicated removal from parent if we already know that this has been requested.I can also add override of this method:
to make this parameter optional.
Changelog:
[ANDROID] [FIXED] - Handle removal of in-transition views.
Test Plan:
WIP WIP