chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,14 @@
'use client';
import * as React from 'react';
import { Animated } from 'react-native';
type TransitionProgressContextBody = {
progress: Animated.Value;
closing: Animated.Value;
goingForward: Animated.Value;
};
export default React.createContext<TransitionProgressContextBody | undefined>(
undefined,
);
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Platform, type ViewProps } from 'react-native';
// @ts-expect-error importing private component
import AppContainer from 'react-native/Libraries/ReactNative/AppContainer';
import ScreenContentWrapper from './ScreenContentWrapper';
import { StackPresentationTypes } from '../types';
type ContainerProps = ViewProps & {
stackPresentation: StackPresentationTypes;
children: React.ReactNode;
};
/**
* This view must *not* be flattened.
* See https://github.com/software-mansion/react-native-screens/pull/1825
* for detailed explanation.
*/
let DebugContainer: React.ComponentType<ContainerProps> = props => {
return <ScreenContentWrapper {...props} />;
};
if (process.env.NODE_ENV !== 'production') {
DebugContainer = (props: ContainerProps) => {
const { stackPresentation, ...rest } = props;
if (
Platform.OS === 'ios' &&
stackPresentation !== 'push' &&
stackPresentation !== 'formSheet'
) {
// This is necessary for LogBox
return (
<AppContainer>
<ScreenContentWrapper {...rest} />
</AppContainer>
);
}
return <ScreenContentWrapper {...rest} />;
};
DebugContainer.displayName = 'DebugContainer';
}
export default DebugContainer;
@@ -0,0 +1,7 @@
import * as React from 'react';
import { type ViewProps } from 'react-native';
import ScreenContentWrapper from './ScreenContentWrapper';
export default function DebugContainer(props: ViewProps) {
return <ScreenContentWrapper {...props} />;
}
@@ -0,0 +1,44 @@
import React, { PropsWithChildren, ReactNode } from 'react';
import {
Platform,
StyleProp,
StyleSheet,
View,
ViewStyle,
useWindowDimensions,
} from 'react-native';
// Native components
import FullWindowOverlayNativeComponent from '../fabric/FullWindowOverlayNativeComponent';
import type { NativeProps } from '../fabric/FullWindowOverlayNativeComponent';
const NativeFullWindowOverlay: React.ComponentType<
PropsWithChildren<{
style: StyleProp<ViewStyle>;
}> &
NativeProps
> = FullWindowOverlayNativeComponent as any;
type FullWindowOverlayProps = {
children: ReactNode;
unstable_accessibilityContainerViewIsModal?: boolean;
};
function FullWindowOverlay(props: FullWindowOverlayProps) {
const { width, height } = useWindowDimensions();
if (Platform.OS !== 'ios') {
console.warn('Using FullWindowOverlay is only valid on iOS devices.');
return <View {...props} />;
}
return (
<NativeFullWindowOverlay
style={[StyleSheet.absoluteFill, { width, height }]}
accessibilityContainerViewIsModal={
props.unstable_accessibilityContainerViewIsModal
}>
{props.children}
</NativeFullWindowOverlay>
);
}
export default FullWindowOverlay;
@@ -0,0 +1,6 @@
import { View } from 'react-native';
import React, { ReactNode } from 'react';
export default View as React.ComponentType<{
children: ReactNode;
}>;
@@ -0,0 +1,304 @@
'use client';
import React from 'react';
import { Animated, View, Platform } from 'react-native';
import TransitionProgressContext from '../TransitionProgressContext';
import DelayedFreeze from './helpers/DelayedFreeze';
import { ScreenProps } from '../types';
import {
freezeEnabled,
isNativePlatformSupported,
screensEnabled,
} from '../core';
// Native components
import ScreenNativeComponent, {
NativeProps as ScreenNativeComponentProps,
} from '../fabric/ScreenNativeComponent';
import ModalScreenNativeComponent, {
NativeProps as ModalScreenNativeComponentProps,
} from '../fabric/ModalScreenNativeComponent';
import { usePrevious } from './helpers/usePrevious';
import { EDGE_TO_EDGE, transformEdgeToEdgeProps } from './helpers/edge-to-edge';
import {
SHEET_DIMMED_ALWAYS,
resolveSheetAllowedDetents,
resolveSheetInitialDetentIndex,
resolveSheetLargestUndimmedDetent,
} from './helpers/sheet';
type NativeProps = ScreenNativeComponentProps | ModalScreenNativeComponentProps;
const AnimatedNativeScreen = Animated.createAnimatedComponent(
ScreenNativeComponent,
);
const AnimatedNativeModalScreen = Animated.createAnimatedComponent(
ModalScreenNativeComponent,
);
// Incomplete type, all accessible properties available at:
// react-native/Libraries/Components/View/ReactNativeViewViewConfig.js
interface ViewConfig extends View {
viewConfig: {
validAttributes: {
style: {
display: boolean | null;
};
};
};
_viewConfig: {
validAttributes: {
style: {
display: boolean | null;
};
};
};
}
export const InnerScreen = React.forwardRef<View, ScreenProps>(
function InnerScreen(props, ref) {
const innerRef = React.useRef<ViewConfig | null>(null);
React.useImperativeHandle(ref, () => innerRef.current!, []);
const prevActivityState = usePrevious(props.activityState);
const setRef = (ref: ViewConfig) => {
innerRef.current = ref;
props.onComponentRef?.(ref);
};
const closing = React.useRef(new Animated.Value(0)).current;
const progress = React.useRef(new Animated.Value(0)).current;
const goingForward = React.useRef(new Animated.Value(0)).current;
const {
enabled = screensEnabled(),
freezeOnBlur = freezeEnabled(),
shouldFreeze,
...rest
} = props;
// To maintain default behavior of formSheet stack presentation style and to have reasonable
// defaults for new medium-detent iOS API we need to set defaults here
const {
// formSheet presentation related props
sheetAllowedDetents = [1.0],
sheetLargestUndimmedDetentIndex = SHEET_DIMMED_ALWAYS,
sheetGrabberVisible = false,
sheetCornerRadius = -1.0,
sheetExpandsWhenScrolledToEdge = true,
sheetElevation = 24,
sheetInitialDetentIndex = 0,
// Other
screenId,
stackPresentation,
// Events for override
onAppear,
onDisappear,
onWillAppear,
onWillDisappear,
} = rest;
if (enabled && isNativePlatformSupported) {
const resolvedSheetAllowedDetents =
resolveSheetAllowedDetents(sheetAllowedDetents);
const resolvedSheetLargestUndimmedDetent =
resolveSheetLargestUndimmedDetent(
sheetLargestUndimmedDetentIndex,
resolvedSheetAllowedDetents.length - 1,
);
const resolvedSheetInitialDetentIndex = resolveSheetInitialDetentIndex(
sheetInitialDetentIndex,
resolvedSheetAllowedDetents.length - 1,
);
// Due to how Yoga resolves layout, we need to have different components for modal nad non-modal screens (there is a need for different
// shadow nodes).
const shouldUseModalScreenComponent = Platform.select({
ios: !(
stackPresentation === undefined ||
stackPresentation === 'push' ||
stackPresentation === 'containedModal' ||
stackPresentation === 'containedTransparentModal'
),
android: false,
default: false,
});
const AnimatedScreen = shouldUseModalScreenComponent
? AnimatedNativeModalScreen
: AnimatedNativeScreen;
let {
// Filter out active prop in this case because it is unused and
// can cause problems depending on react-native version:
// https://github.com/react-navigation/react-navigation/issues/4886
active,
activityState,
children,
isNativeStack,
gestureResponseDistance,
onGestureCancel,
style,
...props
} = rest;
if (active !== undefined && activityState === undefined) {
console.warn(
'It appears that you are using old version of react-navigation library. Please update @react-navigation/bottom-tabs, @react-navigation/stack and @react-navigation/drawer to version 5.10.0 or above to take full advantage of new functionality added to react-native-screens',
);
activityState = active !== 0 ? 2 : 0; // in the new version, we need one of the screens to have value of 2 after the transition
}
if (
isNativeStack &&
prevActivityState !== undefined &&
activityState !== undefined
) {
if (prevActivityState > activityState) {
throw new Error(
'[RNScreens] activityState cannot be decreased in NativeStack',
);
}
}
const handleRef = (ref: ViewConfig) => {
// Workaround is necessary to prevent React Native from hiding frozen screens.
// See this PR: https://github.com/grahammendick/navigation/pull/860
if (ref?.viewConfig?.validAttributes?.style) {
ref.viewConfig.validAttributes.style = {
...ref.viewConfig.validAttributes.style,
display: null,
};
setRef(ref);
} else if (ref?._viewConfig?.validAttributes?.style) {
ref._viewConfig.validAttributes.style = {
...ref._viewConfig.validAttributes.style,
display: null,
};
setRef(ref);
}
};
const freeze =
freezeOnBlur &&
(shouldFreeze !== undefined ? shouldFreeze : activityState === 0);
return (
<DelayedFreeze freeze={freeze}>
<AnimatedScreen
{...props}
/**
* This messy override is to conform NativeProps used by codegen and
* our Public API. To see reasoning go to this PR:
* https://github.com/software-mansion/react-native-screens/pull/2423#discussion_r1810616995
*/
onAppear={onAppear as NativeProps['onAppear']}
onDisappear={onDisappear as NativeProps['onDisappear']}
onWillAppear={onWillAppear as NativeProps['onWillAppear']}
onWillDisappear={onWillDisappear as NativeProps['onWillDisappear']}
onGestureCancel={
(onGestureCancel as NativeProps['onGestureCancel']) ??
(() => {
// for internal use
})
}
//
// Hierarchy of screens is handled on the native side and setting zIndex value causes this issue:
// https://github.com/software-mansion/react-native-screens/issues/2345
// With below change of zIndex, we force RN diffing mechanism to NOT include detaching and attaching mutation in one transaction.
// Detailed information can be found here https://github.com/software-mansion/react-native-screens/pull/2351
style={[style, { zIndex: undefined }]}
activityState={activityState}
screenId={screenId}
sheetAllowedDetents={resolvedSheetAllowedDetents}
sheetLargestUndimmedDetent={resolvedSheetLargestUndimmedDetent}
sheetElevation={sheetElevation}
sheetGrabberVisible={sheetGrabberVisible}
sheetCornerRadius={sheetCornerRadius}
sheetExpandsWhenScrolledToEdge={sheetExpandsWhenScrolledToEdge}
sheetInitialDetent={resolvedSheetInitialDetentIndex}
gestureResponseDistance={{
start: gestureResponseDistance?.start ?? -1,
end: gestureResponseDistance?.end ?? -1,
top: gestureResponseDistance?.top ?? -1,
bottom: gestureResponseDistance?.bottom ?? -1,
}}
// This prevents showing blank screen when navigating between multiple screens with freezing
// https://github.com/software-mansion/react-native-screens/pull/1208
ref={handleRef}
onTransitionProgress={
!isNativeStack
? undefined
: Animated.event(
[
{
nativeEvent: {
progress,
closing,
goingForward,
},
},
],
{ useNativeDriver: true },
)
}>
{!isNativeStack ? ( // see comment of this prop in types.tsx for information why it is needed
children
) : (
<TransitionProgressContext.Provider
value={{
progress,
closing,
goingForward,
}}>
{children}
</TransitionProgressContext.Provider>
)}
</AnimatedScreen>
</DelayedFreeze>
);
} else {
// same reason as above
let {
active,
activityState,
style,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onComponentRef,
...props
} = rest;
if (active !== undefined && activityState === undefined) {
activityState = active !== 0 ? 2 : 0;
}
return (
<Animated.View
style={[style, { display: activityState !== 0 ? 'flex' : 'none' }]}
ref={setRef}
{...props}
/>
);
}
},
);
// context to be used when the user wants to use enhanced implementation
// e.g. to use `useReanimatedTransitionProgress` (see `reanimated` folder in repo)
export const ScreenContext = React.createContext(InnerScreen);
const Screen = React.forwardRef<View, ScreenProps>((props, ref) => {
const ScreenWrapper = React.useContext(ScreenContext) || InnerScreen;
return (
<ScreenWrapper
{...(EDGE_TO_EDGE ? transformEdgeToEdgeProps(props) : props)}
ref={ref}
/>
);
});
Screen.displayName = 'Screen';
export default Screen;
@@ -0,0 +1,46 @@
'use client';
import { ScreenProps } from '../types';
import { Animated, View } from 'react-native';
import React from 'react';
import { screensEnabled } from '../core';
export const InnerScreen = View;
// We're using class component here because of the error from reanimated:
// createAnimatedComponent` does not support stateless functional components; use a class component instead.
// NOTE: React Server Components do not support class components.
export class NativeScreen extends React.Component<ScreenProps> {
render(): JSX.Element {
let {
active,
activityState,
style,
enabled = screensEnabled(),
...rest
} = this.props;
if (enabled) {
if (active !== undefined && activityState === undefined) {
activityState = active !== 0 ? 2 : 0; // change taken from index.native.tsx
}
return (
<View
// @ts-expect-error: hidden exists on web, but not in React Native
hidden={activityState === 0}
style={[style, { display: activityState !== 0 ? 'flex' : 'none' }]}
{...rest}
/>
);
}
return <View {...rest} />;
}
}
const Screen = Animated.createAnimatedComponent(NativeScreen);
export const ScreenContext = React.createContext(Screen);
export default Screen;
@@ -0,0 +1,28 @@
'use client';
import { Platform, View } from 'react-native';
import React from 'react';
import { ScreenContainerProps } from '../types';
import { isNativePlatformSupported, screensEnabled } from '../core';
// Native components
import ScreenContainerNativeComponent from '../fabric/ScreenContainerNativeComponent';
import ScreenNavigationContainerNativeComponent from '../fabric/ScreenNavigationContainerNativeComponent';
function ScreenContainer(props: ScreenContainerProps) {
const { enabled = screensEnabled(), hasTwoStates, ...rest } = props;
if (enabled && isNativePlatformSupported) {
if (hasTwoStates) {
const ScreenNavigationContainer =
Platform.OS === 'ios'
? ScreenNavigationContainerNativeComponent
: ScreenContainerNativeComponent;
return <ScreenNavigationContainer {...rest} />;
}
return <ScreenContainerNativeComponent {...rest} />;
}
return <View {...rest} />;
}
export default ScreenContainer;
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const ScreenContainer = View;
export default ScreenContainer;
@@ -0,0 +1,9 @@
import React from 'react';
import { ViewProps } from 'react-native';
import ScreenContentWrapperNativeComponent from '../fabric/ScreenContentWrapperNativeComponent';
function ScreenContentWrapper(props: ViewProps) {
return <ScreenContentWrapperNativeComponent collapsable={false} {...props} />;
}
export default ScreenContentWrapper;
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const ScreenContentWrapper = View;
export default ScreenContentWrapper;
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const ScreenContentWrapper = View;
export default ScreenContentWrapper;
@@ -0,0 +1,20 @@
import React from 'react';
import { ViewProps } from 'react-native';
import ScreenFooterNativeComponent from '../fabric/ScreenFooterNativeComponent';
/**
* Unstable API
*/
function ScreenFooter(props: ViewProps) {
return <ScreenFooterNativeComponent {...props} />;
}
type FooterProps = {
children?: React.ReactNode;
};
export function FooterComponent({ children }: FooterProps) {
return <ScreenFooter collapsable={false}>{children}</ScreenFooter>;
}
export default ScreenFooter;
@@ -0,0 +1,7 @@
import { View } from 'react-native';
const ScreenFooter = View;
const FooterComponent = View;
export default ScreenFooter;
export { FooterComponent };
@@ -0,0 +1,7 @@
import { View } from 'react-native';
const ScreenFooter = View;
const FooterComponent = View;
export default ScreenFooter;
export { FooterComponent };
@@ -0,0 +1,112 @@
'use client';
import React, { PropsWithChildren } from 'react';
import {
GestureDetectorBridge,
ScreensRefsHolder,
GestureProviderProps,
GoBackGesture,
ScreenStackProps,
} from '../types';
import { GHContext, RNSScreensRefContext } from '../contexts';
import warnOnce from 'warn-once';
// Native components
import ScreenStackNativeComponent, {
NativeProps,
} from '../fabric/ScreenStackNativeComponent';
const assertGHProvider = (
ScreenGestureDetector: (
props: PropsWithChildren<GestureProviderProps>,
) => React.JSX.Element,
goBackGesture: GoBackGesture | undefined,
) => {
const isGestureDetectorProviderNotDetected =
ScreenGestureDetector.name !== 'GHWrapper' && goBackGesture !== undefined;
warnOnce(
isGestureDetectorProviderNotDetected,
'Cannot detect GestureDetectorProvider in a screen that uses `goBackGesture`. Make sure your navigator is wrapped in GestureDetectorProvider.',
);
};
const assertCustomScreenTransitionsProps = (
screensRefs: ScreenStackProps['screensRefs'],
currentScreenId: ScreenStackProps['currentScreenId'],
goBackGesture: ScreenStackProps['goBackGesture'],
) => {
const isGestureDetectorNotConfiguredProperly =
goBackGesture !== undefined &&
screensRefs === null &&
currentScreenId === undefined;
warnOnce(
isGestureDetectorNotConfiguredProperly,
'Custom Screen Transition require screensRefs and currentScreenId to be provided.',
);
};
function ScreenStack(props: ScreenStackProps) {
const {
goBackGesture,
screensRefs: passedScreenRefs, // TODO: For compatibility with v5, remove once v5 is removed
currentScreenId,
transitionAnimation,
screenEdgeGesture,
onFinishTransitioning,
children,
...rest
} = props;
const screensRefs = React.useRef<ScreensRefsHolder>(
passedScreenRefs?.current ?? {},
);
const ref = React.useRef(null);
const ScreenGestureDetector = React.useContext(GHContext);
const gestureDetectorBridge = React.useRef<GestureDetectorBridge>({
stackUseEffectCallback: _stackRef => {
// this method will be overriden in GestureDetector
},
});
React.useEffect(() => {
gestureDetectorBridge.current.stackUseEffectCallback(ref);
});
assertGHProvider(ScreenGestureDetector, goBackGesture);
assertCustomScreenTransitionsProps(
screensRefs,
currentScreenId,
goBackGesture,
);
return (
<RNSScreensRefContext.Provider value={screensRefs}>
<ScreenGestureDetector
gestureDetectorBridge={gestureDetectorBridge}
goBackGesture={goBackGesture}
transitionAnimation={transitionAnimation}
screenEdgeGesture={screenEdgeGesture ?? false}
screensRefs={screensRefs}
currentScreenId={currentScreenId}>
<ScreenStackNativeComponent
{...rest}
/**
* This messy override is to conform NativeProps used by codegen and
* our Public API. To see reasoning go to this PR:
* https://github.com/software-mansion/react-native-screens/pull/2423#discussion_r1810616995
*/
onFinishTransitioning={
onFinishTransitioning as NativeProps['onFinishTransitioning']
}
ref={ref}>
{children}
</ScreenStackNativeComponent>
</ScreenGestureDetector>
</RNSScreensRefContext.Provider>
);
}
export default ScreenStack;
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const ScreenStack = View;
export default ScreenStack;
@@ -0,0 +1,114 @@
'use client';
import React from 'react';
import { ScreenStackHeaderConfigProps } from '../types';
import {
Image,
ImageProps,
Platform,
StyleSheet,
View,
ViewProps,
} from 'react-native';
// Native components
import ScreenStackHeaderConfigNativeComponent from '../fabric/ScreenStackHeaderConfigNativeComponent';
import ScreenStackHeaderSubviewNativeComponent, {
type NativeProps as ScreenStackHeaderSubviewNativeProps,
} from '../fabric/ScreenStackHeaderSubviewNativeComponent';
import { EDGE_TO_EDGE } from './helpers/edge-to-edge';
export const ScreenStackHeaderSubview: React.ComponentType<ScreenStackHeaderSubviewNativeProps> =
ScreenStackHeaderSubviewNativeComponent;
export const ScreenStackHeaderConfig = React.forwardRef<
View,
ScreenStackHeaderConfigProps
>((props, ref) => (
<ScreenStackHeaderConfigNativeComponent
{...props}
ref={ref}
topInsetEnabled={EDGE_TO_EDGE ? true : props.topInsetEnabled}
style={styles.headerConfig}
pointerEvents="box-none"
/>
));
ScreenStackHeaderConfig.displayName = 'ScreenStackHeaderConfig';
export const ScreenStackHeaderBackButtonImage = (
props: ImageProps,
): JSX.Element => (
<ScreenStackHeaderSubview type="back" style={styles.headerSubview}>
<Image resizeMode="center" fadeDuration={0} {...props} />
</ScreenStackHeaderSubview>
);
export const ScreenStackHeaderRightView = (props: ViewProps): JSX.Element => {
const { style, ...rest } = props;
return (
<ScreenStackHeaderSubview
{...rest}
type="right"
style={[styles.headerSubview, style]}
/>
);
};
export const ScreenStackHeaderLeftView = (props: ViewProps): JSX.Element => {
const { style, ...rest } = props;
return (
<ScreenStackHeaderSubview
{...rest}
type="left"
style={[styles.headerSubview, style]}
/>
);
};
export const ScreenStackHeaderCenterView = (props: ViewProps): JSX.Element => {
const { style, ...rest } = props;
return (
<ScreenStackHeaderSubview
{...rest}
type="center"
style={[styles.headerSubviewCenter, style]}
/>
);
};
export const ScreenStackHeaderSearchBarView = (
props: ViewProps,
): JSX.Element => (
<ScreenStackHeaderSubview
{...props}
type="searchBar"
style={styles.headerSubview}
/>
);
const styles = StyleSheet.create({
headerSubview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
headerSubviewCenter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 1,
},
headerConfig: {
position: 'absolute',
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
// We only want to center align the subviews on iOS.
// See https://github.com/software-mansion/react-native-screens/pull/2456
alignItems: Platform.OS === 'ios' ? 'center' : undefined,
},
});
@@ -0,0 +1,35 @@
import { Image, ImageProps, View, ViewProps } from 'react-native';
import React from 'react';
import { HeaderSubviewTypes, ScreenStackHeaderConfigProps } from '../types';
export const ScreenStackHeaderBackButtonImage = (
props: ImageProps,
): JSX.Element => (
<View>
<Image resizeMode="center" fadeDuration={0} {...props} />
</View>
);
export const ScreenStackHeaderRightView = (props: ViewProps): JSX.Element => (
<View {...props} />
);
export const ScreenStackHeaderLeftView = (props: ViewProps): JSX.Element => (
<View {...props} />
);
export const ScreenStackHeaderCenterView = (props: ViewProps): JSX.Element => (
<View {...props} />
);
export const ScreenStackHeaderSearchBarView = (
props: ViewProps,
): JSX.Element => <View {...props} />;
export const ScreenStackHeaderConfig = (
props: ScreenStackHeaderConfigProps,
): JSX.Element => <View {...props} />;
export const ScreenStackHeaderSubview: React.ComponentType<
ViewProps & { type?: HeaderSubviewTypes }
> = View;
@@ -0,0 +1,177 @@
import * as React from 'react';
import {
Platform,
type StyleProp,
StyleSheet,
type ViewStyle,
View,
} from 'react-native';
import warnOnce from 'warn-once';
import DebugContainer from './DebugContainer';
import { ScreenProps, ScreenStackHeaderConfigProps } from '../types';
import { ScreenStackHeaderConfig } from './ScreenStackHeaderConfig';
import Screen from './Screen';
import ScreenStack from './ScreenStack';
import { RNSScreensRefContext } from '../contexts';
import { FooterComponent } from './ScreenFooter';
type Props = Omit<
ScreenProps,
'enabled' | 'isNativeStack' | 'hasLargeHeader'
> & {
screenId: string;
headerConfig?: ScreenStackHeaderConfigProps;
contentStyle?: StyleProp<ViewStyle>;
};
function ScreenStackItem(
{
children,
headerConfig,
activityState,
shouldFreeze,
stackPresentation,
sheetAllowedDetents,
contentStyle,
style,
screenId,
// eslint-disable-next-line camelcase
unstable_sheetFooter,
...rest
}: Props,
ref: React.ForwardedRef<View>,
) {
const currentScreenRef = React.useRef<View | null>(null);
const screenRefs = React.useContext(RNSScreensRefContext);
React.useImperativeHandle(ref, () => currentScreenRef.current!);
const isHeaderInModal =
Platform.OS === 'android'
? false
: stackPresentation !== 'push' && headerConfig?.hidden === false;
const headerHiddenPreviousRef = React.useRef(headerConfig?.hidden);
React.useEffect(() => {
warnOnce(
Platform.OS !== 'android' &&
stackPresentation !== 'push' &&
headerHiddenPreviousRef.current !== headerConfig?.hidden,
`Dynamically changing header's visibility in modals will result in remounting the screen and losing all local state.`,
);
headerHiddenPreviousRef.current = headerConfig?.hidden;
}, [headerConfig?.hidden, stackPresentation]);
const content = (
<>
<DebugContainer
style={[
stackPresentation === 'formSheet'
? Platform.OS === 'ios'
? styles.absolute
: sheetAllowedDetents === 'fitToContents'
? null
: styles.container
: styles.container,
contentStyle,
]}
stackPresentation={stackPresentation ?? 'push'}>
{children}
</DebugContainer>
{/**
* `HeaderConfig` needs to be the direct child of `Screen` without any intermediate `View`
* We don't render it conditionally based on visibility to make it possible to dynamically render a custom `header`
* Otherwise dynamically rendering a custom `header` leaves the native header visible
*
* https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screenstackheaderconfig
*
* HeaderConfig must not be first child of a Screen.
* See https://github.com/software-mansion/react-native-screens/pull/1825
* for detailed explanation.
*/}
<ScreenStackHeaderConfig {...headerConfig} />
{/* eslint-disable-next-line camelcase */}
{stackPresentation === 'formSheet' && unstable_sheetFooter && (
<FooterComponent>{unstable_sheetFooter()}</FooterComponent>
)}
</>
);
// 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,
};
}
return (
<Screen
ref={node => {
currentScreenRef.current = node;
if (screenRefs === null) {
console.warn(
'Looks like RNSScreensRefContext is missing. Make sure the ScreenStack component is wrapped in it',
);
return;
}
const currentRefs = screenRefs.current;
if (node === null) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete currentRefs[screenId];
} else {
currentRefs[screenId] = { current: node };
}
}}
enabled
isNativeStack
activityState={activityState}
shouldFreeze={shouldFreeze}
screenId={screenId}
stackPresentation={stackPresentation}
hasLargeHeader={headerConfig?.largeTitle ?? false}
sheetAllowedDetents={sheetAllowedDetents}
style={[style, internalScreenStyle]}
{...rest}>
{isHeaderInModal ? (
<ScreenStack style={styles.container}>
<Screen
enabled
isNativeStack
activityState={activityState}
shouldFreeze={shouldFreeze}
hasLargeHeader={headerConfig?.largeTitle ?? false}
style={StyleSheet.absoluteFill}>
{content}
</Screen>
</ScreenStack>
) : (
content
)}
</Screen>
);
}
export default React.forwardRef(ScreenStackItem);
const styles = StyleSheet.create({
container: {
flex: 1,
},
absolute: {
position: 'absolute',
top: 0,
start: 0,
end: 0,
},
});
@@ -0,0 +1,105 @@
'use client';
import React from 'react';
import { SearchBarCommands, SearchBarProps } from '../types';
import { isSearchBarAvailableForCurrentPlatform } from '../utils';
import { View } from 'react-native';
// Native components
import SearchBarNativeComponent, {
Commands as SearchBarNativeCommands,
NativeProps as SearchBarNativeProps,
SearchBarEvent,
SearchButtonPressedEvent,
ChangeTextEvent,
} from '../fabric/SearchBarNativeComponent';
import { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
const NativeSearchBar: React.ComponentType<
SearchBarNativeProps & { ref?: React.RefObject<SearchBarCommands> }
> &
typeof NativeSearchBarCommands =
SearchBarNativeComponent as unknown as React.ComponentType<SearchBarNativeProps> &
SearchBarCommandsType;
const NativeSearchBarCommands: SearchBarCommandsType =
SearchBarNativeCommands as SearchBarCommandsType;
type NativeSearchBarRef = React.ElementRef<typeof NativeSearchBar>;
type SearchBarCommandsType = {
blur: (viewRef: NativeSearchBarRef) => void;
focus: (viewRef: NativeSearchBarRef) => void;
clearText: (viewRef: NativeSearchBarRef) => void;
toggleCancelButton: (viewRef: NativeSearchBarRef, flag: boolean) => void;
setText: (viewRef: NativeSearchBarRef, text: string) => void;
cancelSearch: (viewRef: NativeSearchBarRef) => void;
};
function SearchBar(
props: SearchBarProps,
forwardedRef: React.Ref<SearchBarCommands>,
) {
const searchBarRef = React.useRef<SearchBarCommands | null>(null);
React.useImperativeHandle(forwardedRef, () => ({
blur: () => {
_callMethodWithRef(ref => NativeSearchBarCommands.blur(ref));
},
focus: () => {
_callMethodWithRef(ref => NativeSearchBarCommands.focus(ref));
},
toggleCancelButton: (flag: boolean) => {
_callMethodWithRef(ref =>
NativeSearchBarCommands.toggleCancelButton(ref, flag),
);
},
clearText: () => {
_callMethodWithRef(ref => NativeSearchBarCommands.clearText(ref));
},
setText: (text: string) => {
_callMethodWithRef(ref => NativeSearchBarCommands.setText(ref, text));
},
cancelSearch: () => {
_callMethodWithRef(ref => NativeSearchBarCommands.cancelSearch(ref));
},
}));
const _callMethodWithRef = React.useCallback(
(method: (ref: SearchBarCommands) => void) => {
const ref = searchBarRef.current;
if (ref) {
method(ref);
} else {
console.warn(
'Reference to native search bar component has not been updated yet',
);
}
},
[searchBarRef],
);
if (!isSearchBarAvailableForCurrentPlatform) {
console.warn(
'Importing SearchBar is only valid on iOS and Android devices.',
);
return View as unknown as React.ReactNode;
}
return (
<NativeSearchBar
ref={searchBarRef}
{...props}
onSearchFocus={props.onFocus as DirectEventHandler<SearchBarEvent>}
onSearchBlur={props.onBlur as DirectEventHandler<SearchBarEvent>}
onSearchButtonPress={
props.onSearchButtonPress as DirectEventHandler<SearchButtonPressedEvent>
}
onCancelButtonPress={
props.onCancelButtonPress as DirectEventHandler<SearchBarEvent>
}
onChangeText={props.onChangeText as DirectEventHandler<ChangeTextEvent>}
/>
);
}
export default React.forwardRef<SearchBarCommands, SearchBarProps>(SearchBar);
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const SearchBar = View;
export default SearchBar;
@@ -0,0 +1,78 @@
'use client';
import React from 'react';
import {
StyleSheet,
findNodeHandle,
type NativeSyntheticEvent,
} from 'react-native';
import BottomTabsNativeComponent, {
type NativeProps as BottomTabsNativeComponentProps,
} from '../../fabric/bottom-tabs/BottomTabsNativeComponent';
import featureFlags from '../../flags';
import type {
BottomTabsProps,
NativeFocusChangeEvent,
} from './BottomTabs.types';
import { bottomTabsDebugLog } from '../../private/logging';
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function BottomTabs(props: BottomTabsProps) {
bottomTabsDebugLog(`BottomTabs render`);
const {
onNativeFocusChange,
experimentalControlNavigationStateInJS = featureFlags.experiment
.controlledBottomTabs,
...filteredProps
} = props;
const componentNodeRef =
React.useRef<React.Component<BottomTabsNativeComponentProps>>(null);
const componentNodeHandle = React.useRef<number>(-1);
React.useEffect(() => {
if (componentNodeRef.current != null) {
componentNodeHandle.current =
findNodeHandle(componentNodeRef.current) ?? -1;
} else {
componentNodeHandle.current = -1;
}
}, []);
const onNativeFocusChangeCallback = React.useCallback(
(event: NativeSyntheticEvent<NativeFocusChangeEvent>) => {
bottomTabsDebugLog(
`BottomTabs [${
componentNodeHandle.current ?? -1
}] onNativeFocusChange: ${JSON.stringify(event.nativeEvent)}`,
);
onNativeFocusChange?.(event);
},
[onNativeFocusChange],
);
return (
<BottomTabsNativeComponent
style={styles.fillParent}
onNativeFocusChange={onNativeFocusChangeCallback}
controlNavigationStateInJS={experimentalControlNavigationStateInJS}
// @ts-ignore suppress ref - debug only
ref={componentNodeRef}
{...filteredProps}>
{filteredProps.children}
</BottomTabsNativeComponent>
);
}
export default BottomTabs;
const styles = StyleSheet.create({
fillParent: {
flex: 1,
width: '100%',
height: '100%',
},
});
@@ -0,0 +1,206 @@
import type {
ColorValue,
TextStyle,
NativeSyntheticEvent,
ViewProps,
} from 'react-native';
export type NativeFocusChangeEvent = {
tabKey: string;
};
// Android-specific
export type TabBarItemLabelVisibilityMode =
| 'auto'
| 'selected'
| 'labeled'
| 'unlabeled';
// iOS-specific
export type TabBarMinimizeBehavior =
| 'automatic'
| 'never'
| 'onScrollDown'
| 'onScrollUp';
export interface BottomTabsProps extends ViewProps {
// #region Events
/**
* A callback that gets invoked when user requests change of focused tab screen.
*
* @platform android, ios
*/
onNativeFocusChange?: (
event: NativeSyntheticEvent<NativeFocusChangeEvent>,
) => void;
// #endregion Events
// #region Android-only appearance
/**
* @summary Specifies the background color for the entire tab bar.
*
* @platform android
*/
tabBarBackgroundColor?: ColorValue;
/**
* @summary Specifies the font family used for the title of each tab bar item.
*
* @platform android
*/
tabBarItemTitleFontFamily?: TextStyle['fontFamily'];
/**
* @summary Specifies the font size used for the title of each tab bar item.
*
* The size is represented in scale-independent pixels (sp).
*
* @platform android
*/
tabBarItemTitleFontSize?: TextStyle['fontSize'];
/**
* @summary Specifies the font size used for the title of each tab bar item in active state.
*
* The size is represented in scale-independent pixels (sp).
*
* @platform android
*/
tabBarItemTitleFontSizeActive?: TextStyle['fontSize'];
/**
* @summary Specifies the font weight used for the title of each tab bar item.
*
* @platform android
*/
tabBarItemTitleFontWeight?: TextStyle['fontWeight'];
/**
* @summary Specifies the font style used for the title of each tab bar item.
*
* @platform android
*/
tabBarItemTitleFontStyle?: TextStyle['fontStyle'];
/**
* @summary Specifies the font color used for the title of each tab bar item.
*
* @platform android
*/
tabBarItemTitleFontColor?: TextStyle['color'];
/**
* @summary Specifies the font color used for the title of each tab bar item in active state.
*
* If not provided, `tabBarItemTitleFontColor` is used.
*
* @platform android
*/
tabBarItemTitleFontColorActive?: TextStyle['color'];
/**
* @summary Specifies the icon color for each tab bar item.
*
* @platform android
*/
tabBarItemIconColor?: ColorValue;
/**
* @summary Specifies the icon color for each tab bar item in active state.
*
* If not provided, `tabBarItemIconColor` is used.
*
* @platform android
*/
tabBarItemIconColorActive?: ColorValue;
/**
* @summary Specifies the background color of the active indicator.
*
* @platform android
*/
tabBarItemActiveIndicatorColor?: ColorValue;
/**
* @summary Specifies if the active indicator should be used.
*
* @default true
*
* @platform android
*/
tabBarItemActiveIndicatorEnabled?: boolean;
/**
* @summary Specifies the color of each tab bar item's ripple effect.
*
* @platform android
*/
tabBarItemRippleColor?: ColorValue;
/**
* @summary Specifies the label visibility mode.
*
* The label visibility mode defines when the labels of each item bar should be displayed.
*
* The following values are available:
* - `auto` - the label behaves as in “labeled” mode when there are 3 items or less, or as in “selected” mode when there are 4 items or more
* - `selected` - the label is only shown on the selected navigation item
* - `labeled` - the label is shown on all navigation items
* - `unlabeled` - the label is hidden for all navigation items
*
* The supported values correspond to the official Material Components documentation:
* @see {@link https://github.com/material-components/material-components-android/blob/master/docs/components/BottomNavigation.md#making-navigation-bar-accessible|Material Components documentation}
*
* @default auto
* @platform android
*/
tabBarItemLabelVisibilityMode?: TabBarItemLabelVisibilityMode;
// #endregion Android-only appearance
// #region iOS-only appearance
/**
* @summary Specifies the color used for selected tab's text and icon color.
*
* Starting from iOS 26, it also impacts glow of Liquid Glass tab
* selection view.
*
* `tabBarItemTitleFontColor` and `tabBarItemIconColor` defined on
* BottomTabsScreen component override this color.
*
* @platform ios
*/
tabBarTintColor?: ColorValue;
/**
* @summary Specifies the minimize behavior for the tab bar.
*
* Available starting from iOS 26.
*
* The following values are currently supported:
*
* - `automatic` - resolves to the system default minimize behavior
* - `never` - the tab bar does not minimize
* - `onScrollDown` - the tab bar minimizes when scrolling down and
* expands when scrolling back up
* - `onScrollUp` - the tab bar minimizes when scrolling up and expands
* when scrolling back down
*
* The supported values correspond to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uitabbarcontroller/minimizebehavior|UITabBarController.MinimizeBehavior}
*
* @default Defaults to `automatic`.
*
* @platform ios
* @supported iOS 26 or higher
*/
tabBarMinimizeBehavior?: TabBarMinimizeBehavior;
// #endregion iOS-only appearance
// #region Experimental support
/**
* @summary Experimental prop for changing container control.
*
* If set to true, tab screen changes need to be handled by JS using
* onNativeFocusChange callback (controlled/programatically-driven).
*
* If set to false, tab screen change will not be prevented by the
* native side (managed/natively-driven).
*
* On iOS, some features are not fully implemented for managed tabs
* (e.g. overrideScrollViewContentInsetAdjustmentBehavior).
*
* On Android, only controlled tabs are currently supported.
*
* @default Defaults to `false`.
*
* @platform android, ios
*/
experimentalControlNavigationStateInJS?: boolean;
// #endregion Experimental support
}
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const BottomTabs = View;
export default BottomTabs;
@@ -0,0 +1,327 @@
'use client';
import React from 'react';
import { Freeze } from 'react-freeze';
import {
Image,
StyleSheet,
findNodeHandle,
processColor,
type ImageSourcePropType,
type NativeSyntheticEvent,
} from 'react-native';
import { freezeEnabled } from '../../core';
import BottomTabsScreenNativeComponent, {
type IconType,
type NativeProps,
type Appearance,
type ItemAppearance,
type ItemStateAppearance,
} from '../../fabric/bottom-tabs/BottomTabsScreenNativeComponent';
import { featureFlags } from '../../flags';
import type {
BottomTabsScreenAppearance,
BottomTabsScreenItemAppearance,
BottomTabsScreenItemStateAppearance,
BottomTabsScreenProps,
EmptyObject,
Icon,
} from './BottomTabsScreen.types';
import { bottomTabsDebugLog } from '../../private/logging';
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function BottomTabsScreen(props: BottomTabsScreenProps) {
const componentNodeRef = React.useRef<React.Component<NativeProps>>(null);
const componentNodeHandle = React.useRef<number>(-1);
React.useEffect(() => {
if (componentNodeRef.current != null) {
componentNodeHandle.current =
findNodeHandle(componentNodeRef.current) ?? -1;
} else {
componentNodeHandle.current = -1;
}
}, []);
const [nativeViewIsVisible, setNativeViewIsVisible] = React.useState(false);
const {
onWillAppear,
onDidAppear,
onWillDisappear,
onDidDisappear,
isFocused = false,
freezeContents,
icon,
iconResource,
selectedIcon,
standardAppearance,
scrollEdgeAppearance,
...rest
} = props;
const shouldFreeze = shouldFreezeScreen(
nativeViewIsVisible,
isFocused,
freezeContents,
);
const onWillAppearCallback = React.useCallback(
(event: NativeSyntheticEvent<EmptyObject>) => {
bottomTabsDebugLog(
`TabsScreen [${componentNodeHandle.current}] onWillAppear received`,
);
setNativeViewIsVisible(true);
onWillAppear?.(event);
},
[onWillAppear],
);
const onDidAppearCallback = React.useCallback(
(event: NativeSyntheticEvent<EmptyObject>) => {
bottomTabsDebugLog(
`TabsScreen [${componentNodeHandle.current}] onDidAppear received`,
);
onDidAppear?.(event);
},
[onDidAppear],
);
const onWillDisappearCallback = React.useCallback(
(event: NativeSyntheticEvent<EmptyObject>) => {
bottomTabsDebugLog(
`TabsScreen [${componentNodeHandle.current}] onWillDisappear received`,
);
onWillDisappear?.(event);
},
[onWillDisappear],
);
const onDidDisappearCallback = React.useCallback(
(event: NativeSyntheticEvent<EmptyObject>) => {
bottomTabsDebugLog(
`TabsScreen [${componentNodeHandle.current}] onDidDisappear received`,
);
setNativeViewIsVisible(false);
onDidDisappear?.(event);
},
[onDidDisappear],
);
bottomTabsDebugLog(
`TabsScreen [${componentNodeHandle.current ?? -1}] render; tabKey: ${
rest.tabKey
} shouldFreeze: ${shouldFreeze}, isFocused: ${isFocused} nativeViewIsVisible: ${nativeViewIsVisible}`,
);
const iconProps = parseIconsToNativeProps(icon, selectedIcon);
let parsedIconResource;
if (iconResource) {
parsedIconResource = Image.resolveAssetSource(iconResource);
if (!parsedIconResource) {
console.error(
'[RNScreens] failed to resolve an asset for bottom tab icon',
);
}
}
return (
<BottomTabsScreenNativeComponent
collapsable={false}
style={styles.fillParent}
onWillAppear={onWillAppearCallback}
onDidAppear={onDidAppearCallback}
onWillDisappear={onWillDisappearCallback}
onDidDisappear={onDidDisappearCallback}
isFocused={isFocused}
// I'm keeping undefined as a fallback if `Image.resolveAssetSource` has failed for some reason.
// It won't render any icon, but it will prevent from crashing on the native side which is expecting
// ReadableMap. Passing `iconResource` directly will result in crash, because `require` API is returning
// double as a value.
iconResource={parsedIconResource || undefined}
{...iconProps}
standardAppearance={mapAppearanceToNativeProp(standardAppearance)}
scrollEdgeAppearance={mapAppearanceToNativeProp(scrollEdgeAppearance)}
// @ts-ignore - This is debug only anyway
ref={componentNodeRef}
{...rest}>
<Freeze freeze={shouldFreeze} placeholder={rest.placeholder}>
{rest.children}
</Freeze>
</BottomTabsScreenNativeComponent>
);
}
function mapAppearanceToNativeProp(
appearance?: BottomTabsScreenAppearance,
): Appearance | undefined {
if (!appearance) return undefined;
const {
stacked,
inline,
compactInline,
tabBarBackgroundColor,
tabBarShadowColor,
} = appearance;
return {
...appearance,
stacked: mapItemAppearanceToNativeProp(stacked),
inline: mapItemAppearanceToNativeProp(inline),
compactInline: mapItemAppearanceToNativeProp(compactInline),
tabBarBackgroundColor: processColor(tabBarBackgroundColor),
tabBarShadowColor: processColor(tabBarShadowColor),
};
}
function mapItemAppearanceToNativeProp(
itemAppearance?: BottomTabsScreenItemAppearance,
): ItemAppearance | undefined {
if (!itemAppearance) return undefined;
const { normal, selected, focused, disabled } = itemAppearance;
return {
...itemAppearance,
normal: mapItemStateAppearanceToNativeProp(normal),
selected: mapItemStateAppearanceToNativeProp(selected),
focused: mapItemStateAppearanceToNativeProp(focused),
disabled: mapItemStateAppearanceToNativeProp(disabled),
};
}
function mapItemStateAppearanceToNativeProp(
itemStateAppearance?: BottomTabsScreenItemStateAppearance,
): ItemStateAppearance | undefined {
if (!itemStateAppearance) return undefined;
const {
tabBarItemTitleFontColor,
tabBarItemIconColor,
tabBarItemBadgeBackgroundColor,
tabBarItemTitleFontWeight,
} = itemStateAppearance;
return {
...itemStateAppearance,
tabBarItemTitleFontColor: processColor(tabBarItemTitleFontColor),
tabBarItemIconColor: processColor(tabBarItemIconColor),
tabBarItemBadgeBackgroundColor: processColor(
tabBarItemBadgeBackgroundColor,
),
tabBarItemTitleFontWeight:
tabBarItemTitleFontWeight !== undefined
? String(tabBarItemTitleFontWeight)
: undefined,
};
}
function shouldFreezeScreen(
nativeViewVisible: boolean,
screenFocused: boolean,
freezeOverride: boolean | undefined,
) {
if (!freezeEnabled()) {
return false;
}
if (freezeOverride !== undefined) {
return freezeOverride;
}
if (featureFlags.experiment.controlledBottomTabs) {
// If the tabs are JS controlled, we want to freeze only when given view is not focused && it is not currently visible
return !nativeViewVisible && !screenFocused;
}
return !nativeViewVisible;
}
function parseIconToNativeProps(icon: Icon | undefined): {
iconType?: IconType;
iconImageSource?: ImageSourcePropType;
iconSfSymbolName?: string;
} {
if (!icon) {
return {};
}
if ('sfSymbolName' in icon) {
// iOS-specific: SFSymbol usage
return {
iconType: 'sfSymbol',
iconSfSymbolName: icon.sfSymbolName,
};
} else if ('imageSource' in icon) {
return {
iconType: 'image',
iconImageSource: icon.imageSource,
};
} else if ('templateSource' in icon) {
// iOS-specifig: image as a template usage
return {
iconType: 'template',
iconImageSource: icon.templateSource,
};
} else {
// iOS-specific: SFSymbol, image as a template usage
throw new Error(
'[RNScreens] Incorrect icon format. You must provide sfSymbolName, imageSource or templateSource.',
);
}
}
function parseIconsToNativeProps(
icon: Icon | undefined,
selectedIcon: Icon | undefined,
): {
iconType?: IconType;
iconImageSource?: ImageSourcePropType;
iconSfSymbolName?: string;
selectedIconImageSource?: ImageSourcePropType;
selectedIconSfSymbolName?: string;
} {
const { iconImageSource, iconSfSymbolName, iconType } =
parseIconToNativeProps(icon);
const {
iconImageSource: selectedIconImageSource,
iconSfSymbolName: selectedIconSfSymbolName,
iconType: selectedIconType,
} = parseIconToNativeProps(selectedIcon);
if (
iconType !== undefined &&
selectedIconType !== undefined &&
iconType !== selectedIconType
) {
throw new Error('[RNScreens] icon and selectedIcon must be same type.');
} else if (iconType === undefined && selectedIconType !== undefined) {
// iOS-specific: UIKit requirement
throw new Error(
'[RNScreens] To use selectedIcon prop, the icon prop must also be provided.',
);
}
return {
iconType,
iconImageSource,
iconSfSymbolName,
selectedIconImageSource,
selectedIconSfSymbolName,
};
}
export default BottomTabsScreen;
const styles = StyleSheet.create({
fillParent: {
position: 'absolute',
flex: 1,
width: '100%',
height: '100%',
},
});
@@ -0,0 +1,549 @@
import type {
ColorValue,
ImageSourcePropType,
NativeSyntheticEvent,
TextStyle,
ViewProps,
} from 'react-native';
export type EmptyObject = Record<string, never>;
export type BottomTabsScreenEventHandler<T> = (
event: NativeSyntheticEvent<T>,
) => void;
export type LifecycleStateChangeEvent = Readonly<{
previousState: number;
newState: number;
}>;
// iOS-specific: SFSymbol usage
export interface SFIcon {
sfSymbolName: string;
}
// iOS-specific
export interface ImageIcon {
imageSource: ImageSourcePropType;
}
// iOS-specific: image as a template usage
export interface TemplateIcon {
templateSource: ImageSourcePropType;
}
// iOS-specific: SFSymbol, image as a template usage
export type Icon = SFIcon | ImageIcon | TemplateIcon;
// iOS-specific
export type BottomTabsScreenBlurEffect =
| 'none'
| 'systemDefault'
| 'extraLight'
| 'light'
| 'dark'
| 'regular'
| 'prominent'
| 'systemUltraThinMaterial'
| 'systemThinMaterial'
| 'systemMaterial'
| 'systemThickMaterial'
| 'systemChromeMaterial'
| 'systemUltraThinMaterialLight'
| 'systemThinMaterialLight'
| 'systemMaterialLight'
| 'systemThickMaterialLight'
| 'systemChromeMaterialLight'
| 'systemUltraThinMaterialDark'
| 'systemThinMaterialDark'
| 'systemMaterialDark'
| 'systemThickMaterialDark'
| 'systemChromeMaterialDark';
export type BottomTabsSystemItem =
| 'bookmarks'
| 'contacts'
| 'downloads'
| 'favorites'
| 'featured'
| 'history'
| 'more'
| 'mostRecent'
| 'mostViewed'
| 'recents'
| 'search'
| 'topRated';
// Currently iOS-only
export type BottomTabsScreenOrientation =
| 'inherit'
| 'all'
| 'allButUpsideDown'
| 'portrait'
| 'portraitUp'
| 'portraitDown'
| 'landscape'
| 'landscapeLeft'
| 'landscapeRight';
// iOS-specific
export interface BottomTabsScreenAppearance {
/**
* @summary Specifies the appearance of tab bar items when they are in stacked layout.
*
* Tab bar items in stacked layout have the icon above the title.
* Stacked layout is used e.g. on the iPhone in portrait orientation.
*
* @platform ios
*/
stacked?: BottomTabsScreenItemAppearance;
/**
* @summary Specifies the appearance of tab bar items when they are in inline layout.
*
* Tab bar items in inline layout have the icon next to the title.
* Inline layout is used in regular-width environments, e.g. in landscape orientation on the iPhone 16 Pro Max.
*
* Complete list of size classes for iOS and iPadOS devices is available in Apple's Human Interface Guidelines:
* @see {@link https://developer.apple.com/design/human-interface-guidelines/layout#iOS-iPadOS-device-size-classes|HIG: Device size classes}
*
* @platform ios
*/
inline?: BottomTabsScreenItemAppearance;
/**
* @summary Specifies the appearance of tab bar items when they are in compact inline layout.
*
* Tab bar items in compact inline layout have the icon next to the title.
* Compact inline layout is used in compact-width environments, e.g. in landscape orientation on the iPhone 16 Pro.
*
* Complete list of size classes for iOS and iPadOS devices is available in Apple's Human Interface Guidelines:
* @see {@link https://developer.apple.com/design/human-interface-guidelines/layout#iOS-iPadOS-device-size-classes|HIG: Device size classes}
*
* @platform ios
*/
compactInline?: BottomTabsScreenItemAppearance;
/**
* @summary Specifies the background color for the entire tab bar when tab screen is selected.
*
* This property does not affect the tab bar starting from iOS 26.
*
* @platform ios
* @supported iOS 18 or lower
*/
tabBarBackgroundColor?: ColorValue;
/**
* @summary Specifies the blur effect applied to the tab bar when tab screen is selected.
*
* Works with backgroundColor's alpha < 1.
*
* This property does not affect the tab bar starting from iOS 26.
*
* The following values are currently supported:
*
* - `none` - disables blur effect
* - `systemDefault` - uses UIKit's default tab bar blur effect
* - one of styles mapped from UIKit's UIBlurEffectStyle, e.g. `systemUltraThinMaterial`
*
* Complete list of possible blur effect styles is available in the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uiblureffect/style|UIBlurEffect.Style}
*
* @default systemDefault
*
* @platform ios
* @supported iOS 18 or lower
*/
tabBarBlurEffect?: BottomTabsScreenBlurEffect;
/**
* @summary Specifies the shadow color for the tab bar when tab screen is selected.
*
* This property does not affect the tab bar starting from iOS 26.
*
* @platform ios
* @supported iOS 18 or lower
*/
tabBarShadowColor?: ColorValue;
}
// iOS-specific
export interface BottomTabsScreenItemAppearance {
/**
* Specifies the tab bar item appearance when it's enabled, unselected, and not the focused item.
*
* @platform ios
*/
normal?: BottomTabsScreenItemStateAppearance;
/**
* Specifies the tab bar item appearance when it's selected.
*
* @platform ios
*/
selected?: BottomTabsScreenItemStateAppearance;
/**
* Specifies the tab bar item appearance when it's focused.
*
* @platform ios
*/
focused?: BottomTabsScreenItemStateAppearance;
/**
* Specifies the tab bar item appearance when it's disabled.
*
* @platform ios
*/
disabled?: BottomTabsScreenItemStateAppearance;
}
// iOS-specific
export interface BottomTabsScreenItemStateAppearance {
/**
* @summary Specifies the font family used for the title of each tab bar item
* when tab screen is selected.
*
* @platform ios
*/
tabBarItemTitleFontFamily?: TextStyle['fontFamily'];
/**
* @summary Specifies the font size used for the title of each tab bar item
* when tab screen is selected.
*
* @platform ios
*/
tabBarItemTitleFontSize?: TextStyle['fontSize'];
/**
* @summary Specifies the font weight used for the title of each tab bar item
* when tab screen is selected.
*
* @platform ios
*/
tabBarItemTitleFontWeight?: TextStyle['fontWeight'];
/**
* @summary Specifies the font style used for the title of each tab bar item
* when tab screen is selected.
*
* @platform ios
*/
tabBarItemTitleFontStyle?: TextStyle['fontStyle'];
/**
* @summary Specifies the font color used for the title of each tab bar item
* when tab screen is selected.
*
* Overrides the color defined in `tabBarTintColor` and `tabBarItemIconColor`.
*
* @platform ios
*/
tabBarItemTitleFontColor?: TextStyle['color'];
/**
* @summary Specifies the title offset for each tab bar item when tab screen
* is selected.
*
* Depending on the iOS version and the device's interface orientation,
* this setting may affect the alignment of the text, badge and icon.
*
* @platform ios
*/
tabBarItemTitlePositionAdjustment?: {
horizontal?: number;
vertical?: number;
};
/**
* @summary Specifies the icon color for each tab bar item when tab screen
* is selected.
*
* This also impacts the title text color.
*
* Starting from iOS 26, it only applies to selected tab bar item. Other items
* adopt a dark or light appearance depending on the theme of the tab bar.
*
* Is overridden by `tabBarItemTitleFontColor` (for title text color).
* Overrides `tabBarTintColor`.
*
* @platform ios
*/
tabBarItemIconColor?: ColorValue;
/**
* @summary Specifies the background color of badges for each tab bar item
* when tab screen is selected.
*
* @platform ios
*/
tabBarItemBadgeBackgroundColor?: ColorValue;
}
export interface BottomTabsScreenProps {
children?: ViewProps['children'];
/**
* @summary Defines what should be rendered when tab screen is frozen.
*
* @see {@link https://github.com/software-mansion/react-freeze|`react-freeze`'s GitHub repository} for more information about `react-freeze`.
*
* @platform android, ios
*/
placeholder?: React.ReactNode | undefined;
// #region Control
/**
* @summary Determines selected tab.
*
* In controlled container mode, determines if tab screen is currently
* focused.
*
* In managed container mode, it only indicates initially selected tab.
*
* There should be exactly one focused screen at any given time.
*
* @platform android, ios
*/
isFocused?: boolean;
/**
* @summary Identifies screen, e.g. when receiving onNativeFocusChange event.
*
* @platform android, ios
*/
tabKey: string;
// #endregion
// #region General
/**
* @summary Title of the tab screen, displayed in the tab bar item.
*
* @platform android, ios
*/
title?: string;
/**
* @summary Specifies content of tab bar item badge.
*
* On iOS, badge is displayed as regular string.
*
* On Android, the value is interpreted in the following order:
* - if the string can be parsed to integer, displays the value as a number;
* - otherwise if the string is empty, displays "small dot" badge;
* - otherwise, displays the value as a text.
*
* @platform android, ios
*/
badgeValue?: string;
/**
* @summary Specifies supported orientations for the tab screen.
*
* Procedure for determining supported orientations:
* 1. Traversal initiates from the root component and moves to the
* deepest child possible.
* 2. Components are queried for their supported orientations:
* - if `orientation` is explicitly set (e.g., `portrait`,
* `landscape`), it is immediately used,
* - if `orientation` is set to `inherit`, the parent component
* is queried.
*
* Note that:
* - some components (like `SplitViewHost`) may choose not to query
* its child components,
* - Stack v4 implementation **ALWAYS** returns some supported
* orientations (`allButUpsideDown` by default), overriding
* orientation from tab screen.
*
* The following values are currently supported:
*
* - `inherit` - tab screen supports the same orientations as parent
* component,
* - `all` - tab screen supports all orientations,
* - `allButUpsideDown` - tab screen supports all but the upside-down
* portrait interface orientation,
* - `portrait` - tab screen supports both portrait-up and portrait-down
* interface orientations,
* - 'portraitUp' - tab screen supports a portrait-up interface
* orientation,
* - `portraitDown` - tab screen supports a portrait-down interface
* orientation,
* - `landscape` - tab screen supports both landscape-left and
* landscape-right interface orientations,
* - `landscapeLeft` - tab screen supports landscape-left interface
* orientaion,
* - `landscapeRight` - tab screen supports landscape-right interface
* orientaion.
*
* The supported values (apart from `inherit`, `portrait`, `portraitUp`,
* `portraitDown`) correspond to the official UIKit documentation:
*
* @see {@link https://developer.apple.com/documentation/uikit/uiinterfaceorientationmask|UIInterfaceOrientationMask}
*
* @default inherit
*
* @platform ios
*/
orientation?: BottomTabsScreenOrientation;
// #endregion General
// #region Android-only appearance
/**
* @summary Specifies the icon for the tab bar item.
*
* Accepts a string corresponding to the resource name. Initially searches within
* the app's drawable resources. If no matching resource is found, it defaults to
* searching within the Android's drawable resources.
*
* @platform android
*/
iconResourceName?: string;
/**
* @summary Specifies the icon for the tab bar item.
*
* Accepts a path to the external image asset. As for now, it respects an image from local assets
* and passed by `source.uri` property.
*
* @platform android
*/
iconResource?: ImageSourcePropType;
/**
* @summary Specifies the color of the text in the badge.
*
* @platform android
*/
tabBarItemBadgeTextColor?: ColorValue;
/**
* @summary Specifies the background color of the badge.
*
* @platform android
*/
tabBarItemBadgeBackgroundColor?: ColorValue;
// #endregion Android-only appearance
// #region iOS-only appearance
/**
* @summary Specifies the standard tab bar appearance.
*
* Allows to customize the appearance depending on the tab bar item layout (stacked,
* inline, compact inline) and state (normal, selected, focused, disabled).
*
* @platform ios
*/
standardAppearance?: BottomTabsScreenAppearance;
/**
* @summary Specifies the tab bar appearace when edge of scrollable content aligns
* with the edge of the tab bar.
*
* Allows to customize the appearance depending on the tab bar item layout (stacked,
* inline, compact inline) and state (normal, selected, focused, disabled).
*
* If this property is `undefined`, UIKit uses `standardAppearance`, modified to
* have a transparent background.
*
* @platform ios
*/
scrollEdgeAppearance?: BottomTabsScreenAppearance;
/**
* @summary Specifies the icon for the tab bar item.
*
* The following values are currently supported:
*
* - an object with `sfSymbolName` - will attempt to use SF
* Symbol with given name,
* - an object with `imageSource` - will attempt to use image
* from provided resource,
* - an object with `templateSource` - will attempt to use image
* from provided resource as template (the color of the image will
* depend on props related to icon color and tab bar item's state).
*
* If no `selectedIcon` is provided, it will also be used as `selectedIcon`.
*
* @platform ios
*/
icon?: Icon;
/**
* @summary Specifies the icon for tab bar item when it is selected.
*
* Supports the same values as `icon` property.
*
* To use `selectedIcon`, `icon` must also be provided.
*
* @platform ios
*/
selectedIcon?: Icon;
/**
* @summary System-provided tab bar item with predefined icon and title
*
* Uses Apple's built-in tab bar items (e.g., bookmarks, contacts, downloads) with
* standard iOS styling and localized titles. Custom `icon` or `selectedIcon`
* properties will override the system icon, but the system-defined title cannot
* be customized.
*
* @see {@link https://developer.apple.com/documentation/uikit/uitabbaritem/systemitem|UITabBarItem.SystemItem}
* @platform ios
*/
systemItem?: BottomTabsSystemItem;
/**
* @summary Specifies which special effects (also known as microinteractions)
* are enabled for the tab screen.
*
* For repeated tab selection (selecting already focused tab bar item),
* there are 2 supported special effects:
* - `popToRoot` - when Stack is nested inside tab screen and repeated
* selection is detected, the Stack will pop to root screen,
* - `scrollToTop` - when there is a ScrollView in first descendant
* chain from tab screen and repeated selection is detected, ScrollView
* will be scrolled to top.
*
* `popToRoot` has priority over `scrollToTop`.
*
* @default All special effects are enabled by default.
*
* @platform ios
*/
specialEffects?: {
repeatedTabSelection?: {
popToRoot?: boolean;
scrollToTop?: boolean;
};
};
/**
* @summary Allows to control whether contents of a tab screen should be frozen or not. This overrides any default behavior.
*
* @default `undefined`
*/
freezeContents?: boolean;
/**
* @summary Specifies if `contentInsetAdjustmentBehavior` of first ScrollView
* in first descendant chain from tab screen should be overridden back from `never`
* to `automatic`.
*
* By default, `react-native`'s ScrollView has `contentInsetAdjustmentBehavior`
* set to `never` instead of UIKit-default (which is `automatic`). This
* prevents ScrollViews from respecting navigation bar insets.
* When this prop is set to `true`, `automatic` behavior is reverted.
*
* @default true
*
* @platform ios
*/
overrideScrollViewContentInsetAdjustmentBehavior?: boolean;
// #endregion iOS-only appearance
// #region Events
/**
* @summary A callback that gets invoked when the tab screen will appear.
* This is called as soon as the transition begins.
*
* @platform android, ios
*/
onWillAppear?: BottomTabsScreenEventHandler<EmptyObject>;
/**
* @summary A callback that gets invoked when the tab screen did appear.
* This is called as soon as the transition ends.
*
* @platform android, ios
*/
onDidAppear?: BottomTabsScreenEventHandler<EmptyObject>;
/**
* @summary A callback that gets invoked when the tab screen will disappear.
* This is called as soon as the transition begins.
*
* @platform android, ios
*/
onWillDisappear?: BottomTabsScreenEventHandler<EmptyObject>;
/**
* @summary A callback that gets invoked when the tab screen did disappear.
* This is called as soon as the transition ends.
*
* @platform android, ios
*/
onDidDisappear?: BottomTabsScreenEventHandler<EmptyObject>;
// #endregion Events
}
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const BottomTabsScreen = View;
export default BottomTabsScreen;
@@ -0,0 +1,32 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import type { ViewProps } from 'react-native';
import type { NativeProps } from '../../fabric/gamma/ScreenStackHostNativeComponent';
import ScreenStackHostNativeComponent from '../../fabric/gamma/ScreenStackHostNativeComponent';
export type ScreenStackNativeProps = NativeProps & {
// Overrides
};
type ScreenStackHostProps = {
children?: ViewProps['children'];
} & ScreenStackNativeProps;
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function ScreenStackHost({ children }: ScreenStackHostProps) {
return (
<ScreenStackHostNativeComponent style={styles.container}>
{children}
</ScreenStackHostNativeComponent>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default ScreenStackHost;
@@ -0,0 +1,11 @@
import { View, ViewProps } from 'react-native';
interface NativeProps extends ViewProps {}
export type ScreenStackNativeProps = NativeProps & {
// Overrides
};
const ScreenStackHost = View;
export default ScreenStackHost;
@@ -0,0 +1,75 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import SplitViewHostNativeComponent from '../../fabric/gamma/SplitViewHostNativeComponent';
import type {
SplitViewDisplayMode,
SplitViewHostProps,
SplitViewSplitBehavior,
} from './SplitViewHost.types';
// According to the UIKit documentation: https://developer.apple.com/documentation/uikit/uisplitviewcontroller/displaymode-swift.enum
// Only specific pairs for displayMode - splitBehavior are valid and others may lead to unexpected results.
// Therefore, we're adding check on the JS side to return a feedback to the client when that pairing isn't valid.
// However, we're not blocking these props to be set on the native side, because it doesn't crash, just the result or transitions may not work as expected.
const displayModeForSplitViewCompatibilityMap: Record<
SplitViewSplitBehavior,
SplitViewDisplayMode[]
> = {
tile: ['secondaryOnly', 'oneBesideSecondary', 'twoBesideSecondary'],
overlay: ['secondaryOnly', 'oneOverSecondary', 'twoOverSecondary'],
displace: ['secondaryOnly', 'oneBesideSecondary', 'twoDisplaceSecondary'],
automatic: [], // placeholder for satisfying types; we'll handle it specially in logic
};
const isValidDisplayModeForSplitBehavior = (
displayMode: SplitViewDisplayMode,
splitBehavior: SplitViewSplitBehavior,
) => {
if (splitBehavior === 'automatic') {
// for automatic we cannot easily verify the compatibility, because it depends on the system preference for display mode, therefore we're assuming that 'automatic' has only valid combinations
return true;
}
return displayModeForSplitViewCompatibilityMap[splitBehavior].includes(
displayMode,
);
};
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function SplitViewHost(props: SplitViewHostProps) {
const { preferredDisplayMode, preferredSplitBehavior } = props;
React.useEffect(() => {
if (preferredDisplayMode && preferredSplitBehavior) {
const isValid = isValidDisplayModeForSplitBehavior(
preferredDisplayMode,
preferredSplitBehavior,
);
if (!isValid) {
const validDisplayModes =
displayModeForSplitViewCompatibilityMap[preferredSplitBehavior];
console.warn(
`Invalid display mode "${preferredDisplayMode}" for split behavior "${preferredSplitBehavior}".` +
`\nValid modes for "${preferredSplitBehavior}" are: ${validDisplayModes.join(
', ',
)}.`,
);
}
}
}, [preferredDisplayMode, preferredSplitBehavior]);
return (
<SplitViewHostNativeComponent {...props} style={styles.container}>
{props.children}
</SplitViewHostNativeComponent>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default SplitViewHost;
@@ -0,0 +1,292 @@
import type { NativeSyntheticEvent, ViewProps } from 'react-native';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenericEmptyEvent = Readonly<{}>;
export type DisplayModeWillChangeEvent = {
currentDisplayMode: string;
nextDisplayMode: string;
};
export type SplitViewDisplayModeButtonVisibility =
| 'always'
| 'automatic'
| 'never';
export type SplitViewSplitBehavior =
| 'automatic'
| 'displace'
| 'overlay'
| 'tile';
export type SplitViewPrimaryEdge = 'leading' | 'trailing';
export type SplitViewDisplayMode =
| 'automatic'
| 'secondaryOnly'
| 'oneBesideSecondary'
| 'oneOverSecondary'
| 'twoBesideSecondary'
| 'twoOverSecondary'
| 'twoDisplaceSecondary';
export type SplitViewHostOrientation =
| 'inherit'
| 'all'
| 'allButUpsideDown'
| 'portrait'
| 'portraitUp'
| 'portraitDown'
| 'landscape'
| 'landscapeLeft'
| 'landscapeRight';
export interface SplitViewColumnMetrics {
/**
* @summary Minimum width for the primary sidebar.
*
* Specifies the minimum width for the primary column in the SplitView layout, typically representing the leftmost sidebar.
*/
minimumPrimaryColumnWidth?: number;
/**
* @summary Maximum width for the primary sidebar.
*
* Specifies the maximum width (in points) for the primary column in the SplitView layout, typically representing the leftmost sidebar.
*/
maximumPrimaryColumnWidth?: number;
/**
* @summary Preferred width for the primary sidebar.
*
* Specifies the preferred width (in points or as a fraction for percentage width support) for the primary column in the SplitView layout, typically representing the leftmost sidebar.
*/
preferredPrimaryColumnWidthOrFraction?: number;
/**
* @summary Minimum width for the intermediate sidebar.
*
* Specifies the minimum width (in points) for the supplementary column in the SplitView layout, typically representing the intermediate sidebar.
*/
minimumSupplementaryColumnWidth?: number;
/**
* @summary Maximum width for the intermediate sidebar.
*
* Specifies the maximum width (in points) for the supplementary column in the SplitView layout, typically representing the intermediate sidebar.
*/
maximumSupplementaryColumnWidth?: number;
/**
* @summary Preferred width for the intermediate sidebar.
*
* Specifies the preferred width (in points or as a fraction for percentage width support) for the supplementary column in the SplitView layout, typically representing the intermediate sidebar.
*/
preferredSupplementaryColumnWidthOrFraction?: number;
/**
* @summary Minimum width for the secondary component.
*
* Specifies the minimum width (in points) for the secondary column in the SplitView layout, typically for the view with the main content.
*
* @supported iOS 26 or higher
*/
minimumSecondaryColumnWidth?: number;
/**
* @summary Preferred width for the secondary component.
*
* Specifies the preferred width (in points or as a fraction for percentage width support) for the secondary column in the SplitView layout, typically for the view with the main content.
*
* @supported iOS 26 or higher
*/
preferredSecondaryColumnWidthOrFraction?: number;
/**
* @summary Minimum width for the inspector component.
*
* Specifies the minimum width (in points) for the inspector column in the SplitView layout, typically the view which is providing additional data about the secondary column.
*
* @supported iOS 26 or higher
*/
minimumInspectorColumnWidth?: number;
/**
* @summary Maximum width for the inspector component.
*
* Specifies the maximum width (in points) for the inspector column in the SplitView layout, typically the view which is providing additional data about the secondary column.
*
* @supported iOS 26 or higher
*/
maximumInspectorColumnWidth?: number;
/**
* @summary Preferred width for the inspector component.
*
* Specifies the preferred width (in points or as a fraction for percentage width support) for the inspector column in the SplitView layout, typically the view which is providing additional data about the secondary column.
*
* @supported iOS 26 or higher
*/
preferredInspectorColumnWidthOrFraction?: number;
}
export interface SplitViewHostProps extends ViewProps {
children?: React.ReactNode;
/**
* @summary An object describing bounds for column widths.
*
* It supports definitions for the following columns:
*
* - `primary` - the leftmost sidebar
* - `supplementary` - the intermediate sidebar
*
* On iOS 26 or higher, it also supports definitions for:
*
* - `secondary` - the view with the main content
* - `inspector` - the view which is providing additional data about the secondary column
*/
columnMetrics?: SplitViewColumnMetrics;
/**
* @summary Determines whether the button for changing the SplitView display mode is visible on the screen.
*
* The following values are currently supported:
*
* - `automatic` - the visibility of the display mode button is set by system
* - `always` the display mode button is always visible
* - `never` the display mode button is always hidden
*
* The supported values corresponds to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uisplitviewcontroller/displaymodebuttonvisibility-swift.enum|UISplitViewController.DisplayModeButtonVisibility}
*
* @default automatic
*/
displayModeButtonVisibility?: SplitViewDisplayModeButtonVisibility;
/**
* @summary A callback that gets invoked when the SplitView was collapsed to a single column.
*/
onCollapse?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary A callback that gets invoked when the SplitView displayMode has changed.
*
* The purpose of this callback is tracking displayMode updates on host from the JS side.
* These updates might be a consequence of some native interactions, like pressing native button or performing swipe gesture.
*/
onDisplayModeWillChange?: (
e: NativeSyntheticEvent<DisplayModeWillChangeEvent>,
) => void;
/**
* @summary A callback that gets invoked when the SplitView was expanded to multiple columns.
*/
onExpand?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary A callback that gets invoked when the SplitView inspector is either programmatically hidden (in column presentation) or dismissed (in modal presentation).
*
* The purpose of this callback depends on whether the SplitView is collapsed or expanded.
*
* @supported iOS 26 or higher
*/
onInspectorHide?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary Specifies supported orientations for the tab screen.
*
* Procedure for determining supported orientations:
* 1. Traversal initiates from the root component and moves to the
* deepest child possible.
* 2. Components are queried for their supported orientations:
* - if `orientation` is explicitly set (e.g., `portrait`,
* `landscape`), it is immediately used,
* - if `orientation` is set to `inherit`, the parent component
* is queried.
*
* The following values are currently supported:
*
* - `inherit` - tab screen supports the same orientations as parent
* component,
* - `all` - tab screen supports all orientations,
* - `allButUpsideDown` - tab screen supports all but the upside-down
* portrait interface orientation,
* - `portrait` - tab screen supports both portrait-up and portrait-down
* interface orientations,
* - 'portraitUp' - tab screen supports a portrait-up interface
* orientation,
* - `portraitDown` - tab screen supports a portrait-down interface
* orientation,
* interface orientation,
* - `landscape` - tab screen supports both landscape-left and
* landscape-right interface orientations,
* - `landscapeLeft` - tab screen supports landscape-left interface
* orientaion,
* - `landscapeRight` - tab screen supports landscape-right interface
* orientaion.
*
* The supported values (apart from `inherit`) correspond to the official
* UIKit documentation:
*
* @see {@link https://developer.apple.com/documentation/uikit/uiinterfaceorientationmask|UIInterfaceOrientationMask}
*
* @default inherit
*
* @platform ios
*/
orientation?: SplitViewHostOrientation;
/**
* @summary Determines whether gestures are enabled to change the display mode.
*/
presentsWithGesture?: boolean;
/**
* @summary Specifies the display mode which will be preferred to use, if the layout requirements are met.
*
* Preferred means that we may only suggest the OS which layout we're expecting, but the final decision is dependent on the device's type and size class.
*
* The following values are currently supported:
*
* - `automatic` - display mode is chosen by the OS, the appropriate display mode is based on the device and the current app size
* - `secondaryOnly` only the secondary column is displayed
* - `oneBesideSecondary` a sidebar is displayed side-by-side with the secondary column
* - `twoBesideSecondary` two sidebars are displayed side-by-side with the secondary column
* - `oneOverSecondary` a one sidebar is displayed over the secondary column
* - `twoOverSecondary` two sidebars are displayed over the secondary column
* - `twoDisplaceSecondary` two sidebars are displacind the secondary column, moving it partially offscreen
*
* The supported values corresponds to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uisplitviewcontroller/displaymode-swift.enum|UISplitViewController.DisplayMode}
*
* @default automatic
*/
preferredDisplayMode?: SplitViewDisplayMode;
/**
* @summary Specifies the split behavior which will be preferred to use, if the layout requirements are met.
*
* Preferred means that we may only suggest the OS which layout we're expecting, but the final decision is dependent on the device's type and size class.
*
* The following values are currently supported:
*
* - `automatic` - chosen by the OS, the appropriate split behavior is based on the device and the current app size
* - `displace` the main column is moved partially offscreen, making a space for sidebars
* - `overlay` the sidebars are partially covering main column
* - `tile` the sidebars appears side-by-side with the main column
*
* The supported values corresponds to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uisplitviewcontroller/splitbehavior-swift.enum|UISplitViewController.SplitBehavior}
*
* @default automatic
*/
preferredSplitBehavior?: SplitViewSplitBehavior;
/**
* @summary Indicates on which side primary sidebar is placed, affecting the split view layout.
*
* The following values are currently supported:
*
* - `leading` - primary sidebar is placed on the leading edge of the interface
* - `trailing` - primary sidebar is placed on the trailing edge of the interface
*
* The supported values corresponds to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uisplitviewcontroller/primaryedge-swift.enum|UISplitViewController.PrimaryEdge}
*
* @default leading
*/
primaryEdge?: SplitViewPrimaryEdge;
/**
* @summary Determines whether inspector column should be displayed.
*
* Inspector will be displayed on the trailing edge of the main (secondary) column (for expanded SplitView) or as a modal (for collapsed SplitView).
* The result on the interface for this prop depends on whether the SplitView is collapsed or expanded.
*
* @supported iOS 26 or higher
*/
showInspector?: boolean;
/**
* @summary Determines whether a button to toggle to and from secondaryOnly display mode is visible.
*/
showSecondaryToggleButton?: boolean;
}
@@ -0,0 +1,5 @@
import { View } from 'react-native';
const SplitViewHost = View;
export default SplitViewHost;
@@ -0,0 +1,43 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import SplitViewScreenNativeComponent from '../../fabric/gamma/SplitViewScreenNativeComponent';
import { SplitViewScreenProps } from './SplitViewScreen.types';
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function Column(props: SplitViewScreenProps) {
return (
<SplitViewScreenNativeComponent
columnType="column"
{...props}
style={StyleSheet.absoluteFill}>
{props.children}
</SplitViewScreenNativeComponent>
);
}
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function Inspector(props: SplitViewScreenProps) {
return (
<SplitViewScreenNativeComponent
columnType="inspector"
{...props}
style={StyleSheet.absoluteFill}>
{props.children}
</SplitViewScreenNativeComponent>
);
}
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
// TODO: refactor to drop `Screen` suffix as the API name is really long at the moment
const SplitViewScreen = {
Column,
Inspector,
};
export default SplitViewScreen;
@@ -0,0 +1,34 @@
import type { NativeSyntheticEvent, ViewProps } from 'react-native';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenericEmptyEvent = Readonly<{}>;
export type SplitViewScreenColumnType = 'column' | 'inspector';
export interface SplitViewScreenProps extends ViewProps {
children?: React.ReactNode;
/**
* @summary A callback that gets invoked when the current SplitViewScreen did appear.
*
* This is called as soon as the transition ends.
*/
onDidAppear?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary A callback that gets invoked when the current SplitViewScreen did disappear.
*
* This is called as soon as the transition ends.
*/
onDidDisappear?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary A callback that gets invoked when the current SplitViewScreen will appear.
*
* This is called as soon as the transition begins.
*/
onWillAppear?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
/**
* @summary A callback that gets invoked when the current SplitViewScreen will disappear.
*
* This is called as soon as the transition begins.
*/
onWillDisappear?: (e: NativeSyntheticEvent<GenericEmptyEvent>) => void;
}
@@ -0,0 +1,6 @@
import { View } from 'react-native';
const Column = View;
const Inspector = View;
export default { Column, Inspector };
@@ -0,0 +1,64 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import StackScreenNativeComponent from '../../fabric/gamma/StackScreenNativeComponent';
import type { NativeSyntheticEvent, ViewProps } from 'react-native';
import type { NativeProps } from '../../fabric/gamma/StackScreenNativeComponent';
export const StackScreenLifecycleState = {
INITIAL: 0,
DETACHED: 1,
ATTACHED: 2,
} as const;
export type StackScreenNativeProps = NativeProps & {
// Overrides
maxLifecycleState: (typeof StackScreenLifecycleState)[keyof typeof StackScreenLifecycleState];
};
type StackScreenProps = {
children?: ViewProps['children'];
// Custom events
onPop?: (screenKey: string) => void;
} & StackScreenNativeProps;
/**
* EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE
*/
function StackScreen({
children,
// Control
maxLifecycleState,
screenKey,
// Events
onWillAppear,
onWillDisappear,
onDidAppear,
onDidDisappear,
// Custom events
onPop,
}: StackScreenProps) {
const handleOnDidDisappear = React.useCallback(
(e: NativeSyntheticEvent<Record<string, never>>) => {
onDidDisappear?.(e);
onPop?.(screenKey);
},
[onDidDisappear, onPop, screenKey],
);
return (
<StackScreenNativeComponent
style={StyleSheet.absoluteFill}
// Control
maxLifecycleState={maxLifecycleState}
screenKey={screenKey}
// Events
onWillAppear={onWillAppear}
onDidAppear={onDidAppear}
onWillDisappear={onWillDisappear}
onDidDisappear={handleOnDidDisappear}>
{children}
</StackScreenNativeComponent>
);
}
export default StackScreen;
@@ -0,0 +1,18 @@
import { View, ViewProps } from 'react-native';
interface NativeProps extends ViewProps {}
export const StackScreenLifecycleState = {
INITIAL: 0,
DETACHED: 1,
ATTACHED: 2,
} as const;
export type StackScreenNativeProps = NativeProps & {
// Overrides
maxLifecycleState: (typeof StackScreenLifecycleState)[keyof typeof StackScreenLifecycleState];
};
const StackScreen = View;
export default StackScreen;
@@ -0,0 +1,27 @@
import React from 'react';
import { Freeze } from 'react-freeze';
interface FreezeWrapperProps {
freeze: boolean;
children: React.ReactNode;
}
// This component allows one more render before freezing the screen.
// Allows activityState to reach the native side and useIsFocused to work correctly.
function DelayedFreeze({ freeze, children }: FreezeWrapperProps) {
// flag used for determining whether freeze should be enabled
const [freezeState, setFreezeState] = React.useState(false);
React.useEffect(() => {
const id = setTimeout(() => {
setFreezeState(freeze);
}, 0);
return () => {
clearTimeout(id);
};
}, [freeze]);
return <Freeze freeze={freeze ? freezeState : false}>{children}</Freeze>;
}
export default DelayedFreeze;
@@ -0,0 +1,29 @@
import {
controlEdgeToEdgeValues,
isEdgeToEdge,
} from 'react-native-is-edge-to-edge';
import { ScreenProps } from '../../types';
export const EDGE_TO_EDGE = isEdgeToEdge();
export function transformEdgeToEdgeProps(props: ScreenProps): ScreenProps {
const {
// Filter out edge-to-edge related props
statusBarColor,
statusBarTranslucent,
navigationBarColor,
navigationBarTranslucent,
...rest
} = props;
if (__DEV__) {
controlEdgeToEdgeValues({
statusBarColor,
statusBarTranslucent,
navigationBarColor,
navigationBarTranslucent,
});
}
return rest;
}
@@ -0,0 +1,110 @@
import { Platform } from 'react-native';
import { ScreenProps } from '../../types';
// This value must be kept in sync with native side.
export const SHEET_FIT_TO_CONTENTS = [-1];
export const SHEET_COMPAT_LARGE = [1.0];
export const SHEET_COMPAT_MEDIUM = [0.5];
export const SHEET_COMPAT_ALL = [0.5, 1.0];
export const SHEET_DIMMED_ALWAYS = -1;
export function assertDetentsArrayIsSorted(array: number[]) {
for (let i = 1; i < array.length; i++) {
if (array[i - 1] > array[i]) {
throw new Error(
'[RNScreens] The detent array is not sorted in ascending order!',
);
}
}
}
// These exist to transform old 'legacy' values used by the formsheet API to the new API shape.
// We can get rid of it, once we get rid of support for legacy values: 'large', 'medium', 'all'.
export function resolveSheetAllowedDetents(
allowedDetentsCompat: ScreenProps['sheetAllowedDetents'],
): number[] {
if (Array.isArray(allowedDetentsCompat)) {
if (Platform.OS === 'android' && allowedDetentsCompat.length > 3) {
if (__DEV__) {
console.warn(
'[RNScreens] Sheets API on Android do accept only up to 3 values. Any surplus value are ignored.',
);
}
allowedDetentsCompat = allowedDetentsCompat.slice(0, 3);
}
if (__DEV__) {
assertDetentsArrayIsSorted(allowedDetentsCompat);
}
return allowedDetentsCompat;
} else if (allowedDetentsCompat === 'fitToContents') {
return SHEET_FIT_TO_CONTENTS;
} else if (allowedDetentsCompat === 'large') {
return SHEET_COMPAT_LARGE;
} else if (allowedDetentsCompat === 'medium') {
return SHEET_COMPAT_MEDIUM;
} else if (allowedDetentsCompat === 'all') {
return SHEET_COMPAT_ALL;
} else {
// Safe default, only large detent is allowed.
return SHEET_COMPAT_LARGE;
}
}
export function resolveSheetLargestUndimmedDetent(
lud: ScreenProps['sheetLargestUndimmedDetentIndex'],
lastDetentIndex: number,
): number {
if (typeof lud === 'number') {
if (!isIndexInClosedRange(lud, SHEET_DIMMED_ALWAYS, lastDetentIndex)) {
if (__DEV__) {
throw new Error(
"[RNScreens] Provided value of 'sheetLargestUndimmedDetentIndex' prop is out of bounds of 'sheetAllowedDetents' array.",
);
}
// Return default in production
return SHEET_DIMMED_ALWAYS;
}
return lud;
} else if (lud === 'last') {
return lastDetentIndex;
} else if (lud === 'none' || lud === 'all') {
return SHEET_DIMMED_ALWAYS;
} else if (lud === 'large') {
return 1;
} else if (lud === 'medium') {
return 0;
} else {
// Safe default, every detent is dimmed
return SHEET_DIMMED_ALWAYS;
}
}
export function resolveSheetInitialDetentIndex(
index: ScreenProps['sheetInitialDetentIndex'],
lastDetentIndex: number,
): number {
if (index === 'last') {
index = lastDetentIndex;
} else if (index == null) {
// Intentional check for undefined & null ^
index = 0;
}
if (!isIndexInClosedRange(index, 0, lastDetentIndex)) {
if (__DEV__) {
throw new Error(
"[RNScreens] Provided value of 'sheetInitialDetentIndex' prop is out of bounds of 'sheetAllowedDetents' array.",
);
}
// Return default in production
return 0;
}
return index;
}
function isIndexInClosedRange(
value: number,
lowerBound: number,
upperBound: number,
): boolean {
return Number.isInteger(value) && value >= lowerBound && value <= upperBound;
}
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export function usePrevious<T>(state: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = state;
});
return ref.current;
}
+9
View File
@@ -0,0 +1,9 @@
import React, { PropsWithChildren } from 'react';
import { GestureProviderProps, ScreensRefsHolder } from './types';
export const GHContext = React.createContext(
(props: PropsWithChildren<GestureProviderProps>) => <>{props.children}</>,
);
export const RNSScreensRefContext =
React.createContext<React.MutableRefObject<ScreensRefsHolder> | null>(null);
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { Platform, UIManager } from 'react-native';
export const isNativePlatformSupported =
Platform.OS === 'ios' ||
Platform.OS === 'android' ||
Platform.OS === 'windows';
let ENABLE_SCREENS = isNativePlatformSupported;
export function enableScreens(shouldEnableScreens = true) {
ENABLE_SCREENS = shouldEnableScreens;
if (!isNativePlatformSupported) {
return;
}
if (ENABLE_SCREENS && !UIManager.getViewManagerConfig('RNSScreen')) {
console.error(
`Screen native module hasn't been linked. Please check the react-native-screens README for more details`,
);
}
}
let ENABLE_FREEZE = false;
export function enableFreeze(shouldEnableReactFreeze = true) {
if (!isNativePlatformSupported) {
return;
}
ENABLE_FREEZE = shouldEnableReactFreeze;
}
export function screensEnabled() {
return ENABLE_SCREENS;
}
export function freezeEnabled() {
return ENABLE_FREEZE;
}
@@ -0,0 +1,15 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
import { WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
// Internal export, not part of stable library API.
export interface NativeProps extends ViewProps {
accessibilityContainerViewIsModal?: WithDefault<boolean, true>;
}
export default codegenNativeComponent<NativeProps>('RNSFullWindowOverlay', {
interfaceOnly: true,
});
@@ -0,0 +1,118 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps, ColorValue } from 'react-native';
import type {
DirectEventHandler,
WithDefault,
Int32,
Float,
Double,
} from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @typescript-eslint/ban-types
type ScreenEvent = Readonly<{}>;
type ScreenDismissedEvent = Readonly<{
dismissCount: Int32;
}>;
type TransitionProgressEvent = Readonly<{
progress: Double;
closing: Int32;
goingForward: Int32;
}>;
type HeaderHeightChangeEvent = Readonly<{
headerHeight: Double;
}>;
type SheetDetentChangedEvent = Readonly<{
index: Int32;
isStable: boolean;
}>;
type GestureResponseDistanceType = Readonly<{
start: Float;
end: Float;
top: Float;
bottom: Float;
}>;
type StackPresentation =
| 'push'
| 'modal'
| 'transparentModal'
| 'fullScreenModal'
| 'formSheet'
| 'pageSheet'
| 'containedModal'
| 'containedTransparentModal';
type StackAnimation =
| 'default'
| 'flip'
| 'simple_push'
| 'none'
| 'fade'
| 'slide_from_right'
| 'slide_from_left'
| 'slide_from_bottom'
| 'fade_from_bottom'
| 'ios_from_right'
| 'ios_from_left';
type SwipeDirection = 'vertical' | 'horizontal';
type ReplaceAnimation = 'pop' | 'push';
export interface NativeProps extends ViewProps {
onAppear?: DirectEventHandler<ScreenEvent>;
onDisappear?: DirectEventHandler<ScreenEvent>;
onDismissed?: DirectEventHandler<ScreenDismissedEvent>;
onNativeDismissCancelled?: DirectEventHandler<ScreenDismissedEvent>;
onWillAppear?: DirectEventHandler<ScreenEvent>;
onWillDisappear?: DirectEventHandler<ScreenEvent>;
onHeaderHeightChange?: DirectEventHandler<HeaderHeightChangeEvent>;
onTransitionProgress?: DirectEventHandler<TransitionProgressEvent>;
onGestureCancel?: DirectEventHandler<ScreenEvent>;
onHeaderBackButtonClicked?: DirectEventHandler<ScreenEvent>;
onSheetDetentChanged?: DirectEventHandler<SheetDetentChangedEvent>;
screenId?: WithDefault<string, ''>;
sheetAllowedDetents?: number[];
sheetLargestUndimmedDetent?: WithDefault<Int32, -1>;
sheetGrabberVisible?: WithDefault<boolean, false>;
sheetCornerRadius?: WithDefault<Float, -1.0>;
sheetExpandsWhenScrolledToEdge?: WithDefault<boolean, false>;
sheetInitialDetent?: WithDefault<Int32, 0>;
sheetElevation?: WithDefault<Int32, 24>;
customAnimationOnSwipe?: boolean;
fullScreenSwipeEnabled?: boolean;
fullScreenSwipeShadowEnabled?: WithDefault<boolean, true>;
homeIndicatorHidden?: boolean;
preventNativeDismiss?: boolean;
gestureEnabled?: WithDefault<boolean, true>;
statusBarColor?: ColorValue;
statusBarHidden?: boolean;
screenOrientation?: string;
statusBarAnimation?: string;
statusBarStyle?: string;
statusBarTranslucent?: boolean;
gestureResponseDistance?: GestureResponseDistanceType;
stackPresentation?: WithDefault<StackPresentation, 'push'>;
stackAnimation?: WithDefault<StackAnimation, 'default'>;
transitionDuration?: WithDefault<Int32, 500>;
replaceAnimation?: WithDefault<ReplaceAnimation, 'pop'>;
swipeDirection?: WithDefault<SwipeDirection, 'horizontal'>;
hideKeyboardOnSwipe?: boolean;
activityState?: WithDefault<Float, -1.0>;
navigationBarColor?: ColorValue;
navigationBarTranslucent?: boolean;
navigationBarHidden?: boolean;
nativeBackButtonDismissalEnabled?: boolean;
}
export default codegenNativeComponent<NativeProps>('RNSModalScreen', {
interfaceOnly: true,
});
@@ -0,0 +1,8 @@
'use client';
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {}
export default TurboModuleRegistry.get<Spec>('RNSModule');
@@ -0,0 +1 @@
export default {};
@@ -0,0 +1,9 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
interface NativeProps extends ViewProps {}
export default codegenNativeComponent<NativeProps>('RNSScreenContainer', {});
@@ -0,0 +1,10 @@
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
export interface NativeProps extends ViewProps {}
export default codegenNativeComponent<NativeProps>(
'RNSScreenContentWrapper',
{},
);
@@ -0,0 +1,7 @@
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
export interface NativeProps extends ViewProps {}
export default codegenNativeComponent<NativeProps>('RNSScreenFooter', {});
@@ -0,0 +1,118 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps, ColorValue } from 'react-native';
import type {
DirectEventHandler,
WithDefault,
Int32,
Float,
Double,
} from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @typescript-eslint/ban-types
type ScreenEvent = Readonly<{}>;
type ScreenDismissedEvent = Readonly<{
dismissCount: Int32;
}>;
type TransitionProgressEvent = Readonly<{
progress: Double;
closing: Int32;
goingForward: Int32;
}>;
type HeaderHeightChangeEvent = Readonly<{
headerHeight: Double;
}>;
type SheetDetentChangedEvent = Readonly<{
index: Int32;
isStable: boolean;
}>;
type GestureResponseDistanceType = Readonly<{
start: Float;
end: Float;
top: Float;
bottom: Float;
}>;
type StackPresentation =
| 'push'
| 'modal'
| 'transparentModal'
| 'fullScreenModal'
| 'formSheet'
| 'pageSheet'
| 'containedModal'
| 'containedTransparentModal';
type StackAnimation =
| 'default'
| 'flip'
| 'simple_push'
| 'none'
| 'fade'
| 'slide_from_right'
| 'slide_from_left'
| 'slide_from_bottom'
| 'fade_from_bottom'
| 'ios_from_right'
| 'ios_from_left';
type SwipeDirection = 'vertical' | 'horizontal';
type ReplaceAnimation = 'pop' | 'push';
export interface NativeProps extends ViewProps {
onAppear?: DirectEventHandler<ScreenEvent>;
onDisappear?: DirectEventHandler<ScreenEvent>;
onDismissed?: DirectEventHandler<ScreenDismissedEvent>;
onNativeDismissCancelled?: DirectEventHandler<ScreenDismissedEvent>;
onWillAppear?: DirectEventHandler<ScreenEvent>;
onWillDisappear?: DirectEventHandler<ScreenEvent>;
onHeaderHeightChange?: DirectEventHandler<HeaderHeightChangeEvent>;
onTransitionProgress?: DirectEventHandler<TransitionProgressEvent>;
onGestureCancel?: DirectEventHandler<ScreenEvent>;
onHeaderBackButtonClicked?: DirectEventHandler<ScreenEvent>;
onSheetDetentChanged?: DirectEventHandler<SheetDetentChangedEvent>;
screenId?: WithDefault<string, ''>;
sheetAllowedDetents?: number[];
sheetLargestUndimmedDetent?: WithDefault<Int32, -1>;
sheetGrabberVisible?: WithDefault<boolean, false>;
sheetCornerRadius?: WithDefault<Float, -1.0>;
sheetExpandsWhenScrolledToEdge?: WithDefault<boolean, false>;
sheetInitialDetent?: WithDefault<Int32, 0>;
sheetElevation?: WithDefault<Int32, 24>;
customAnimationOnSwipe?: boolean;
fullScreenSwipeEnabled?: boolean;
fullScreenSwipeShadowEnabled?: WithDefault<boolean, true>;
homeIndicatorHidden?: boolean;
preventNativeDismiss?: boolean;
gestureEnabled?: WithDefault<boolean, true>;
statusBarColor?: ColorValue;
statusBarHidden?: boolean;
screenOrientation?: string;
statusBarAnimation?: string;
statusBarStyle?: string;
statusBarTranslucent?: boolean;
gestureResponseDistance?: GestureResponseDistanceType;
stackPresentation?: WithDefault<StackPresentation, 'push'>;
stackAnimation?: WithDefault<StackAnimation, 'default'>;
transitionDuration?: WithDefault<Int32, 500>;
replaceAnimation?: WithDefault<ReplaceAnimation, 'pop'>;
swipeDirection?: WithDefault<SwipeDirection, 'horizontal'>;
hideKeyboardOnSwipe?: boolean;
activityState?: WithDefault<Float, -1.0>;
navigationBarColor?: ColorValue;
navigationBarTranslucent?: boolean;
navigationBarHidden?: boolean;
nativeBackButtonDismissalEnabled?: boolean;
}
export default codegenNativeComponent<NativeProps>('RNSScreen', {
interfaceOnly: true,
});
@@ -0,0 +1,12 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
interface NativeProps extends ViewProps {}
export default codegenNativeComponent<NativeProps>(
'RNSScreenNavigationContainer',
{},
);
@@ -0,0 +1,83 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps, ColorValue } from 'react-native';
import type {
Int32,
WithDefault,
DirectEventHandler,
} from 'react-native/Libraries/Types/CodegenTypes';
type DirectionType = 'rtl' | 'ltr';
// eslint-disable-next-line @typescript-eslint/ban-types
type OnAttachedEvent = Readonly<{}>;
// eslint-disable-next-line @typescript-eslint/ban-types
type OnDetachedEvent = Readonly<{}>;
type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
type BlurEffect =
| 'none'
| 'extraLight'
| 'light'
| 'dark'
| 'regular'
| 'prominent'
| 'systemUltraThinMaterial'
| 'systemThinMaterial'
| 'systemMaterial'
| 'systemThickMaterial'
| 'systemChromeMaterial'
| 'systemUltraThinMaterialLight'
| 'systemThinMaterialLight'
| 'systemMaterialLight'
| 'systemThickMaterialLight'
| 'systemChromeMaterialLight'
| 'systemUltraThinMaterialDark'
| 'systemThinMaterialDark'
| 'systemMaterialDark'
| 'systemThickMaterialDark'
| 'systemChromeMaterialDark';
export interface NativeProps extends ViewProps {
onAttached?: DirectEventHandler<OnAttachedEvent>;
onDetached?: DirectEventHandler<OnDetachedEvent>;
backgroundColor?: ColorValue;
backTitle?: string;
backTitleFontFamily?: string;
backTitleFontSize?: Int32;
backTitleVisible?: WithDefault<boolean, 'true'>;
color?: ColorValue;
direction?: WithDefault<DirectionType, 'ltr'>;
hidden?: boolean;
hideShadow?: boolean;
largeTitle?: boolean;
largeTitleFontFamily?: string;
largeTitleFontSize?: Int32;
largeTitleFontWeight?: string;
largeTitleBackgroundColor?: ColorValue;
largeTitleHideShadow?: boolean;
largeTitleColor?: ColorValue;
translucent?: boolean;
title?: string;
titleFontFamily?: string;
titleFontSize?: Int32;
titleFontWeight?: string;
titleColor?: ColorValue;
disableBackButtonMenu?: boolean;
backButtonDisplayMode?: WithDefault<BackButtonDisplayMode, 'default'>;
hideBackButton?: boolean;
backButtonInCustomView?: boolean;
blurEffect?: WithDefault<BlurEffect, 'none'>;
// TODO: implement this props on iOS
topInsetEnabled?: boolean;
}
export default codegenNativeComponent<NativeProps>(
'RNSScreenStackHeaderConfig',
{
interfaceOnly: true,
},
);
@@ -0,0 +1,25 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
import type { WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
export type HeaderSubviewTypes =
| 'back'
| 'right'
| 'left'
| 'title'
| 'center'
| 'searchBar';
export interface NativeProps extends ViewProps {
type?: WithDefault<HeaderSubviewTypes, 'left'>;
}
export default codegenNativeComponent<NativeProps>(
'RNSScreenStackHeaderSubview',
{
interfaceOnly: true,
},
);
@@ -0,0 +1,15 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @typescript-eslint/ban-types
type FinishTransitioningEvent = Readonly<{}>;
export interface NativeProps extends ViewProps {
onFinishTransitioning?: DirectEventHandler<FinishTransitioningEvent>;
}
export default codegenNativeComponent<NativeProps>('RNSScreenStack', {});
@@ -0,0 +1,87 @@
'use client';
/* eslint-disable */
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps, ColorValue, HostComponent } from 'react-native';
import type {
WithDefault,
DirectEventHandler,
} from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands';
export type SearchBarEvent = Readonly<{}>;
export type SearchButtonPressedEvent = Readonly<{
text?: string;
}>;
export type ChangeTextEvent = Readonly<{
text?: string;
}>;
type SearchBarPlacement =
| 'automatic'
| 'inline' // deprecated starting from iOS 26
| 'stacked'
| 'integrated'
| 'integratedButton'
| 'integratedCentered';
type AutoCapitalizeType = 'none' | 'words' | 'sentences' | 'characters';
export interface NativeProps extends ViewProps {
onSearchFocus?: DirectEventHandler<SearchBarEvent> | null;
onSearchBlur?: DirectEventHandler<SearchBarEvent> | null;
onSearchButtonPress?: DirectEventHandler<SearchButtonPressedEvent> | null;
onCancelButtonPress?: DirectEventHandler<SearchBarEvent> | null;
onChangeText?: DirectEventHandler<ChangeTextEvent> | null;
hideWhenScrolling?: WithDefault<boolean, true>;
autoCapitalize?: WithDefault<AutoCapitalizeType, 'none'>;
placeholder?: string;
placement?: WithDefault<SearchBarPlacement, 'automatic'>;
allowToolbarIntegration?: WithDefault<boolean, true>;
obscureBackground?: boolean;
hideNavigationBar?: boolean;
cancelButtonText?: string;
// TODO: implement these on iOS
barTintColor?: ColorValue;
tintColor?: ColorValue;
textColor?: ColorValue;
// Android only
disableBackButtonOverride?: boolean;
// TODO: consider creating enum here
inputType?: string;
onClose?: DirectEventHandler<SearchBarEvent> | null;
onOpen?: DirectEventHandler<SearchBarEvent> | null;
hintTextColor?: ColorValue;
headerIconColor?: ColorValue;
shouldShowHintSearchIcon?: WithDefault<boolean, true>;
}
type ComponentType = HostComponent<NativeProps>;
interface NativeCommands {
blur: (viewRef: React.ElementRef<ComponentType>) => void;
focus: (viewRef: React.ElementRef<ComponentType>) => void;
clearText: (viewRef: React.ElementRef<ComponentType>) => void;
toggleCancelButton: (
viewRef: React.ElementRef<ComponentType>,
flag: boolean,
) => void;
setText: (viewRef: React.ElementRef<ComponentType>, text: string) => void;
cancelSearch: (viewRef: React.ElementRef<ComponentType>) => void;
}
export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
supportedCommands: [
'blur',
'focus',
'clearText',
'toggleCancelButton',
'setText',
'cancelSearch',
],
});
export default codegenNativeComponent<NativeProps>('RNSSearchBar', {});
@@ -0,0 +1,73 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ColorValue, ViewProps } from 'react-native';
import type {
DirectEventHandler,
Float,
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
// TODO: Report issue on RN repo, that nesting color value inside a struct does not work.
// Generated code is ok, but the value is not passed down correctly - whatever color is set
// host component receives RGBA(0, 0, 0, 0) anyway.
// type TabBarAppearance = {
// backgroundColor?: ColorValue;
// };
type NativeFocusChangeEvent = {
tabKey: string;
};
type TabBarItemLabelVisibilityMode =
| 'auto'
| 'selected'
| 'labeled'
| 'unlabeled';
type TabBarMinimizeBehavior =
| 'automatic'
| 'never'
| 'onScrollDown'
| 'onScrollUp';
export interface NativeProps extends ViewProps {
// Events
onNativeFocusChange?: DirectEventHandler<NativeFocusChangeEvent>;
// Appearance
// tabBarAppearance?: TabBarAppearance; // Does not work due to codegen issue.
// Android-specific
tabBarBackgroundColor?: ColorValue;
tabBarItemTitleFontFamily?: string;
tabBarItemTitleFontSize?: Float;
tabBarItemTitleFontSizeActive?: Float;
tabBarItemTitleFontWeight?: string;
tabBarItemTitleFontStyle?: string;
tabBarItemTitleFontColor?: ColorValue;
tabBarItemTitleFontColorActive?: ColorValue;
tabBarItemIconColor?: ColorValue;
tabBarItemIconColorActive?: ColorValue;
tabBarItemActiveIndicatorColor?: ColorValue;
tabBarItemActiveIndicatorEnabled?: WithDefault<boolean, true>;
tabBarItemRippleColor?: ColorValue;
tabBarItemLabelVisibilityMode?: WithDefault<
TabBarItemLabelVisibilityMode,
'auto'
>;
// iOS-specific
tabBarTintColor?: ColorValue;
tabBarMinimizeBehavior?: WithDefault<TabBarMinimizeBehavior, 'automatic'>;
// Control
// Experimental support
controlNavigationStateInJS?: WithDefault<boolean, false>;
}
export default codegenNativeComponent<NativeProps>('RNSBottomTabs', {
interfaceOnly: true,
});
@@ -0,0 +1,161 @@
'use client';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type {
ColorValue,
ImageSource,
ProcessedColorValue,
ViewProps,
} from 'react-native';
import {
DirectEventHandler,
Float,
Int32,
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
import { UnsafeMixed } from './codegenUtils';
// iOS-specific: SFSymbol, image as a template usage
export type IconType = 'image' | 'template' | 'sfSymbol';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenericEmptyEvent = Readonly<{}>;
type LifecycleStateChangeEvent = Readonly<{
previousState: Int32;
newState: Int32;
}>;
export type ItemStateAppearance = {
tabBarItemTitleFontFamily?: string;
tabBarItemTitleFontSize?: Float;
tabBarItemTitleFontWeight?: string;
tabBarItemTitleFontStyle?: string;
tabBarItemTitleFontColor?: ProcessedColorValue | null;
tabBarItemTitlePositionAdjustment?: {
horizontal?: Float;
vertical?: Float;
};
tabBarItemIconColor?: ProcessedColorValue | null;
tabBarItemBadgeBackgroundColor?: ProcessedColorValue | null;
};
export type ItemAppearance = {
normal?: ItemStateAppearance;
selected?: ItemStateAppearance;
focused?: ItemStateAppearance;
disabled?: ItemStateAppearance;
};
export type Appearance = {
stacked?: ItemAppearance;
inline?: ItemAppearance;
compactInline?: ItemAppearance;
tabBarBackgroundColor?: ProcessedColorValue | null;
tabBarShadowColor?: ProcessedColorValue | null;
tabBarBlurEffect?: WithDefault<BlurEffect, 'systemDefault'>;
};
type BlurEffect =
| 'none'
| 'systemDefault'
| 'extraLight'
| 'light'
| 'dark'
| 'regular'
| 'prominent'
| 'systemUltraThinMaterial'
| 'systemThinMaterial'
| 'systemMaterial'
| 'systemThickMaterial'
| 'systemChromeMaterial'
| 'systemUltraThinMaterialLight'
| 'systemThinMaterialLight'
| 'systemMaterialLight'
| 'systemThickMaterialLight'
| 'systemChromeMaterialLight'
| 'systemUltraThinMaterialDark'
| 'systemThinMaterialDark'
| 'systemMaterialDark'
| 'systemThickMaterialDark'
| 'systemChromeMaterialDark';
type Orientation =
| 'inherit'
| 'all'
| 'allButUpsideDown'
| 'portrait'
| 'portraitUp'
| 'portraitDown'
| 'landscape'
| 'landscapeLeft'
| 'landscapeRight';
type SystemItem =
| 'none'
| 'bookmarks'
| 'contacts'
| 'downloads'
| 'favorites'
| 'featured'
| 'history'
| 'more'
| 'mostRecent'
| 'mostViewed'
| 'recents'
| 'search'
| 'topRated';
export interface NativeProps extends ViewProps {
// Events
onLifecycleStateChange?: DirectEventHandler<LifecycleStateChangeEvent>;
onWillAppear?: DirectEventHandler<GenericEmptyEvent>;
onDidAppear?: DirectEventHandler<GenericEmptyEvent>;
onWillDisappear?: DirectEventHandler<GenericEmptyEvent>;
onDidDisappear?: DirectEventHandler<GenericEmptyEvent>;
// Control
isFocused?: boolean;
tabKey: string;
// General
title?: string | undefined | null;
badgeValue?: string;
// Currently iOS-only
orientation?: WithDefault<Orientation, 'inherit'>;
// Android-specific image handling
iconResourceName?: string;
iconResource?: ImageSource;
tabBarItemBadgeTextColor?: ColorValue;
tabBarItemBadgeBackgroundColor?: ColorValue;
// iOS-specific
standardAppearance?: UnsafeMixed<Appearance>;
scrollEdgeAppearance?: UnsafeMixed<Appearance>;
iconType?: WithDefault<IconType, 'sfSymbol'>;
iconImageSource?: ImageSource;
iconSfSymbolName?: string;
selectedIconImageSource?: ImageSource;
selectedIconSfSymbolName?: string;
systemItem?: WithDefault<SystemItem, 'none'>;
specialEffects?: {
repeatedTabSelection?: {
popToRoot?: WithDefault<boolean, true>;
scrollToTop?: WithDefault<boolean, true>;
};
};
overrideScrollViewContentInsetAdjustmentBehavior?: WithDefault<boolean, true>;
}
export default codegenNativeComponent<NativeProps>('RNSBottomTabsScreen', {});
@@ -0,0 +1,6 @@
// copied from https://github.com/software-mansion/react-native-svg/blob/be06e84ec4809a8071f18f9824ffbe61424ee80d/src/fabric/codegenUtils.ts
// codegen will generate folly::dynamic in place of this type, but it's not exported by RN
// since codegen doesn't really follow imports, this way we can trick it into generating the correct type
// while keeping typescript happy
export type UnsafeMixed<T> = T;
@@ -0,0 +1,9 @@
'use client';
import type { ViewProps } from 'react-native';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
export interface NativeProps extends ViewProps {}
export default codegenNativeComponent<NativeProps>('RNSScreenStackHost', {});
@@ -0,0 +1,89 @@
'use client';
import type { ViewProps } from 'react-native';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type {
DirectEventHandler,
Float,
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenericEmptyEvent = Readonly<{}>;
type DisplayModeWillChangeEvent = {
currentDisplayMode: string;
nextDisplayMode: string;
};
type SplitViewDisplayModeButtonVisibility = 'always' | 'automatic' | 'never';
type SplitViewSplitBehavior = 'automatic' | 'displace' | 'overlay' | 'tile';
type SplitViewPrimaryEdge = 'leading' | 'trailing';
type SplitViewDisplayMode =
| 'automatic'
| 'secondaryOnly'
| 'oneBesideSecondary'
| 'oneOverSecondary'
| 'twoBesideSecondary'
| 'twoOverSecondary'
| 'twoDisplaceSecondary';
type SplitViewOrientation =
| 'inherit'
| 'all'
| 'allButUpsideDown'
| 'portrait'
| 'portraitUp'
| 'portraitDown'
| 'landscape'
| 'landscapeLeft'
| 'landscapeRight';
interface ColumnMetrics {
minimumPrimaryColumnWidth?: WithDefault<Float, -1.0>;
maximumPrimaryColumnWidth?: WithDefault<Float, -1.0>;
preferredPrimaryColumnWidthOrFraction?: WithDefault<Float, -1.0>;
minimumSupplementaryColumnWidth?: WithDefault<Float, -1.0>;
maximumSupplementaryColumnWidth?: WithDefault<Float, -1.0>;
preferredSupplementaryColumnWidthOrFraction?: WithDefault<Float, -1.0>;
// iOS 26 only
minimumSecondaryColumnWidth?: WithDefault<Float, -1.0>;
preferredSecondaryColumnWidthOrFraction?: WithDefault<Float, -1.0>;
minimumInspectorColumnWidth?: WithDefault<Float, -1.0>;
maximumInspectorColumnWidth?: WithDefault<Float, -1.0>;
preferredInspectorColumnWidthOrFraction?: WithDefault<Float, -1.0>;
}
interface NativeProps extends ViewProps {
// Appearance
preferredDisplayMode?: WithDefault<SplitViewDisplayMode, 'automatic'>;
preferredSplitBehavior?: WithDefault<SplitViewSplitBehavior, 'automatic'>;
primaryEdge?: WithDefault<SplitViewPrimaryEdge, 'leading'>;
showSecondaryToggleButton?: WithDefault<boolean, false>;
displayModeButtonVisibility?: WithDefault<
SplitViewDisplayModeButtonVisibility,
'automatic'
>;
columnMetrics?: ColumnMetrics;
orientation?: WithDefault<SplitViewOrientation, 'inherit'>;
// Interactions
presentsWithGesture?: WithDefault<boolean, true>;
showInspector?: WithDefault<boolean, false>;
// Custom events
onCollapse?: DirectEventHandler<GenericEmptyEvent>;
onDisplayModeWillChange?: DirectEventHandler<DisplayModeWillChangeEvent>;
onExpand?: DirectEventHandler<GenericEmptyEvent>;
onInspectorHide?: DirectEventHandler<GenericEmptyEvent>;
}
export default codegenNativeComponent<NativeProps>('RNSSplitViewHost', {});
@@ -0,0 +1,29 @@
'use client';
import type { ViewProps } from 'react-native';
import {
DirectEventHandler,
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
// eslint-disable-next-line @typescript-eslint/ban-types
type GenericEmptyEvent = Readonly<{}>;
type SplitViewScreenColumnType = 'column' | 'inspector';
interface NativeProps extends ViewProps {
// Config
columnType?: WithDefault<SplitViewScreenColumnType, 'column'>;
// Events
onWillAppear?: DirectEventHandler<GenericEmptyEvent>;
onDidAppear?: DirectEventHandler<GenericEmptyEvent>;
onWillDisappear?: DirectEventHandler<GenericEmptyEvent>;
onDidDisappear?: DirectEventHandler<GenericEmptyEvent>;
}
export default codegenNativeComponent<NativeProps>('RNSSplitViewScreen', {
interfaceOnly: true,
});
@@ -0,0 +1,26 @@
'use client';
import type { ViewProps } from 'react-native';
import {
DirectEventHandler,
Int32,
} from 'react-native/Libraries/Types/CodegenTypes';
// eslint-disable-next-line @react-native/no-deep-imports
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
// eslint-disable-next-line @typescript-eslint/ban-types
export type GenericEmptyEvent = Readonly<{}>;
export interface NativeProps extends ViewProps {
// Control
maxLifecycleState: Int32;
screenKey: string;
// Events
onWillAppear?: DirectEventHandler<GenericEmptyEvent>;
onDidAppear?: DirectEventHandler<GenericEmptyEvent>;
onWillDisappear?: DirectEventHandler<GenericEmptyEvent>;
onDidDisappear?: DirectEventHandler<GenericEmptyEvent>;
}
export default codegenNativeComponent<NativeProps>('RNSStackScreen', {});
+72
View File
@@ -0,0 +1,72 @@
const RNS_CONTROLLED_BOTTOM_TABS_DEFAULT = true;
// TODO: Migrate freeze here
/**
* Exposes information useful for downstream navigation library implementers,
* so they can keep reasonable backward compatibility, if desired.
*
* We don't mean for this object to only grow in number of fields, however at the same time
* we won't be very hasty to reduce it. Expect gradual changes.
*/
export const compatibilityFlags = {
/**
* Because of a bug introduced in https://github.com/software-mansion/react-native-screens/pull/1646
* react-native-screens v3.21 changed how header's backTitle handles whitespace strings in https://github.com/software-mansion/react-native-screens/pull/1726
* To allow for backwards compatibility in @react-navigation/native-stack we need a way to check if this version or newer is used.
* See https://github.com/react-navigation/react-navigation/pull/11423 for more context.
*/
isNewBackTitleImplementation: true,
/**
* With version 4.0.0 the header implementation has been changed. To allow for backward compat
* with native-stack@v6 we want to expose a way to check whether the new implementation
* is in use or not.
*
* See:
* * https://github.com/software-mansion/react-native-screens/pull/2325
* * https://github.com/react-navigation/react-navigation/pull/12125
*/
usesHeaderFlexboxImplementation: true,
} as const;
const _featureFlags = {
experiment: {
controlledBottomTabs: RNS_CONTROLLED_BOTTOM_TABS_DEFAULT,
},
stable: {},
};
/**
* Exposes configurable global behaviour of the library.
*
* Most of these can be overridden on particular component level, these are global switches.
*/
export const featureFlags = {
/**
* Flags to enable experimental features. These might be removed w/o notice or moved to stable.
*/
experiment: {
get controlledBottomTabs() {
return _featureFlags.experiment.controlledBottomTabs;
},
set controlledBottomTabs(value: boolean) {
if (
value !== _featureFlags.experiment.controlledBottomTabs &&
_featureFlags.experiment.controlledBottomTabs !==
RNS_CONTROLLED_BOTTOM_TABS_DEFAULT
) {
console.error(
`[RNScreens] controlledBottomTabs feature flag modified for a second time; this might lead to unexpected effects`,
);
}
_featureFlags.experiment.controlledBottomTabs = value;
},
},
/**
* Section for stable flags, which can be used to configure library behaviour.
*/
stable: {},
};
export default featureFlags;
@@ -0,0 +1,16 @@
import React from 'react';
import { GestureProviderProps } from '../types';
import { GHContext } from '../contexts';
import ScreenGestureDetector from './ScreenGestureDetector';
function GHWrapper(props: GestureProviderProps) {
return <ScreenGestureDetector {...props} />;
}
export default function GestureDetectorProvider(props: {
children: React.ReactNode;
}) {
return (
<GHContext.Provider value={GHWrapper}>{props.children}</GHContext.Provider>
);
}
@@ -0,0 +1,13 @@
type RNScreensTurboModuleType = {
startTransition: (stackTag: number) => {
topScreenTag: number;
belowTopScreenTag: number;
canStartTransition: boolean;
};
updateTransition: (stackTag: number, progress: number) => void;
finishTransition: (stackTag: number, isCanceled: boolean) => void;
disableSwipeBackForTopScreen: (stackTag: number) => void;
};
export const RNScreensTurboModule: RNScreensTurboModuleType = (global as any)
.RNScreensTurboModule;
@@ -0,0 +1,255 @@
import React, { useEffect } from 'react';
import { Dimensions, Platform, findNodeHandle } from 'react-native';
import {
GestureDetector,
Gesture,
PanGestureHandlerEventPayload,
GestureUpdateEvent,
} from 'react-native-gesture-handler';
import {
useSharedValue,
measure,
startScreenTransition,
finishScreenTransition,
makeMutable,
runOnUI,
} from 'react-native-reanimated';
import { getShadowNodeWrapperAndTagFromRef, isFabric } from './fabricUtils';
import { RNScreensTurboModule } from './RNScreensTurboModule';
import { DefaultEvent, DefaultScreenDimensions } from './defaults';
import {
checkBoundaries,
checkIfTransitionCancelled,
getAnimationForTransition,
} from './constraints';
import { GestureProviderProps } from '../types';
// The detector is disabled to work around issue with pressables
// losing focus. See https://github.com/software-mansion/react-native-screens/pull/2819
const EmptyGestureHandler = Gesture.Fling().enabled(false);
const ScreenGestureDetector = ({
children,
gestureDetectorBridge,
goBackGesture,
screenEdgeGesture,
transitionAnimation: customTransitionAnimation,
screensRefs,
currentScreenId,
}: GestureProviderProps) => {
const sharedEvent = useSharedValue(DefaultEvent);
const startingGesturePosition = useSharedValue(DefaultEvent);
const canPerformUpdates = makeMutable(false);
const transitionAnimation = getAnimationForTransition(
goBackGesture,
customTransitionAnimation,
);
const screenTransitionConfig = makeMutable({
stackTag: -1,
belowTopScreenId: -1,
topScreenId: -1,
sharedEvent,
startingGesturePosition,
screenTransition: transitionAnimation,
isTransitionCanceled: false,
goBackGesture: goBackGesture ?? 'swipeRight',
screenDimensions: DefaultScreenDimensions,
onFinishAnimation: () => {
'worklet';
},
});
const stackTag = makeMutable(-1);
const screenTagToNodeWrapperUI = makeMutable<Record<string, any>>({});
const IS_FABRIC = isFabric();
gestureDetectorBridge.current.stackUseEffectCallback = stackRef => {
if (!goBackGesture) {
return;
}
stackTag.value = findNodeHandle(stackRef.current as any) as number;
if (Platform.OS === 'ios') {
runOnUI(() => {
RNScreensTurboModule.disableSwipeBackForTopScreen(stackTag.value);
})();
}
};
useEffect(() => {
if (!IS_FABRIC || !goBackGesture || screensRefs === undefined) {
return;
}
const screenTagToNodeWrapper: Record<string, Record<string, unknown>> = {};
for (const key in screensRefs.current) {
const screenRef = screensRefs.current[key];
const screenData = getShadowNodeWrapperAndTagFromRef(screenRef.current);
if (screenData.tag && screenData.shadowNodeWrapper) {
screenTagToNodeWrapper[screenData.tag] = screenData.shadowNodeWrapper;
} else {
console.warn('[RNScreens] Failed to find tag for screen.');
}
}
screenTagToNodeWrapperUI.value = screenTagToNodeWrapper;
}, [currentScreenId, goBackGesture]);
function computeProgress(
event: GestureUpdateEvent<PanGestureHandlerEventPayload>,
) {
'worklet';
let progress = 0;
const screenDimensions = screenTransitionConfig.value.screenDimensions;
const startingPosition = startingGesturePosition.value;
if (goBackGesture === 'swipeRight') {
progress =
event.translationX /
(screenDimensions.width - startingPosition.absoluteX);
} else if (goBackGesture === 'swipeLeft') {
progress = (-1 * event.translationX) / startingPosition.absoluteX;
} else if (goBackGesture === 'swipeDown') {
progress =
(-1 * event.translationY) /
(screenDimensions.height - startingPosition.absoluteY);
} else if (goBackGesture === 'swipeUp') {
progress = event.translationY / startingPosition.absoluteY;
} else if (goBackGesture === 'horizontalSwipe') {
progress = Math.abs(event.translationX / screenDimensions.width / 2);
} else if (goBackGesture === 'verticalSwipe') {
progress = Math.abs(event.translationY / screenDimensions.height / 2);
} else if (goBackGesture === 'twoDimensionalSwipe') {
const progressX = Math.abs(
event.translationX / screenDimensions.width / 2,
);
const progressY = Math.abs(
event.translationY / screenDimensions.height / 2,
);
progress = Math.max(progressX, progressY);
}
return progress;
}
function onStart(event: GestureUpdateEvent<PanGestureHandlerEventPayload>) {
'worklet';
sharedEvent.value = event;
const transitionConfig = screenTransitionConfig.value;
const transitionData = RNScreensTurboModule.startTransition(stackTag.value);
if (transitionData.canStartTransition === false) {
canPerformUpdates.value = false;
return;
}
if (IS_FABRIC) {
transitionConfig.topScreenId =
screenTagToNodeWrapperUI.value[transitionData.topScreenTag];
transitionConfig.belowTopScreenId =
screenTagToNodeWrapperUI.value[transitionData.belowTopScreenTag];
} else {
transitionConfig.topScreenId = transitionData.topScreenTag;
transitionConfig.belowTopScreenId = transitionData.belowTopScreenTag;
}
transitionConfig.stackTag = stackTag.value;
startingGesturePosition.value = event;
const animatedRefMock = () => {
return screenTransitionConfig.value.topScreenId;
};
const screenSize = measure(animatedRefMock as any);
if (screenSize == null) {
throw new Error('[RNScreens] Failed to measure screen.');
}
if (screenSize == null) {
canPerformUpdates.value = false;
RNScreensTurboModule.finishTransition(stackTag.value, true);
return;
}
transitionConfig.screenDimensions = screenSize;
// Gesture Handler added `pointerType` to event payload back in 2.16.0,
// see: https://github.com/software-mansion/react-native-gesture-handler/pull/2760
// and this causes type errors here. Proper solution would be to patch parameter types
// of this function in reanimated. This should not cause runtime errors as the payload
// has correct shape, only the types are incorrect.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
startScreenTransition(transitionConfig as any);
canPerformUpdates.value = true;
}
function onUpdate(event: GestureUpdateEvent<PanGestureHandlerEventPayload>) {
'worklet';
if (!canPerformUpdates.value) {
return;
}
checkBoundaries(goBackGesture, event);
const progress = computeProgress(event);
sharedEvent.value = event;
const stackTag = screenTransitionConfig.value.stackTag;
RNScreensTurboModule.updateTransition(stackTag, progress);
}
function onEnd(event: GestureUpdateEvent<PanGestureHandlerEventPayload>) {
'worklet';
if (!canPerformUpdates.value) {
return;
}
const velocityFactor = 0.3;
const screenSize = screenTransitionConfig.value.screenDimensions;
const distanceX =
event.translationX + Math.min(event.velocityX * velocityFactor, 100);
const distanceY =
event.translationY + Math.min(event.velocityY * velocityFactor, 100);
const requiredXDistance = screenSize.width / 2;
const requiredYDistance = screenSize.height / 2;
const isTransitionCanceled = checkIfTransitionCancelled(
goBackGesture,
distanceX,
requiredXDistance,
distanceY,
requiredYDistance,
);
const stackTag = screenTransitionConfig.value.stackTag;
screenTransitionConfig.value.onFinishAnimation = () => {
RNScreensTurboModule.finishTransition(stackTag, isTransitionCanceled);
};
screenTransitionConfig.value.isTransitionCanceled = isTransitionCanceled;
// Gesture Handler added `pointerType` to event payload back in 2.16.0,
// see: https://github.com/software-mansion/react-native-gesture-handler/pull/2760
// and this causes type errors here. Proper solution would be to patch parameter types
// of this function in reanimated. This should not cause runtime errors as the payload
// has correct shape, only the types are incorrect.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
finishScreenTransition(screenTransitionConfig.value as any);
}
let panGesture = Gesture.Pan()
.onStart(onStart)
.onUpdate(onUpdate)
.onEnd(onEnd);
if (screenEdgeGesture) {
const HIT_SLOP_SIZE = 50;
const ACTIVATION_DISTANCE = 30;
if (goBackGesture === 'swipeRight') {
panGesture = panGesture
.activeOffsetX(ACTIVATION_DISTANCE)
.hitSlop({ left: 0, top: 0, width: HIT_SLOP_SIZE });
} else if (goBackGesture === 'swipeLeft') {
panGesture = panGesture
.activeOffsetX(-ACTIVATION_DISTANCE)
.hitSlop({ right: 0, top: 0, width: HIT_SLOP_SIZE });
} else if (goBackGesture === 'swipeDown') {
panGesture = panGesture
.activeOffsetY(ACTIVATION_DISTANCE)
.hitSlop({ top: 0, height: Dimensions.get('window').height * 0.2 });
// workaround, because we don't have access to header height
} else if (goBackGesture === 'swipeUp') {
panGesture = panGesture
.activeOffsetY(-ACTIVATION_DISTANCE)
.hitSlop({ bottom: 0, height: HIT_SLOP_SIZE });
}
}
return (
<GestureDetector gesture={goBackGesture ? panGesture : EmptyGestureHandler}>
{children}
</GestureDetector>
);
};
export default ScreenGestureDetector;
@@ -0,0 +1,87 @@
import { ScreenTransition } from 'react-native-reanimated';
import {
AnimatedScreenTransition,
GoBackGesture,
PanGestureHandlerEventPayload,
} from '../native-stack/types';
import { AnimationForGesture } from './defaults';
import { GestureUpdateEvent } from 'react-native-gesture-handler';
const SupportedGestures = [
'swipeRight',
'swipeLeft',
'swipeDown',
'swipeUp',
'horizontalSwipe',
'verticalSwipe',
'twoDimensionalSwipe',
];
export function getAnimationForTransition(
goBackGesture: GoBackGesture | undefined,
customTransitionAnimation: AnimatedScreenTransition | undefined,
) {
let transitionAnimation = ScreenTransition.SwipeRight;
if (customTransitionAnimation) {
transitionAnimation = customTransitionAnimation;
if (!goBackGesture) {
throw new Error(
'[RNScreens] You have to specify `goBackGesture` when using `transitionAnimation`.',
);
}
} else {
if (!!goBackGesture && SupportedGestures.includes(goBackGesture)) {
transitionAnimation = AnimationForGesture[goBackGesture];
} else if (goBackGesture !== undefined) {
throw new Error(
`[RNScreens] Unknown goBackGesture parameter has been specified: ${goBackGesture}.`,
);
}
}
return transitionAnimation;
}
export function checkBoundaries(
goBackGesture: string | undefined,
event: GestureUpdateEvent<PanGestureHandlerEventPayload>,
) {
'worklet';
if (goBackGesture === 'swipeRight' && event.translationX < 0) {
event.translationX = 0;
} else if (goBackGesture === 'swipeLeft' && event.translationX > 0) {
event.translationX = 0;
} else if (goBackGesture === 'swipeDown' && event.translationY < 0) {
event.translationY = 0;
} else if (goBackGesture === 'swipeUp' && event.translationY > 0) {
event.translationY = 0;
}
}
export function checkIfTransitionCancelled(
goBackGesture: string | undefined,
distanceX: number,
requiredXDistance: number,
distanceY: number,
requiredYDistance: number,
) {
'worklet';
let isTransitionCanceled = false;
if (goBackGesture === 'swipeRight') {
isTransitionCanceled = distanceX < requiredXDistance;
} else if (goBackGesture === 'swipeLeft') {
isTransitionCanceled = -distanceX < requiredXDistance;
} else if (goBackGesture === 'horizontalSwipe') {
isTransitionCanceled = Math.abs(distanceX) < requiredXDistance;
} else if (goBackGesture === 'swipeUp') {
isTransitionCanceled = -distanceY < requiredYDistance;
} else if (goBackGesture === 'swipeDown') {
isTransitionCanceled = distanceY < requiredYDistance;
} else if (goBackGesture === 'verticalSwipe') {
isTransitionCanceled = Math.abs(distanceY) < requiredYDistance;
} else if (goBackGesture === 'twoDimensionalSwipe') {
const isCanceledHorizontally = Math.abs(distanceX) < requiredXDistance;
const isCanceledVertically = Math.abs(distanceY) < requiredYDistance;
isTransitionCanceled = isCanceledHorizontally && isCanceledVertically;
}
return isTransitionCanceled;
}
@@ -0,0 +1,45 @@
import {
GestureUpdateEvent,
PanGestureHandlerEventPayload,
PointerType,
} from 'react-native-gesture-handler';
import { ScreenTransition } from 'react-native-reanimated';
export const DefaultEvent: GestureUpdateEvent<PanGestureHandlerEventPayload> = {
absoluteX: 0,
absoluteY: 0,
handlerTag: 0,
numberOfPointers: 0,
state: 0,
translationX: 0,
translationY: 0,
velocityX: 0,
velocityY: 0,
x: 0,
y: 0,
// These two were added in recent versions of gesture handler
// and they are required to specify. This should be backward
// compatible unless they strictly parse the objects, which seems
// not likely. PointerType is present since 2.16.0, StylusData since 2.20.0
pointerType: PointerType.TOUCH,
};
export const DefaultScreenDimensions = {
width: 0,
height: 0,
x: 0,
y: 0,
pageX: 0,
pageY: 0,
};
export const AnimationForGesture = {
swipeRight: ScreenTransition.SwipeRight,
swipeLeft: ScreenTransition.SwipeLeft,
swipeDown: ScreenTransition.SwipeDown,
swipeUp: ScreenTransition.SwipeUp,
horizontalSwipe: ScreenTransition.Horizontal,
verticalSwipe: ScreenTransition.Vertical,
twoDimensionalSwipe: ScreenTransition.TwoDimensional,
};
@@ -0,0 +1,86 @@
'use strict';
import { View } from 'react-native';
/* eslint-disable */
type LocalGlobal = typeof global & Record<string, unknown>;
export function isFabric() {
return !!(global as LocalGlobal).RN$Bridgeless;
}
export type ShadowNodeWrapper = {
__hostObjectShadowNodeWrapper: never;
};
let findHostInstance_DEPRECATED: (ref: unknown) => void;
let getInternalInstanceHandleFromPublicInstance: (ref: unknown) => {
stateNode: { node: unknown };
};
// Taken and modifies from reanimated
export function getShadowNodeWrapperAndTagFromRef(ref: View | null): {
shadowNodeWrapper: ShadowNodeWrapper;
tag: number;
} {
// load findHostInstance_DEPRECATED lazily because it may not be available before render
if (findHostInstance_DEPRECATED === undefined) {
try {
findHostInstance_DEPRECATED =
require('react-native/Libraries/Renderer/shims/ReactFabric').findHostInstance_DEPRECATED;
} catch (e) {
findHostInstance_DEPRECATED = (_ref: unknown) => null;
}
}
if (getInternalInstanceHandleFromPublicInstance === undefined) {
try {
getInternalInstanceHandleFromPublicInstance =
require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance')
.getInternalInstanceHandleFromPublicInstance ??
((_ref: any) => _ref._internalInstanceHandle);
} catch (e) {
getInternalInstanceHandleFromPublicInstance = (_ref: any) =>
_ref._internalInstanceHandle;
}
}
// taken from https://github.com/facebook/react-native/commit/803bb16531697233686efd475f004c1643e03617#diff-d8172256c6d63b5d32db10e54d7b10f37a26b337d5280d89f5bfd7bcea778292R196
// @ts-ignore some weird stuff on RN 0.74 - see examples with scrollView
const scrollViewRef = ref?.getScrollResponder?.()?.getNativeScrollRef?.();
// @ts-ignore some weird stuff on RN 0.74 - see examples with scrollView
const otherScrollViewRef = ref?.getNativeScrollRef?.();
// @ts-ignore some weird stuff on RN 0.74 - see setNativeProps example
const textInputRef = ref?.__internalInstanceHandle?.stateNode?.node;
let resolvedRef;
if (scrollViewRef) {
resolvedRef = {
shadowNodeWrapper: scrollViewRef.__internalInstanceHandle.stateNode.node,
tag: scrollViewRef._nativeTag,
};
} else if (otherScrollViewRef) {
resolvedRef = {
shadowNodeWrapper:
otherScrollViewRef.__internalInstanceHandle.stateNode.node,
tag: otherScrollViewRef.__nativeTag,
};
} else if (textInputRef) {
resolvedRef = {
shadowNodeWrapper: textInputRef,
tag: (ref as any)?.__nativeTag,
};
} else {
const hostInstance = findHostInstance_DEPRECATED(ref);
resolvedRef = {
shadowNodeWrapper:
getInternalInstanceHandleFromPublicInstance(hostInstance).stateNode
.node,
tag: (hostInstance as any)?._nativeTag,
};
}
return resolvedRef;
}
@@ -0,0 +1,10 @@
export function isFabric() {
return false;
}
export function getShadowNodeWrapperAndTagFromRef() {
return {
shadowNodeWrapper: undefined,
tag: undefined,
};
}
@@ -0,0 +1,4 @@
/*
* Providers
*/
export { default as GestureDetectorProvider } from './GestureDetectorProvider';
+73
View File
@@ -0,0 +1,73 @@
// Side effects import declaration to ensure our TurboModule
// is loaded.
import './fabric/NativeScreensModule';
export * from './types';
/**
* Core
*/
export {
enableScreens,
enableFreeze,
screensEnabled,
freezeEnabled,
} from './core';
/**
* RNS Components
*/
export {
default as Screen,
InnerScreen,
ScreenContext,
} from './components/Screen';
export {
ScreenStackHeaderConfig,
ScreenStackHeaderSubview,
ScreenStackHeaderLeftView,
ScreenStackHeaderCenterView,
ScreenStackHeaderRightView,
ScreenStackHeaderBackButtonImage,
ScreenStackHeaderSearchBarView,
} from './components/ScreenStackHeaderConfig';
export { default as SearchBar } from './components/SearchBar';
export { default as ScreenContainer } from './components/ScreenContainer';
export { default as ScreenStack } from './components/ScreenStack';
export { default as ScreenStackItem } from './components/ScreenStackItem';
export { default as FullWindowOverlay } from './components/FullWindowOverlay';
export { default as ScreenFooter } from './components/ScreenFooter';
export { default as ScreenContentWrapper } from './components/ScreenContentWrapper';
/**
* Utils
*/
export {
isSearchBarAvailableForCurrentPlatform,
executeNativeBackPress,
} from './utils';
/**
* Flags
*/
export { compatibilityFlags, featureFlags } from './flags';
/**
* Hooks
*/
export { default as useTransitionProgress } from './useTransitionProgress';
/**
* EXPERIMENTAL API BELOW. MIGHT CHANGE W/O ANY NOTICE
*/
export { default as BottomTabs } from './components/bottom-tabs/BottomTabs';
export { default as BottomTabsScreen } from './components/bottom-tabs/BottomTabsScreen';
export { default as ScreenStackHost } from './components/gamma/ScreenStackHost';
export {
default as StackScreen,
StackScreenLifecycleState,
} from './components/gamma/StackScreen';
export { default as SplitViewHost } from './components/gamma/SplitViewHost';
export { default as SplitViewScreen } from './components/gamma/SplitViewScreen';
@@ -0,0 +1,9 @@
'use client';
import React, { PropsWithChildren } from 'react';
import { GestureProviderProps } from '../types';
// context to be used when the user wants full screen swipe (see `gesture-handler` folder in repo)
export const GHContext = React.createContext(
(props: PropsWithChildren<GestureProviderProps>) => <>{props.children}</>,
);
@@ -0,0 +1,27 @@
/**
* Navigators
*/
export { default as createNativeStackNavigator } from './navigators/createNativeStackNavigator';
/**
* Views
*/
export { default as NativeStackView } from './views/NativeStackView';
/**
* Utilities
*/
export { default as useHeaderHeight } from './utils/useHeaderHeight';
export { default as HeaderHeightContext } from './utils/HeaderHeightContext';
export { default as useAnimatedHeaderHeight } from './utils/useAnimatedHeaderHeight';
export { default as AnimatedHeaderHeightContext } from './utils/AnimatedHeaderHeightContext';
/**
* Types
*/
export type {
NativeStackNavigationOptions,
NativeStackNavigationProp,
NativeStackScreenProps,
} from './types';
@@ -0,0 +1,96 @@
import {
createNavigatorFactory,
EventArg,
StackActions,
StackActionHelpers,
StackNavigationState,
StackRouter,
StackRouterOptions,
ParamListBase,
useNavigationBuilder,
} from '@react-navigation/native';
import * as React from 'react';
import {
NativeStackNavigationEventMap,
NativeStackNavigationOptions,
NativeStackNavigatorProps,
} from '../types';
import NativeStackView from '../views/NativeStackView';
function NativeStackNavigator({
initialRouteName,
children,
screenOptions,
...rest
}: NativeStackNavigatorProps) {
const { state, descriptors, navigation } = useNavigationBuilder<
StackNavigationState<ParamListBase>,
StackRouterOptions,
StackActionHelpers<ParamListBase>,
NativeStackNavigationOptions,
NativeStackNavigationEventMap
>(StackRouter, {
initialRouteName,
children,
screenOptions,
});
// Starting from React Navigation v6, `native-stack` should be imported from
// `@react-navigation/native-stack` rather than `react-native-screens/native-stack`
React.useEffect(() => {
// @ts-ignore navigation.dangerouslyGetParent was removed in v6
if (navigation?.dangerouslyGetParent === undefined) {
console.warn(
'Looks like you are importing `native-stack` from `react-native-screens/native-stack`. Since version 6 of `react-navigation`, it should be imported from `@react-navigation/native-stack`.',
);
}
}, [navigation]);
React.useEffect(
() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(navigation as typeof navigation & { addListener: any })?.addListener?.(
'tabPress',
(e: any) => {
const isFocused = navigation.isFocused();
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
if (
state.index > 0 &&
isFocused &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
// When user taps on already focused tab and we're inside the tab,
// reset the stack to replicate native behaviour
navigation.dispatch({
...StackActions.popToTop(),
target: state.key,
});
}
});
},
),
[navigation, state.index, state.key],
);
return (
<NativeStackView
{...rest}
state={state}
navigation={navigation}
descriptors={descriptors}
/>
);
}
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export default createNavigatorFactory<
StackNavigationState<ParamListBase>,
NativeStackNavigationOptions,
NativeStackNavigationEventMap,
typeof NativeStackNavigator
>(NativeStackNavigator);
@@ -0,0 +1,649 @@
import {
DefaultNavigatorOptions,
Descriptor,
NavigationHelpers,
NavigationProp,
ParamListBase,
StackNavigationState,
StackRouterOptions,
StackActionHelpers,
RouteProp,
} from '@react-navigation/native';
import * as React from 'react';
import { PropsWithChildren } from 'react';
import {
ImageSourcePropType,
StyleProp,
ViewStyle,
ColorValue,
} from 'react-native';
import {
GestureDetectorBridge,
ScreenProps,
ScreenStackHeaderConfigProps,
SearchBarProps,
} from '../types';
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigationEventMap = {
/**
* Event which fires when the screen appears.
*
* @deprecated Use `transitionEnd` event with `data.closing: false` instead.
*/
appear: { data: undefined };
/**
* Event which fires when the current screen is dismissed by hardware back (on Android) or dismiss gesture (swipe back or down).
*/
dismiss: { data: undefined };
/**
* Event which fires when a transition animation starts.
*/
transitionStart: { data: { closing: boolean } };
/**
* Event which fires when a transition animation ends.
*/
transitionEnd: { data: { closing: boolean } };
/**
* Event which fires when a swipe back is canceled on iOS.
*/
gestureCancel: { data: undefined };
/**
* Event which fires when a header height gets changed.
*/
headerHeightChange: { data: { headerHeight: number } };
/**
* Event which fires when screen is in sheet presentation & it's detent changes.
*
* In payload it caries two fields:
*
* * index - current detent index in the `sheetAllowedDetents` array,
* * isStable - on Android `false` value means that the user is dragging the sheet or it is settling; on iOS it is always `true`.
*/
sheetDetentChange: { data: { index: number; isStable: boolean } };
};
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = string,
> = NavigationProp<
ParamList,
RouteName,
StackNavigationState<ParamList>,
NativeStackNavigationOptions,
NativeStackNavigationEventMap
> &
StackActionHelpers<ParamList>;
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackScreenProps<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = string,
> = {
navigation: NativeStackNavigationProp<ParamList, RouteName>;
route: RouteProp<ParamList, RouteName>;
};
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigationHelpers = NavigationHelpers<
ParamListBase,
NativeStackNavigationEventMap
>;
/**
* We want it to be an empty object beacuse navigator does not have any additional config
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigationConfig = {}; // eslint-disable-line @typescript-eslint/ban-types
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigationOptions = {
/**
* Image to display in the header as the back button.
* Defaults to back icon image for the platform (a chevron on iOS and an arrow on Android).
*/
backButtonImage?: ImageSourcePropType;
/**
* Whether to show the back button with custom left side of the header.
*/
backButtonInCustomView?: boolean;
/**
* Style object for the scene content.
*
* As a workaround to truncated sheet content, formSheet uses backgroundColor from contentStyle and applies it on Screen.
*/
contentStyle?: StyleProp<ViewStyle>;
/**
* Boolean indicating that swipe dismissal should trigger animation provided by `stackAnimation`. Defaults to `false`.
*
* @platform ios
*/
customAnimationOnSwipe?: boolean;
/**
* Whether the stack should be in rtl or ltr form.
*/
direction?: 'rtl' | 'ltr';
/**
* Boolean indicating whether to show the menu on longPress of iOS >= 14 back button.
* @platform ios
*/
disableBackButtonMenu?: boolean;
/**
* How the back button behaves. It is used only when none of: `backTitleFontFamily`, `backTitleFontSize`, `disableBackButtonMenu` and `backTitleVisible=false` is set.
* The following values are currently supported (they correspond to [UINavigationItemBackButtonDisplayMode](https://developer.apple.com/documentation/uikit/uinavigationitembackbuttondisplaymode?language=objc)):
*
* - `default` show given back button previous controller title, system generic or just icon based on available space
* - `generic` show given system generic or just icon based on available space
* - `minimal` show just an icon
*
* @platform ios
*/
backButtonDisplayMode?: ScreenStackHeaderConfigProps['backButtonDisplayMode'];
/**
* Whether inactive screens should be suspended from re-rendering. Defaults to `false`.
* Defaults to `true` when `enableFreeze()` is run at the top of the application.
*/
freezeOnBlur?: boolean;
/**
* Boolean indicating whether the swipe gesture should work on whole screen. Swiping with this option results in the same transition animation as `simple_push` by default.
* It can be changed to other custom animations with `customAnimationOnSwipe` prop, but default iOS swipe animation is not achievable due to usage of custom recognizer.
* Defaults to `false`.
*
* @platform ios
*/
fullScreenSwipeEnabled?: boolean;
/**
* Whether the full screen dismiss gesture has shadow under view during transition. The gesture uses custom transition and thus
* doesn't have a shadow by default. When enabled, a custom shadow view is added during the transition which tries to mimic the
* default iOS shadow. Defaults to `true`.
*
* This does not affect the behavior of transitions that don't use gestures, enabled by `fullScreenGestureEnabled` prop.
*
* @platform ios
*/
fullScreenSwipeShadowEnabled?: boolean;
/**
* Whether you can use gestures to dismiss this screen. Defaults to `true`.
* Only supported on iOS.
*
* @platform ios
*/
gestureEnabled?: boolean;
/**
* Use it to restrict the distance from the edges of screen in which the gesture should be recognized. To be used alongside `fullScreenSwipeEnabled`.
*
* @platform ios
*/
gestureResponseDistance?: ScreenProps['gestureResponseDistance'];
/**
* Title to display in the back button.
* Only supported on iOS.
*
* @platform ios
*/
headerBackTitle?: string;
/**
* Style object for header back title. Supported properties:
* - fontFamily
* - fontSize
*
* Only supported on iOS.
*
* @platform ios
*/
headerBackTitleStyle?: {
fontFamily?: string;
fontSize?: number;
};
/**
* Whether the back button title should be visible or not. Defaults to `true`.
*
* When set to `false` it works as a "kill switch": it enforces `backButtonDisplayMode=minimal`, and ignores `backButtonDisplayMode`,
* `headerBackTitleStyle`, `disableBackButtonMenu`. For `headerBackTitle` it works only in back button menu.
*
* Only supported on iOS.
*
* @platform ios
*/
headerBackTitleVisible?: boolean;
/**
* Function which returns a React Element to display in the center of the header.
*/
headerCenter?: (props: { tintColor?: ColorValue }) => React.ReactNode;
/**
* Boolean indicating whether to hide the back button in header.
*/
headerHideBackButton?: boolean;
/**
* Boolean indicating whether to hide the elevation shadow or the bottom border on the header.
*/
headerHideShadow?: boolean;
/**
* Controls the style of the navigation header when the edge of any scrollable content reaches the matching edge of the navigation bar. Supported properties:
* - backgroundColor
*
* @platform ios
*/
headerLargeStyle?: {
backgroundColor?: ColorValue;
};
/**
* Boolean to set native property to prefer large title header (like in iOS setting).
* For large title to collapse on scroll, the content of the screen should be wrapped in a scrollable view such as `ScrollView` or `FlatList`.
* If the scrollable area doesn't fill the screen, the large title won't collapse on scroll.
* Only supported on iOS.
*
* @platform ios
*/
headerLargeTitle?: boolean;
/**
* Boolean that allows for disabling drop shadow under navigation header when the edge of any scrollable content reaches the matching edge of the navigation bar.
*/
headerLargeTitleHideShadow?: boolean;
/**
* Style object for header large title. Supported properties:
* - fontFamily
* - fontSize
* - color
*
* Only supported on iOS.
*
* @platform ios
*/
headerLargeTitleStyle?: {
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
color?: ColorValue;
};
/**
* Function which returns a React Element to display on the left side of the header.
*/
headerLeft?: (props: { tintColor?: ColorValue }) => React.ReactNode;
/**
* Function which returns a React Element to display on the right side of the header.
*/
headerRight?: (props: { tintColor?: ColorValue }) => React.ReactNode;
/**
* Whether to show the header.
*/
headerShown?: boolean;
/**
* Style object for header title. Supported properties:
* - backgroundColor
* - blurEffect
*/
headerStyle?: {
backgroundColor?: ColorValue;
blurEffect?: ScreenStackHeaderConfigProps['blurEffect'];
};
/**
* Tint color for the header. Changes the color of back button and title.
*/
headerTintColor?: ColorValue;
/**
* String to display in the header as title. Defaults to scene `title`.
*/
headerTitle?: string;
/**
* Style object for header title. Supported properties:
* - fontFamily
* - fontSize
* - fontWeight
* - color
*/
headerTitleStyle?: {
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
color?: ColorValue;
};
/**
* A flag to that lets you opt out of insetting the header. You may want to
* set this to `false` if you use an opaque status bar. Defaults to `true`.
* Only supported on Android. Insets are always applied on iOS because the
* header cannot be opaque.
*
* @platform android
*/
headerTopInsetEnabled?: boolean;
/**
* Boolean indicating whether the navigation bar is translucent.
*/
headerTranslucent?: boolean;
/**
* Whether the home indicator should be hidden on this screen. Defaults to `false`.
*
* @platform ios
*/
homeIndicatorHidden?: boolean;
/**
* Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`.
*
* @platform ios
*/
hideKeyboardOnSwipe?: boolean;
/**
* Boolean indicating whether, when the Android default back button is clicked, the `pop` action should be performed on the native side or on the JS side to be able to prevent it.
* Unfortunately the same behavior is not available on iOS since the behavior of native back button cannot be changed there.
* Defaults to `false`.
*
* @platform android
*/
nativeBackButtonDismissalEnabled?: boolean;
/**
* Sets the navigation bar color. Defaults to initial status bar color.
*
* @platform android
*/
navigationBarColor?: ColorValue;
/**
* Boolean indicating whether the content should be visible behind the navigation bar. Defaults to `false`.
*
* @platform android
*/
navigationBarTranslucent?: boolean;
/**
* Sets the visibility of the navigation bar. Defaults to `false`.
*
* @platform android
*/
navigationBarHidden?: boolean;
/**
* How should the screen replacing another screen animate. Defaults to `pop`.
* The following values are currently supported:
* - "push" the new screen will perform push animation.
* - "pop" the new screen will perform pop animation.
*/
replaceAnimation?: ScreenProps['replaceAnimation'];
/**
* In which orientation should the screen appear.
* The following values are currently supported:
* - "default" - resolves to "all" without "portrait_down" on iOS. On Android, this lets the system decide the best orientation.
* - "all" all orientations are permitted
* - "portrait" portrait orientations are permitted
* - "portrait_up" right-side portrait orientation is permitted
* - "portrait_down" upside-down portrait orientation is permitted
* - "landscape" landscape orientations are permitted
* - "landscape_left" landscape-left orientation is permitted
* - "landscape_right" landscape-right orientation is permitted
*/
screenOrientation?: ScreenProps['screenOrientation'];
/**
* Object in which you should pass props in order to render native iOS searchBar.
*/
searchBar?: SearchBarProps;
/**
* Describes heights where a sheet can rest.
* Works only when `stackPresentation` is set to `formSheet`.
*
* Heights should be described as fraction (a number from [0, 1] interval) of screen height / maximum detent height.
*
* Please note that the array **must** be sorted in ascending order.
*
* Defaults to `[1.0]` literal.
*/
sheetAllowedDetents?: ScreenProps['sheetAllowedDetents'] | 'fitToContents';
/**
* Integer value describing elevation of the sheet, impacting shadow on the top edge of the sheet.
*
* Not dynamic.
*
* Defaults to `24`.
*
* @platform Android
*/
sheetElevation?: ScreenProps['sheetElevation'];
/**
* Whether the sheet should expand to larger detent when scrolling.
* Works only when `stackPresentation` is set to `formSheet`.
* Defaults to `true`.
*
* @platform ios
*/
sheetExpandsWhenScrolledToEdge?: ScreenProps['sheetExpandsWhenScrolledToEdge'];
/**
* The corner radius that the sheet will try to render with.
* Works only when `stackPresentation` is set to `formSheet`.
*
* If set to non-negative value it will try to render sheet with provided radius, else it will apply system default.
*
* If left unset system default is used.
*/
sheetCornerRadius?: ScreenProps['sheetCornerRadius'];
/**
* Boolean indicating whether the sheet shows a grabber at the top.
* Works only when `stackPresentation` is set to `formSheet`.
* Defaults to `false`.
*
* @platform ios
*/
sheetGrabberVisible?: ScreenProps['sheetGrabberVisible'];
/**
* Index of the detent the sheet should expand to after being opened.
* Works only when `stackPresentation` is set to `formSheet`.
*
* Defaults to `0` - which represents first detent in the detents array.
*/
sheetInitialDetentIndex?: ScreenProps['sheetInitialDetentIndex'];
/**
* The largest sheet detent for which a view underneath won't be dimmed.
* Works only when `stackPresentation` is set to `formSheet`.
*
* This prop can be set to an number, which indicates index of detent in `sheetAllowedDetents` array for which
* there won't be a dimming view beneath the sheet.
*
* Additionaly there are following options available:
*
* * `none` - there will be dimming view for all detents levels,
* * `largest` - there won't be a dimming view for any detent level.
*
* There also legacy & **deprecated** prop values available: `medium`, `large` (don't confuse with `largest`), `all`, which work in tandem with
* corresponding legacy prop values for `sheetAllowedDetents` prop.
*
* Defaults to `none`, indicating that the dimming view should be always present.
*/
sheetLargestUndimmedDetentIndex?: ScreenProps['sheetLargestUndimmedDetentIndex'];
/**
* How the screen should appear/disappear when pushed or popped at the top of the stack.
* The following values are currently supported:
* - "default" uses a platform default animation
* - "fade" fades screen in or out
* - "fade_from_bottom" performs a fade from bottom animation
* - "flip" flips the screen, requires stackPresentation: "modal" (iOS only)
* - "simple_push" performs a default animation, but without native header transition (iOS only)
* - "slide_from_bottom" performs a slide from bottom animation
* - "slide_from_right" - slide in the new screen from right to left (Android only, resolves to default transition on iOS)
* - "slide_from_left" - slide in the new screen from left to right
* - "ios_from_right" - iOS like slide in animation. pushes in the new screen from right to left (Android only, resolves to default transition on iOS)
* - "ios_from_left" - iOS like slide in animation. pushes in the new screen from left to right (Android only, resolves to default transition on iOS)
* - "none" the screen appears/dissapears without an animation
*/
stackAnimation?: ScreenProps['stackAnimation'];
/**
* How should the screen be presented.
* The following values are currently supported:
* - "push" the new screen will be pushed onto a stack which on iOS means that the default animation will be slide from the side, the animation on Android may vary depending on the OS version and theme.
* - "modal" the new screen will be presented modally. In addition this allow for a nested stack to be rendered inside such screens.
* - "transparentModal" the new screen will be presented modally but in addition the second to last screen will remain attached to the stack container such that if the top screen is non opaque the content below can still be seen. If "modal" is used instead the below screen will get unmounted as soon as the transition ends.
* - "containedModal" will use "UIModalPresentationCurrentContext" modal style on iOS and will fallback to "modal" on Android.
* - "containedTransparentModal" will use "UIModalPresentationOverCurrentContext" modal style on iOS and will fallback to "transparentModal" on Android.
* - "fullScreenModal" will use "UIModalPresentationFullScreen" modal style on iOS and will fallback to "modal" on Android.
* - "formSheet" will use "UIModalPresentationFormSheet" modal style on iOS and will fallback to "modal" on Android.
* - "pageSheet" will use "UIModalPresentationPageSheet" modal style on iOS and will fallback to "modal" on Android.
*/
stackPresentation?: ScreenProps['stackPresentation'];
/**
* Sets the status bar animation (similar to the `StatusBar` component). Requires enabling (or deleting) `View controller-based status bar appearance` in your Info.plist file on iOS.
*/
statusBarAnimation?: ScreenProps['statusBarAnimation'];
/**
* Sets the status bar color (similar to the `StatusBar` component). Defaults to initial status bar color.
*
* @platform android
*/
statusBarColor?: ColorValue;
/**
* Whether the status bar should be hidden on this screen. Requires enabling (or deleting) `View controller-based status bar appearance` in your Info.plist file on iOS. Defaults to `false`.
*/
statusBarHidden?: boolean;
/**
* Sets the status bar color (similar to the `StatusBar` component). Requires enabling (or deleting) `View controller-based status bar appearance` in your Info.plist file on iOS. Defaults to `auto`.
*/
statusBarStyle?: ScreenProps['statusBarStyle'];
/**
* Sets the translucency of the status bar. Defaults to `false`.
*
* @platform android
*/
statusBarTranslucent?: boolean;
/**
* Sets the direction in which you should swipe to dismiss the screen.
* When using `vertical` option, options `fullScreenSwipeEnabled: true`, `customAnimationOnSwipe: true` and `stackAnimation: 'slide_from_bottom'` are set by default.
* The following values are supported:
* - `vertical` dismiss screen vertically
* - `horizontal` dismiss screen horizontally (default)
* @platform ios
*/
swipeDirection?: ScreenProps['swipeDirection'];
/**
* String that can be displayed in the header as a fallback for `headerTitle`.
*/
title?: string;
/**
* Changes the duration (in milliseconds) of `slide_from_bottom`, `fade_from_bottom`, `fade` and `simple_push` transitions on iOS. Defaults to `500`.
* The duration of `default` and `flip` transitions isn't customizable.
*
* @platform ios
*/
transitionDuration?: number;
goBackGesture?: GoBackGesture;
transitionAnimation?: AnimatedScreenTransition;
screenEdgeGesture?: boolean;
/**
* Footer component that can be used alongside form sheet stack presentation style.
*
* This option is provided, because due to implementation details it might be problematic
* to implement such layout with JS-only code.
*
* Please note that this prop is marked as unstable and might be subject of breaking changes,
* even removal.
*
* @platform android
*/
unstable_sheetFooter?: () => React.ReactNode;
};
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackNavigatorProps =
DefaultNavigatorOptions<NativeStackNavigationOptions> &
StackRouterOptions &
NativeStackNavigationConfig;
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackDescriptor = Descriptor<
ParamListBase,
string,
StackNavigationState<ParamListBase>,
NativeStackNavigationOptions
>;
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type NativeStackDescriptorMap = {
[key: string]: NativeStackDescriptor;
};
/**
* Those below copied to src/types.ts should be removed with next minor and native-stack v5 removal
*/
/**
* copy from GestureHandler to avoid strong dependency
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type PanGestureHandlerEventPayload = {
x: number;
y: number;
absoluteX: number;
absoluteY: number;
translationX: number;
translationY: number;
velocityX: number;
velocityY: number;
};
/**
* copy from Reanimated to avoid strong dependency
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type GoBackGesture =
| 'swipeRight'
| 'swipeLeft'
| 'swipeUp'
| 'swipeDown'
| 'verticalSwipe'
| 'horizontalSwipe'
| 'twoDimensionalSwipe';
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export interface MeasuredDimensions {
x: number;
y: number;
width: number;
height: number;
pageX: number;
pageY: number;
}
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type AnimatedScreenTransition = {
topScreenStyle: (
event: PanGestureHandlerEventPayload,
screenSize: MeasuredDimensions,
) => Record<string, unknown>;
belowTopScreenStyle: (
event: PanGestureHandlerEventPayload,
screenSize: MeasuredDimensions,
) => Record<string, unknown>;
};
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type ScreensRefsHolder = React.MutableRefObject<
Record<string, React.MutableRefObject<React.Ref<React.Component>>>
>;
/**
* @deprecated NativeStack has been moved from react-native-screens/native-stack to @react-navigation/native since version v6. With react-native-screens v4 native stack v5 (react-native-screens/native-stack) is deprecated and marked for removal in the upcoming minor release, react-native-screens v4 will support only @react-navigation/native-stack v7.
*/
export type GestureProviderProps = PropsWithChildren<{
gestureDetectorBridge: React.MutableRefObject<GestureDetectorBridge>;
screensRefs: ScreensRefsHolder;
currentRouteKey: string;
goBackGesture: GoBackGesture | undefined;
transitionAnimation: AnimatedScreenTransition | undefined;
screenEdgeGesture: boolean | undefined;
}>;
@@ -0,0 +1,8 @@
import * as React from 'react';
import { Animated } from 'react-native';
const AnimatedHeaderHeightContext = React.createContext<
Animated.Value | undefined
>(undefined);
export default AnimatedHeaderHeightContext;
@@ -0,0 +1,5 @@
import * as React from 'react';
const HeaderHeightContext = React.createContext<number | undefined>(undefined);
export default HeaderHeightContext;
@@ -0,0 +1,63 @@
// code taken from
// https://github.com/react-navigation/react-navigation/blob/ec0d113eb25c39ef9defb6c7215640f44e3569ae/packages/elements/src/SafeAreaProviderCompat.tsx
import * as React from 'react';
import {
Dimensions,
Platform,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native';
import {
initialWindowMetrics,
SafeAreaInsetsContext,
SafeAreaProvider,
} from 'react-native-safe-area-context';
type Props = {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
const { width = 0, height = 0 } = Dimensions.get('window');
// To support SSR on web, we need to have empty insets for initial values
// Otherwise there can be mismatch between SSR and client output
// We also need to specify empty values to support tests environments
const initialMetrics =
Platform.OS === 'web' || initialWindowMetrics == null
? {
frame: { x: 0, y: 0, width, height },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
}
: initialWindowMetrics;
export default function SafeAreaProviderCompat({ children, style }: Props) {
return (
<SafeAreaInsetsContext.Consumer>
{insets => {
if (insets) {
// If we already have insets, don't wrap the stack in another safe area provider
// This avoids an issue with updates at the cost of potentially incorrect values
// https://github.com/react-navigation/react-navigation/issues/174
return <View style={[styles.container, style]}>{children}</View>;
}
return (
<SafeAreaProvider initialMetrics={initialMetrics} style={style}>
{children}
</SafeAreaProvider>
);
}}
</SafeAreaInsetsContext.Consumer>
);
}
SafeAreaProviderCompat.initialMetrics = initialMetrics;
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
@@ -0,0 +1,43 @@
import { Platform } from 'react-native';
import { StackPresentationTypes } from '../../types';
type Layout = { width: number; height: number };
const formSheetModalHeight = 56;
export default function getDefaultHeaderHeight(
layout: Layout,
statusBarHeight: number,
stackPresentation: StackPresentationTypes,
isLargeHeader = false,
): number {
// default header heights
let headerHeight = Platform.OS === 'android' ? 56 : 64;
if (Platform.OS === 'ios') {
const isLandscape = layout.width > layout.height;
const isFormSheetModal =
stackPresentation === 'modal' ||
stackPresentation === 'formSheet' ||
stackPresentation === 'pageSheet';
if (isFormSheetModal && !isLandscape) {
// `modal`, `formSheet` and `pageSheet` presentations do not take whole screen, so should not take the inset.
statusBarHeight = 0;
}
if (Platform.isPad || Platform.isTV) {
headerHeight = isFormSheetModal ? formSheetModalHeight : 50;
} else {
if (isLandscape) {
headerHeight = 32;
} else {
if (isFormSheetModal) {
headerHeight = formSheetModalHeight;
} else {
headerHeight = isLargeHeader ? 96 : 44;
}
}
}
}
return headerHeight + statusBarHeight;
}
@@ -0,0 +1,22 @@
import { Rect } from 'react-native-safe-area-context';
import { Platform } from 'react-native';
export default function getStatusBarHeight(
topInset: number,
dimensions: Rect,
isStatusBarTranslucent: boolean,
) {
if (Platform.OS === 'ios') {
// It looks like some iOS devices don't have strictly set status bar height to 44.
// Thus, if the top inset is higher than 50, then the device should have a dynamic island.
// On models with Dynamic Island the status bar height is smaller than the safe area top inset by 5 pixels.
// See https://developer.apple.com/forums/thread/662466 for more details about status bar height.
const hasDynamicIsland = topInset > 50;
return hasDynamicIsland ? topInset - 5 : topInset;
} else if (Platform.OS === 'android') {
// On Android we should also rely on frame's y-axis position, as topInset is 0 on visible status bar.
return isStatusBarTranslucent ? topInset : dimensions.y;
}
return topInset;
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import AnimatedHeaderHeightContext from './AnimatedHeaderHeightContext';
export default function useAnimatedHeaderHeight() {
const animatedValue = React.useContext(AnimatedHeaderHeightContext);
if (animatedValue === undefined) {
throw new Error(
"Couldn't find the header height. Are you inside a screen in a navigator with a header?",
);
}
return animatedValue;
}
@@ -0,0 +1,66 @@
import React from 'react';
import { BackHandler, NativeEventSubscription } from 'react-native';
interface Args {
onBackPress: () => boolean;
isDisabled: boolean;
}
interface UseBackPressSubscription {
handleAttached: () => void;
handleDetached: () => void;
createSubscription: () => void;
clearSubscription: () => void;
}
/**
* This hook is an abstraction for keeping back press subscription
* logic in one place.
*/
export function useBackPressSubscription({
onBackPress,
isDisabled,
}: Args): UseBackPressSubscription {
const [isActive, setIsActive] = React.useState(false);
const subscription = React.useRef<NativeEventSubscription | undefined>();
const clearSubscription = React.useCallback((shouldSetActive = true) => {
subscription.current?.remove();
subscription.current = undefined;
if (shouldSetActive) setIsActive(false);
}, []);
const createSubscription = React.useCallback(() => {
if (!isDisabled) {
subscription.current?.remove();
subscription.current = BackHandler.addEventListener(
'hardwareBackPress',
onBackPress,
);
setIsActive(true);
}
}, [isDisabled, onBackPress]);
const handleAttached = React.useCallback(() => {
if (isActive) {
createSubscription();
}
}, [createSubscription, isActive]);
const handleDetached = React.useCallback(() => {
clearSubscription(false);
}, [clearSubscription]);
React.useEffect(() => {
if (isDisabled) {
clearSubscription();
}
}, [isDisabled, clearSubscription]);
return {
handleAttached,
handleDetached,
createSubscription,
clearSubscription,
};
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import HeaderHeightContext from './HeaderHeightContext';
export default function useHeaderHeight() {
const height = React.useContext(HeaderHeightContext);
if (height === undefined) {
throw new Error(
"Couldn't find the header height. Are you inside a screen in a navigator with a header?",
);
}
return height;
}
@@ -0,0 +1,14 @@
// @ts-ignore: No declaration available
import ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes';
export function processFonts(
fontFamilies: (string | undefined)[],
): (string | undefined)[] {
// @ts-ignore: React Native types are incorrect here and don't consider fontFamily a style value
const fontFamilyProcessor = ReactNativeStyleAttributes.fontFamily?.process;
if (typeof fontFamilyProcessor === 'function') {
return fontFamilies.map(fontFamilyProcessor);
}
return fontFamilies;
}
@@ -0,0 +1,10 @@
import React from 'react';
import ScreenFooter from '../../components/ScreenFooter';
type FooterProps = {
children?: React.ReactNode;
};
export default function FooterComponent({ children }: FooterProps) {
return <ScreenFooter collapsable={false}>{children}</ScreenFooter>;
}
@@ -0,0 +1,188 @@
import { Route, useTheme } from '@react-navigation/native';
import * as React from 'react';
import { Platform } from 'react-native';
import { SearchBarProps } from '../../types';
import {
isSearchBarAvailableForCurrentPlatform,
executeNativeBackPress,
} from '../../utils';
import {
ScreenStackHeaderBackButtonImage,
ScreenStackHeaderCenterView,
ScreenStackHeaderConfig,
ScreenStackHeaderLeftView,
ScreenStackHeaderRightView,
ScreenStackHeaderSearchBarView,
} from '../../components/ScreenStackHeaderConfig';
import SearchBar from '../../components/SearchBar';
import { NativeStackNavigationOptions } from '../types';
import { useBackPressSubscription } from '../utils/useBackPressSubscription';
import { processFonts } from './FontProcessor';
import warnOnce from 'warn-once';
type Props = NativeStackNavigationOptions & {
route: Route<string>;
};
export default function HeaderConfig({
backButtonImage,
backButtonInCustomView,
direction,
disableBackButtonMenu,
backButtonDisplayMode = 'default',
headerBackTitle,
headerBackTitleStyle = {},
headerBackTitleVisible = true,
headerCenter,
headerHideBackButton,
headerHideShadow,
headerLargeStyle = {},
headerLargeTitle,
headerLargeTitleHideShadow,
headerLargeTitleStyle = {},
headerLeft,
headerRight,
headerShown,
headerStyle = {},
headerTintColor,
headerTitle,
headerTitleStyle = {},
headerTopInsetEnabled = true,
headerTranslucent,
route,
searchBar,
title,
}: Props): JSX.Element {
const { colors } = useTheme();
const tintColor = headerTintColor ?? colors.primary;
// We need to use back press subscription here to override back button behavior on JS side.
// Because screens are usually used with react-navigation and this library overrides back button
// we need to handle it first in case when search bar is open
const {
handleAttached,
handleDetached,
clearSubscription,
createSubscription,
} = useBackPressSubscription({
onBackPress: executeNativeBackPress,
isDisabled: !searchBar || !!searchBar.disableBackButtonOverride,
});
const [backTitleFontFamily, largeTitleFontFamily, titleFontFamily] =
processFonts([
headerBackTitleStyle.fontFamily,
headerLargeTitleStyle.fontFamily,
headerTitleStyle.fontFamily,
]);
// We want to clear clearSubscription only when components unmounts or search bar changes
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => clearSubscription, [searchBar]);
const processedSearchBarOptions = React.useMemo(() => {
if (
Platform.OS === 'android' &&
searchBar &&
!searchBar.disableBackButtonOverride
) {
const onFocus: SearchBarProps['onFocus'] = (...args) => {
createSubscription();
searchBar.onFocus?.(...args);
};
const onClose: SearchBarProps['onClose'] = (...args) => {
clearSubscription();
searchBar.onClose?.(...args);
};
return { ...searchBar, onFocus, onClose };
}
return searchBar;
}, [searchBar, createSubscription, clearSubscription]);
// @ts-ignore isVision is not yet in the type definitions (RN 0.74+)
const isVisionOS = Platform?.isVision;
warnOnce(
isVisionOS &&
(headerTitleStyle.color !== undefined || headerTintColor !== undefined),
'headerTitleStyle.color and headerTintColor are not supported on visionOS.',
);
return (
<ScreenStackHeaderConfig
backButtonInCustomView={backButtonInCustomView}
backgroundColor={
headerStyle.backgroundColor ? headerStyle.backgroundColor : colors.card
}
backTitle={headerBackTitle}
backTitleFontFamily={backTitleFontFamily}
backTitleFontSize={headerBackTitleStyle.fontSize}
backTitleVisible={headerBackTitleVisible}
blurEffect={headerStyle.blurEffect}
color={tintColor}
direction={direction}
disableBackButtonMenu={disableBackButtonMenu}
backButtonDisplayMode={backButtonDisplayMode}
hidden={headerShown === false}
hideBackButton={headerHideBackButton}
hideShadow={headerHideShadow}
largeTitle={headerLargeTitle}
largeTitleBackgroundColor={headerLargeStyle.backgroundColor}
largeTitleColor={headerLargeTitleStyle.color}
largeTitleFontFamily={largeTitleFontFamily}
largeTitleFontSize={headerLargeTitleStyle.fontSize}
largeTitleFontWeight={headerLargeTitleStyle.fontWeight}
largeTitleHideShadow={headerLargeTitleHideShadow}
title={
headerTitle !== undefined
? headerTitle
: title !== undefined
? title
: route.name
}
titleColor={
headerTitleStyle.color !== undefined
? headerTitleStyle.color
: headerTintColor !== undefined
? headerTintColor
: colors.text
}
titleFontFamily={titleFontFamily}
titleFontSize={headerTitleStyle.fontSize}
titleFontWeight={headerTitleStyle.fontWeight}
topInsetEnabled={headerTopInsetEnabled}
translucent={headerTranslucent === true}
onAttached={handleAttached}
onDetached={handleDetached}>
{headerRight !== undefined ? (
<ScreenStackHeaderRightView>
{headerRight({ tintColor })}
</ScreenStackHeaderRightView>
) : null}
{backButtonImage !== undefined ? (
<ScreenStackHeaderBackButtonImage
key="backImage"
source={backButtonImage}
/>
) : null}
{headerLeft !== undefined ? (
<ScreenStackHeaderLeftView>
{headerLeft({ tintColor })}
</ScreenStackHeaderLeftView>
) : null}
{headerCenter !== undefined ? (
<ScreenStackHeaderCenterView>
{headerCenter({ tintColor })}
</ScreenStackHeaderCenterView>
) : null}
{isSearchBarAvailableForCurrentPlatform &&
processedSearchBarOptions !== undefined ? (
<ScreenStackHeaderSearchBarView>
{/* @ts-ignore Skip incorrect error about incompatible ref types */}
<SearchBar {...processedSearchBarOptions} />
</ScreenStackHeaderSearchBarView>
) : null}
</ScreenStackHeaderConfig>
);
}
@@ -0,0 +1,534 @@
/* eslint-disable camelcase */
import * as React from 'react';
import {
Animated,
Platform,
StyleSheet,
ViewProps,
ViewStyle,
} from 'react-native';
// @ts-ignore Getting private component
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,
sheetAllowedDetents,
children,
internalScreenStyle,
}: {
options: NativeStackNavigationOptions;
route: Route<string>;
stackPresentation: StackPresentationTypes;
sheetAllowedDetents: NativeStackNavigationOptions['sheetAllowedDetents'];
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 formSheetAdjustedContentStyle =
stackPresentation === 'formSheet'
? Platform.OS === 'ios'
? styles.absoluteFillNoBottom
: sheetAllowedDetents === 'fitToContents'
? null
: styles.container
: styles.container;
const content = (
<Container
style={[
formSheetAdjustedContentStyle,
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
sheetAllowedDetents={sheetAllowedDetents}
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 = true,
gestureEnabled,
headerShown,
hideKeyboardOnSwipe,
homeIndicatorHidden,
sheetAllowedDetents = [1.0],
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 {
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 (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}
sheetAllowedDetents={sheetAllowedDetents}
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,
},
});
@@ -0,0 +1,4 @@
export {
internalEnableDetailedBottomTabsLogging,
bottomTabsDebugLog,
} from './logging';
@@ -0,0 +1,13 @@
let isDetailedLoggingEnabled = false;
export function bottomTabsDebugLog(
...args: Parameters<(typeof console)['log']>
) {
if (isDetailedLoggingEnabled) {
console.log(...args);
}
}
export function internalEnableDetailedBottomTabsLogging() {
isDetailedLoggingEnabled = true;
}
@@ -0,0 +1,7 @@
import * as React from 'react';
// @ts-ignore file to be used only if `react-native-reanimated` available in the project
import Animated from 'react-native-reanimated';
export default React.createContext<Animated.SharedValue<number> | undefined>(
undefined,
);
@@ -0,0 +1,118 @@
import React from 'react';
import { Platform } from 'react-native';
import { InnerScreen } from '../components/Screen';
import {
HeaderHeightChangeEventType,
ScreenProps,
TransitionProgressEventType,
} from '../types';
// @ts-ignore file to be used only if `react-native-reanimated` available in the project
import Animated, { useEvent, useSharedValue } from 'react-native-reanimated';
import ReanimatedTransitionProgressContext from './ReanimatedTransitionProgressContext';
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import getDefaultHeaderHeight from '../native-stack/utils/getDefaultHeaderHeight';
import getStatusBarHeight from '../native-stack/utils/getStatusBarHeight';
import ReanimatedHeaderHeightContext from './ReanimatedHeaderHeightContext';
const AnimatedScreen = Animated.createAnimatedComponent(
InnerScreen as unknown as React.ComponentClass,
);
// We use prop added to global by reanimated since it seems safer than the one from RN. See:
// https://github.com/software-mansion/react-native-reanimated/blob/3fe8b35b05e82b2f2aefda1fb97799cf81e4b7bb/src/reanimated2/UpdateProps.ts#L46
// @ts-expect-error nativeFabricUIManager is not yet included in the RN types
const ENABLE_FABRIC = !!global?.RN$Bridgeless;
const ReanimatedNativeStackScreen = React.forwardRef<
typeof AnimatedScreen,
ScreenProps
>((props, ref) => {
const { children, ...rest } = props;
const { stackPresentation = 'push', hasLargeHeader } = rest;
const dimensions = useSafeAreaFrame();
const topInset = useSafeAreaInsets().top;
const isStatusBarTranslucent = rest.statusBarTranslucent ?? false;
const statusBarHeight = getStatusBarHeight(
topInset,
dimensions,
isStatusBarTranslucent,
);
// Default header height, normally used in `useHeaderHeight` hook.
// Here, it is used for returning a default value for shared value.
const defaultHeaderHeight = getDefaultHeaderHeight(
dimensions,
statusBarHeight,
stackPresentation,
hasLargeHeader,
);
const cachedHeaderHeight = React.useRef(defaultHeaderHeight);
const headerHeight = useSharedValue(defaultHeaderHeight);
const progress = useSharedValue(0);
const closing = useSharedValue(0);
const goingForward = useSharedValue(0);
return (
<AnimatedScreen
// @ts-ignore some problems with ref and onTransitionProgressReanimated being "fake" prop for parsing of `useEvent` return value
ref={ref}
onTransitionProgressReanimated={useEvent(
(event: TransitionProgressEventType) => {
'worklet';
progress.value = event.progress;
closing.value = event.closing;
goingForward.value = event.goingForward;
},
[
// This should not be necessary, but is not properly managed by `react-native-reanimated`
// @ts-ignore wrong type
Platform.OS === 'android'
? 'onTransitionProgress'
: // for some reason there is a difference in required event name between architectures
ENABLE_FABRIC
? 'onTransitionProgress'
: 'topTransitionProgress',
],
)}
onHeaderHeightChangeReanimated={useEvent(
(event: HeaderHeightChangeEventType) => {
'worklet';
if (event.headerHeight !== cachedHeaderHeight.current) {
headerHeight.value = event.headerHeight;
cachedHeaderHeight.current = event.headerHeight;
}
},
[
// @ts-ignore wrong type
Platform.OS === 'android'
? 'onHeaderHeightChange'
: ENABLE_FABRIC
? 'onHeaderHeightChange'
: 'topHeaderHeightChange',
],
)}
{...rest}>
<ReanimatedHeaderHeightContext.Provider value={headerHeight}>
<ReanimatedTransitionProgressContext.Provider
value={{
progress,
closing,
goingForward,
}}>
{children}
</ReanimatedTransitionProgressContext.Provider>
</ReanimatedHeaderHeightContext.Provider>
</AnimatedScreen>
);
});
ReanimatedNativeStackScreen.displayName = 'ReanimatedNativeStackScreen';
export default ReanimatedNativeStackScreen;
@@ -0,0 +1,26 @@
import React from 'react';
import { InnerScreen } from '../components/Screen';
import { ScreenProps } from '../types';
// @ts-ignore file to be used only if `react-native-reanimated` available in the project
import Animated from 'react-native-reanimated';
const AnimatedScreen = Animated.createAnimatedComponent(
InnerScreen as unknown as React.ComponentClass,
);
const ReanimatedScreen = React.forwardRef<typeof AnimatedScreen, ScreenProps>(
(props, ref) => {
return (
<AnimatedScreen
// @ts-ignore some problems with ref and onTransitionProgressReanimated being "fake" prop for parsing of `useEvent` return value
ref={ref}
{...props}
/>
);
},
);
ReanimatedScreen.displayName = 'ReanimatedScreen';
export default ReanimatedScreen;
@@ -0,0 +1,43 @@
import React, { PropsWithChildren } from 'react';
import { View } from 'react-native';
import { ScreenContext } from '../components/Screen';
import { ScreenProps } from '../types';
import ReanimatedNativeStackScreen from './ReanimatedNativeStackScreen';
import AnimatedScreen from './ReanimatedScreen';
class ReanimatedScreenWrapper extends React.Component<ScreenProps> {
private ref: React.ElementRef<typeof View> | null = null;
setNativeProps(props: ScreenProps): void {
this.ref?.setNativeProps(props);
}
setRef = (ref: React.ElementRef<typeof View> | null): void => {
this.ref = ref;
this.props.onComponentRef?.(ref);
};
render() {
const ReanimatedScreen = this.props.isNativeStack
? ReanimatedNativeStackScreen
: AnimatedScreen;
return (
<ReanimatedScreen
{...this.props}
// @ts-ignore some problems with ref
ref={this.setRef}
/>
);
}
}
export default function ReanimatedScreenProvider(
props: PropsWithChildren<unknown>,
) {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<ScreenContext.Provider value={ReanimatedScreenWrapper as any}>
{props.children}
</ScreenContext.Provider>
);
}
@@ -0,0 +1,13 @@
import * as React from 'react';
// @ts-ignore file to be used only if `react-native-reanimated` available in the project
import Animated from 'react-native-reanimated';
type ReanimatedTransitionProgressContextBody = {
progress: Animated.SharedValue<number>;
closing: Animated.SharedValue<number>;
goingForward: Animated.SharedValue<number>;
};
export default React.createContext<
ReanimatedTransitionProgressContextBody | undefined
>(undefined);
@@ -0,0 +1,3 @@
export { default as ReanimatedScreenProvider } from './ReanimatedScreenProvider';
export { default as useReanimatedTransitionProgress } from './useReanimatedTransitionProgress';
export { default as useReanimatedHeaderHeight } from './useReanimatedHeaderHeight';
@@ -0,0 +1,14 @@
import * as React from 'react';
import ReanimatedHeaderHeightContext from './ReanimatedHeaderHeightContext';
export default function useReanimatedHeaderHeight() {
const height = React.useContext(ReanimatedHeaderHeightContext);
if (height === undefined) {
throw new Error(
"Couldn't find the header height using Reanimated. Are you inside a screen in a navigator with a header and your NavigationContainer is wrapped in ReanimatedScreenProvider?",
);
}
return height;
}
@@ -0,0 +1,14 @@
import * as React from 'react';
import ReanimatedTransitionProgressContext from './ReanimatedTransitionProgressContext';
export default function useReanimatedTransitionProgress() {
const progress = React.useContext(ReanimatedTransitionProgressContext);
if (progress === undefined) {
throw new Error(
"Couldn't find values for reanimated transition progress. Are you inside a screen in Native Stack?",
);
}
return progress;
}

Some files were not shown because too many files have changed in this diff Show More