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

feat: add support for custom screen transitions with native-stack v7 #12225

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from

Conversation

maciekstosio
Copy link
Contributor

@maciekstosio maciekstosio commented Nov 4, 2024

Continuation of #12204 and #11943. Changes from original version:

In general, the idea suggested by @satya164 was that we could move more logic into react-native-screens and avoid leaking it to @react-navigation.

Original description:

Motivation

This PR intents to add implementation of custom screen transitions, recently added in react-native-screens. You can find more about this feature here.

Test plan

Go to the code of the native stack in example (NativeStack.tsx) and wrap whole navigator with (import from react-native-screens/gesture-handler). After that, use gestureType prop on the desired screen.

Ready to paste code 🍝
/* eslint-disable camelcase */
import * as React from 'react';
import {
  Animated,
  Platform,
  StyleSheet,
  ViewProps,
  ViewStyle,
} from 'react-native';
// @ts-ignore Getting private component
// eslint-disable-next-line import/no-named-as-default, import/default, import/no-named-as-default-member, import/namespace
import AppContainer from 'react-native/Libraries/ReactNative/AppContainer';
import warnOnce from 'warn-once';
import { StackPresentationTypes, ScreensRefsHolder } from '../../types';
import ScreenStack from '../../components/ScreenStack';
import ScreenContentWrapper from '../../components/ScreenContentWrapper';
import { ScreenContext } from '../../components/Screen';
import {
  ParamListBase,
  StackActions,
  StackNavigationState,
  useTheme,
  Route,
  NavigationState,
  PartialState,
} from '@react-navigation/native';
import {
  useSafeAreaFrame,
  useSafeAreaInsets,
} from 'react-native-safe-area-context';
import {
  NativeStackDescriptorMap,
  NativeStackNavigationHelpers,
  NativeStackNavigationOptions,
} from '../types';
import HeaderConfig from './HeaderConfig';
import SafeAreaProviderCompat from '../utils/SafeAreaProviderCompat';
import getDefaultHeaderHeight from '../utils/getDefaultHeaderHeight';
import getStatusBarHeight from '../utils/getStatusBarHeight';
import HeaderHeightContext from '../utils/HeaderHeightContext';
import AnimatedHeaderHeightContext from '../utils/AnimatedHeaderHeightContext';
import FooterComponent from './FooterComponent';

const isAndroid = Platform.OS === 'android';

let Container = ScreenContentWrapper;

if (__DEV__) {
  const DebugContainer = (
    props: ViewProps & { stackPresentation: StackPresentationTypes },
  ) => {
    const { stackPresentation, ...rest } = props;
    if (
      Platform.OS === 'ios' &&
      stackPresentation !== 'push' &&
      stackPresentation !== 'formSheet'
    ) {
      return (
        <AppContainer>
          <ScreenContentWrapper {...rest} />
        </AppContainer>
      );
    }
    return <ScreenContentWrapper {...rest} />;
  };
  // @ts-ignore Wrong props
  Container = DebugContainer;
}

const MaybeNestedStack = ({
  options,
  route,
  stackPresentation,
  children,
  internalScreenStyle,
}: {
  options: NativeStackNavigationOptions;
  route: Route<string>;
  stackPresentation: StackPresentationTypes;
  children: React.ReactNode;
  internalScreenStyle?: Pick<ViewStyle, 'backgroundColor'>;
}) => {
  const { colors } = useTheme();
  const { headerShown = true, contentStyle } = options;

  const Screen = React.useContext(ScreenContext);

  const isHeaderInModal = isAndroid
    ? false
    : stackPresentation !== 'push' && headerShown === true;

  const headerShownPreviousRef = React.useRef(headerShown);

  React.useEffect(() => {
    warnOnce(
      !isAndroid &&
        stackPresentation !== 'push' &&
        headerShownPreviousRef.current !== headerShown,
      `Dynamically changing 'headerShown' in modals will result in remounting the screen and losing all local state. See options for the screen '${route.name}'.`,
    );

    headerShownPreviousRef.current = headerShown;
  }, [headerShown, stackPresentation, route.name]);

  const content = (
    <Container
      style={[
        stackPresentation === 'formSheet'
          ? Platform.OS === 'ios'
            ? styles.absoluteFillNoBottom
            : null
          : styles.container,
        stackPresentation !== 'transparentModal' &&
          stackPresentation !== 'containedTransparentModal' && {
            backgroundColor: colors.background,
          },
        contentStyle,
      ]}
      // @ts-ignore Wrong props passed to View
      stackPresentation={stackPresentation}
      // This view must *not* be flattened.
      // See https://github.com/software-mansion/react-native-screens/pull/1825
      // for detailed explanation.
      collapsable={false}>
      {children}
    </Container>
  );

  const dimensions = useSafeAreaFrame();
  const topInset = useSafeAreaInsets().top;
  const isStatusBarTranslucent = options.statusBarTranslucent ?? false;
  const statusBarHeight = getStatusBarHeight(
    topInset,
    dimensions,
    isStatusBarTranslucent,
  );

  const hasLargeHeader = options.headerLargeTitle ?? false;

  const headerHeight = getDefaultHeaderHeight(
    dimensions,
    statusBarHeight,
    stackPresentation,
    hasLargeHeader,
  );

  if (isHeaderInModal) {
    return (
      <ScreenStack style={styles.container}>
        <Screen
          enabled
          isNativeStack
          hasLargeHeader={hasLargeHeader}
          style={[StyleSheet.absoluteFill, internalScreenStyle]}>
          <HeaderHeightContext.Provider value={headerHeight}>
            <HeaderConfig {...options} route={route} />
            {content}
          </HeaderHeightContext.Provider>
        </Screen>
      </ScreenStack>
    );
  }
  return content;
};

type NavigationRoute<
  ParamList extends ParamListBase,
  RouteName extends keyof ParamList,
> = Route<Extract<RouteName, string>, ParamList[RouteName]> & {
  state?: NavigationState | PartialState<NavigationState>;
};

const RouteView = ({
  descriptors,
  route,
  index,
  navigation,
  stateKey,
  screensRefs,
}: {
  descriptors: NativeStackDescriptorMap;
  route: NavigationRoute<ParamListBase, string>;
  index: number;
  navigation: NativeStackNavigationHelpers;
  stateKey: string;
  screensRefs: React.MutableRefObject<ScreensRefsHolder>;
}) => {
  const { options, render: renderScene } = descriptors[route.key];

  const {
    fullScreenSwipeShadowEnabled = false,
    gestureEnabled,
    headerShown,
    hideKeyboardOnSwipe,
    homeIndicatorHidden,
    sheetLargestUndimmedDetentIndex = 'none',
    sheetGrabberVisible = false,
    sheetCornerRadius = -1.0,
    sheetElevation = 24,
    sheetExpandsWhenScrolledToEdge = true,
    sheetInitialDetentIndex = 0,
    nativeBackButtonDismissalEnabled = false,
    navigationBarColor,
    navigationBarTranslucent,
    navigationBarHidden,
    replaceAnimation = 'pop',
    screenOrientation,
    statusBarAnimation,
    statusBarColor,
    statusBarHidden,
    statusBarStyle,
    statusBarTranslucent,
    swipeDirection = 'horizontal',
    transitionDuration,
    freezeOnBlur,
    unstable_sheetFooter = null,
    contentStyle,
  } = options;

  let {
    sheetAllowedDetents = [1.0],
    customAnimationOnSwipe,
    fullScreenSwipeEnabled,
    gestureResponseDistance,
    stackAnimation,
    stackPresentation = 'push',
  } = options;

  // We take backgroundColor from contentStyle and apply it on Screen.
  // This allows to workaround one issue with truncated
  // content with formSheet presentation.
  let internalScreenStyle;

  if (stackPresentation === 'formSheet' && contentStyle) {
    const flattenContentStyles = StyleSheet.flatten(contentStyle);
    internalScreenStyle = {
      backgroundColor: flattenContentStyles?.backgroundColor,
    };
  }

  if (sheetAllowedDetents === 'fitToContents') {
    sheetAllowedDetents = [-1];
  }

  if (swipeDirection === 'vertical') {
    // for `vertical` direction to work, we need to set `fullScreenSwipeEnabled` to `true`
    // so the screen can be dismissed from any point on screen.
    // `customAnimationOnSwipe` needs to be set to `true` so the `stackAnimation` set by user can be used,
    // otherwise `simple_push` will be used.
    // Also, the default animation for this direction seems to be `slide_from_bottom`.
    if (fullScreenSwipeEnabled === undefined) {
      fullScreenSwipeEnabled = true;
    }
    if (customAnimationOnSwipe === undefined) {
      customAnimationOnSwipe = true;
    }
    if (stackAnimation === undefined) {
      stackAnimation = 'slide_from_bottom';
    }
  }

  if (index === 0) {
    // first screen should always be treated as `push`, it resolves problems with no header animation
    // for navigator with first screen as `modal` and the next as `push`
    stackPresentation = 'push';
  }

  const dimensions = useSafeAreaFrame();
  const topInset = useSafeAreaInsets().top;
  const isStatusBarTranslucent = options.statusBarTranslucent ?? false;
  const statusBarHeight = getStatusBarHeight(
    topInset,
    dimensions,
    isStatusBarTranslucent,
  );

  const hasLargeHeader = options.headerLargeTitle ?? false;

  const defaultHeaderHeight = getDefaultHeaderHeight(
    dimensions,
    statusBarHeight,
    stackPresentation,
    hasLargeHeader,
  );

  const parentHeaderHeight = React.useContext(HeaderHeightContext);
  const isHeaderInPush = isAndroid
    ? headerShown
    : stackPresentation === 'push' && headerShown !== false;

  const staticHeaderHeight =
    isHeaderInPush !== false ? defaultHeaderHeight : parentHeaderHeight ?? 0;

  // We need to ensure the first retrieved header height will be cached and set in animatedHeaderHeight.
  // We're caching the header height here, as on iOS native side events are not always coming to the JS on first notify.
  // TODO: Check why first event is not being received once it is cached on the native side.
  const cachedAnimatedHeaderHeight = React.useRef(defaultHeaderHeight);
  const animatedHeaderHeight = React.useRef(
    new Animated.Value(staticHeaderHeight, {
      useNativeDriver: true,
    }),
  ).current;

  const Screen = React.useContext(ScreenContext);
  const { dark } = useTheme();

  const screenRef = React.useRef(null);
  React.useEffect(() => {
    screensRefs.current[route.key] = screenRef;
    return () => {
      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
      delete screensRefs.current[route.key];
    };
  });

  return (
    <Screen
      key={route.key}
      ref={screenRef}
      enabled
      isNativeStack
      hasLargeHeader={hasLargeHeader}
      style={[StyleSheet.absoluteFill, internalScreenStyle]}
      sheetAllowedDetents={sheetAllowedDetents}
      sheetLargestUndimmedDetentIndex={sheetLargestUndimmedDetentIndex}
      sheetGrabberVisible={sheetGrabberVisible}
      sheetInitialDetentIndex={sheetInitialDetentIndex}
      sheetCornerRadius={sheetCornerRadius}
      sheetElevation={sheetElevation}
      sheetExpandsWhenScrolledToEdge={sheetExpandsWhenScrolledToEdge}
      customAnimationOnSwipe={customAnimationOnSwipe}
      freezeOnBlur={freezeOnBlur}
      fullScreenSwipeEnabled={fullScreenSwipeEnabled}
      fullScreenSwipeShadowEnabled={fullScreenSwipeShadowEnabled}
      hideKeyboardOnSwipe={hideKeyboardOnSwipe}
      homeIndicatorHidden={homeIndicatorHidden}
      gestureEnabled={isAndroid ? false : gestureEnabled}
      gestureResponseDistance={gestureResponseDistance}
      nativeBackButtonDismissalEnabled={nativeBackButtonDismissalEnabled}
      navigationBarColor={navigationBarColor}
      navigationBarTranslucent={navigationBarTranslucent}
      navigationBarHidden={navigationBarHidden}
      replaceAnimation={replaceAnimation}
      screenOrientation={screenOrientation}
      stackAnimation={stackAnimation}
      stackPresentation={stackPresentation}
      statusBarAnimation={statusBarAnimation}
      statusBarColor={statusBarColor}
      statusBarHidden={statusBarHidden}
      statusBarStyle={statusBarStyle ?? (dark ? 'light' : 'dark')}
      statusBarTranslucent={statusBarTranslucent}
      swipeDirection={swipeDirection}
      transitionDuration={transitionDuration}
      onHeaderBackButtonClicked={() => {
        navigation.dispatch({
          ...StackActions.pop(),
          source: route.key,
          target: stateKey,
        });
      }}
      onWillAppear={() => {
        navigation.emit({
          type: 'transitionStart',
          data: { closing: false },
          target: route.key,
        });
      }}
      onWillDisappear={() => {
        navigation.emit({
          type: 'transitionStart',
          data: { closing: true },
          target: route.key,
        });
      }}
      onAppear={() => {
        navigation.emit({
          type: 'appear',
          target: route.key,
        });
        navigation.emit({
          type: 'transitionEnd',
          data: { closing: false },
          target: route.key,
        });
      }}
      onDisappear={() => {
        navigation.emit({
          type: 'transitionEnd',
          data: { closing: true },
          target: route.key,
        });
      }}
      onHeaderHeightChange={e => {
        const headerHeight = e.nativeEvent.headerHeight;

        if (cachedAnimatedHeaderHeight.current !== headerHeight) {
          // Currently, we're setting value by Animated#setValue, because we want to cache animated value.
          // Also, in React Native 0.72 there was a bug on Fabric causing a large delay between the screen transition,
          // which should not occur.
          // TODO: Check if it's possible to replace animated#setValue to Animated#event.
          animatedHeaderHeight.setValue(headerHeight);
          cachedAnimatedHeaderHeight.current = headerHeight;
        }
      }}
      onDismissed={e => {
        navigation.emit({
          type: 'dismiss',
          target: route.key,
        });

        const dismissCount =
          e.nativeEvent.dismissCount > 0 ? e.nativeEvent.dismissCount : 1;

        navigation.dispatch({
          ...StackActions.pop(dismissCount),
          source: route.key,
          target: stateKey,
        });
      }}
      onSheetDetentChanged={e => {
        navigation.emit({
          type: 'sheetDetentChange',
          target: route.key,
          data: {
            index: e.nativeEvent.index,
            isStable: e.nativeEvent.isStable,
          },
        });
      }}
      onGestureCancel={() => {
        navigation.emit({
          type: 'gestureCancel',
          target: route.key,
        });
      }}>
      <AnimatedHeaderHeightContext.Provider value={animatedHeaderHeight}>
        <HeaderHeightContext.Provider value={staticHeaderHeight}>
          <MaybeNestedStack
            options={options}
            route={route}
            stackPresentation={stackPresentation}
            internalScreenStyle={internalScreenStyle}>
            {renderScene()}
          </MaybeNestedStack>
          {/* HeaderConfig must not be first child of a Screen.
           See https://github.com/software-mansion/react-native-screens/pull/1825
           for detailed explanation */}
          <HeaderConfig
            {...options}
            route={route}
            headerShown={isHeaderInPush}
          />
          {stackPresentation === 'formSheet' && unstable_sheetFooter && (
            <FooterComponent>{unstable_sheetFooter()}</FooterComponent>
          )}
        </HeaderHeightContext.Provider>
      </AnimatedHeaderHeightContext.Provider>
    </Screen>
  );
};

type Props = {
  state: StackNavigationState<ParamListBase>;
  navigation: NativeStackNavigationHelpers;
  descriptors: NativeStackDescriptorMap;
};

function NativeStackViewInner({
  state,
  navigation,
  descriptors,
}: Props): JSX.Element {
  const { key, routes } = state;

  const currentRouteKey = routes[state.index].key;
  const { goBackGesture, transitionAnimation, screenEdgeGesture } =
    descriptors[currentRouteKey].options;

  const screensRefs = React.useRef<ScreensRefsHolder>({});

  return (
    <ScreenStack
      style={styles.container}
      goBackGesture={goBackGesture}
      transitionAnimation={transitionAnimation}
      screenEdgeGesture={screenEdgeGesture ?? false}
      screensRefs={screensRefs}
      currentScreenId={currentRouteKey}>
      {routes.map((route, index) => (
        <RouteView
          key={route.key}
          descriptors={descriptors}
          route={route}
          index={index}
          navigation={navigation}
          stateKey={key}
          screensRefs={screensRefs}
        />
      ))}
    </ScreenStack>
  );
}

export default function NativeStackView(props: Props) {
  return (
    <SafeAreaProviderCompat>
      <NativeStackViewInner {...props} />
    </SafeAreaProviderCompat>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  absoluteFill: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
  },
  absoluteFillNoBottom: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
  },
});

Presentation

Screen.Recording.2024-04-16.at.18.18.51.mov

@maciekstosio maciekstosio changed the title refactore: use context instead of passing screenRefs refactor!: use context instead of passing screenRefs Nov 4, 2024
Copy link

netlify bot commented Nov 4, 2024

Deploy Preview for react-navigation-example ready!

Name Link
🔨 Latest commit b2419c2
🔍 Latest deploy log https://app.netlify.com/sites/react-navigation-example/deploys/672a04cd8c5ac0000839065e
😎 Deploy Preview https://deploy-preview-12225--react-navigation-example.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@maciekstosio maciekstosio changed the base branch from main to @maciekstosio/refactor-custom-screen-transitions November 4, 2024 10:39
maciekstosio added a commit to software-mansion/react-native-screens that referenced this pull request Nov 5, 2024
## Description

This PR replaces passing screenRefs into using context. This avoids
unnecessary logic on react-navigation side.

## Changes

Create Context that holds screen refs and add this ref in
ScreenStackItem instead of doing same on react-navigation part. See the
changes there:
react-navigation/react-navigation#12225

## Testing

See Example App > Swipe Back Animation (changes from react-navigation
required:
react-navigation/react-navigation#12225)
OR
react-navigation: TestScreenAnimation.tsx
native-stack v5: TestScreenAnimationV5.tsx

|native-stack v5|react-navigation|
|-|-|
|<video
src="https://github.com/user-attachments/assets/0af1422c-4e61-4c7c-ba6d-1c6a852d4e13"
/>|<video
src="https://github.com/user-attachments/assets/03af7230-198f-4081-ba6d-2b78325ebaa7"
/>|

## Checklist

- [x] Included code example that can be used to test this change
- [x] 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
@maciekstosio maciekstosio changed the base branch from @maciekstosio/refactor-custom-screen-transitions to main November 5, 2024 10:53
@maciekstosio maciekstosio changed the title refactor!: use context instead of passing screenRefs feat: add support for custom screen transitions with native-stack v7 Nov 5, 2024
@maciekstosio
Copy link
Contributor Author

maciekstosio commented Nov 5, 2024

The solution feature works, but the PR is still in draft because I'm getting an error about findHostInstance_DEPRECATED being deprecated in strict mode. And Reanimated not finding RNScreensTurboModule. I'm checking what we can do about it.

CST.x.ReactNavigation.mov

@satya164
Copy link
Member

satya164 commented Nov 5, 2024

I'm getting an error about findHostInstance_DEPRECATED being deprecated in strict mode

It's a warning, React Native core does not fully support strict mode. Likely unrelated to your changes.

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