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,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;
}