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 handling removal of transitioning views #47634

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

kkafar
Copy link
Contributor

@kkafar kkafar commented Nov 15, 2024

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 until endViewTransition 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
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,
  },
});

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:

  1. Let's have initially simple stack with two screens: "A, B". This is component rendered under "B":
    <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>
  1. We press the back button.
  2. 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.
  3. React Mounting stage starts, view hierarchy is disassembled in bottom-up fashion (leafs first).
  4. ReactViewGroupManager receives MountItem to detach ChV from IV.
  5. A call to IV.removeView(ChV) is made, 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.
  6. IV has removeClippedSubviews enabled, therefore a call to IV.removeViewWithSubviewsClippingEnabled(ChV) is made. This function 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.

Rationale for startViewTransition usage

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) (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.

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:

  • 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:

  /*package*/ void removeViewWithSubviewClippingEnabled(View view) {
    this.removeViewWithSubviewClippingEnabled(view, false);
  }

to make this parameter optional.

Changelog:

[ANDROID] [FIXED] - Handle removal of in-transition views.

Test Plan:

WIP WIP

@facebook-github-bot facebook-github-bot added CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner labels Nov 15, 2024
@kkafar kkafar marked this pull request as ready for review November 15, 2024 15:55
@kkafar kkafar changed the title [WIP] Fix handling removal of transitioning views Fix handling removal of transitioning views Nov 15, 2024
@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Nov 15, 2024
@tdn120
Copy link

tdn120 commented Nov 15, 2024

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 removeViewWithSubviewClippingEnabled() in the library. So we'd effectively never be running the subview clipping logic on view removal.

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?

Copy link
Contributor Author

@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.

@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.

@@ -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;
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'm using Set<Integer> (holding only the view tag) instead of Set<View> to avoid any retain issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants