Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/drawer-react-native-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@razorpay/blade": minor
---

feat(rn): add React Native support for Drawer component
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ThemeContext } from './useTheme';
import { useBladeProvider } from './useBladeProvider';
import type { BladeProviderProps } from './types';
import { BottomSheetStackProvider } from '~components/BottomSheet/BottomSheetStack';
import { DrawerStackProvider } from '~components/Drawer/StackProvider';

const gestureHandlerStyle = {
flex: 1,
Expand All @@ -23,7 +24,9 @@ const BladeProvider = ({
<PortalProvider>
<ThemeContext.Provider value={themeContextValue}>
<StyledComponentThemeProvider theme={theme}>
<BottomSheetStackProvider>{children}</BottomSheetStackProvider>
<DrawerStackProvider>
<BottomSheetStackProvider>{children}</BottomSheetStackProvider>
</DrawerStackProvider>
<PortalHost name="BladeBottomSheetPortal" />
</StyledComponentThemeProvider>
</ThemeContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React from 'react';
import styled from 'styled-components/native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
cancelAnimation,
} from 'react-native-reanimated';
import { Dimensions, Pressable } from 'react-native';
import type { ElevationStyles } from '~tokens/global/elevation';
import BaseBox from '~components/Box/BaseBox';
import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles';
import { useTheme } from '~components/BladeProvider';
import { makeAccessible } from '~utils/makeAccessible';

const fillStyle = {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
};

// Outer animated wrapper carries the slide/opacity animation + elevation shadow.
// Shadow lives here (NOT on the inner surface) because the inner surface uses
// `overflow: 'hidden'` for the Android border-radius clip fix, which would clip the shadow.
const StyledDrawerWrapper = styled(Animated.View)(() => {
return {
position: 'absolute' as const,
top: 0,
bottom: 0,
right: 0,
width: '90%',
flexDirection: 'column' as const,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · api-decision-critique · confidence: 7/10

Problem: Drawer width is hardcoded to '90%' with no responsive breakpoints. Web uses width={{ base: '90%', s: '375px', m: '420px' }} to cap the drawer width on larger screens. On RN tablet or wide-window contexts, the native drawer will be significantly wider than its web counterpart.

Suggestion: Use the theme's breakpoint system or Dimensions-based logic to apply responsive width matching web's responsive width tokens.

};
});

const StyledDrawerSurface = styled(BaseBox)(({ theme }) => {
return {
flex: 1,
backgroundColor: theme.colors.popup.background.gray.subtle,
// base breakpoint renders the drawer edge-to-edge with no radius (matches web's phone view)
borderRadius: 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · api-decision-critique · confidence: 6/10

Problem: borderRadius is hardcoded to 0. Web uses borderRadius={{ base: 'none', m: 'large' }} — no radius on mobile, rounded corners on medium+ screens. Native always renders edge-to-edge with no radius regardless of screen size.

Suggestion: Apply conditional borderRadius based on screen width breakpoint to match web's behavior.

overflow: 'hidden' as const,
flexDirection: 'column' as const,
};
});

const StyledOverlay = styled(Animated.View)(({ theme }) => {
return {
...fillStyle,
backgroundColor: theme.colors.overlay.background.subtle,
};
});

type AnimatedDrawerContainerProps = {
/**
* Drives the enter/exit animation. When `true` the drawer slides in and the
* overlay fades in; when `false` it slides out and fades away.
*/
isVisible: boolean;
/**
* Whether to render the dismissible overlay behind the drawer surface.
*/
showOverlay: boolean;
/**
* Called when the user presses the overlay to dismiss the drawer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · code-quality-critique · confidence: 7/10

Problem: Dimensions.get('window').width is called during render but never updated. If the device rotates or the window resizes (e.g., multitasking on iPad), screenWidth becomes stale and the slide-in/slide-out animation will use the wrong off-screen position.

Suggestion: Subscribe to dimension changes via Dimensions.addEventListener('change', ...) and update screenWidth in state, or use the useWindowDimensions hook from react-native which is reactive.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: replaced Dimensions.get('window').width with the reactive useWindowDimensions() hook from react-native. Now screenWidth updates automatically on device rotation or multitasking resize, and the animation effect re-runs with the correct off-screen position. See PR #3754. [resolved by agent]

*/
onOverlayPress: () => void;
/**
* Called after the exit animation completes (parity with web `onUnmount`).
*/
onExitComplete?: () => void;
/**
* Accessibility label announced for the drawer dialog.
*/
accessibilityLabel?: string;
/**
* Whether this drawer is the first (bottom-most) in a stack of 2+ open drawers.
* When true, the resting translateX is offset slightly so the first drawer peeks
* out behind the stacked drawer — matching web's stacked-drawer positioning.
*/
isFirstDrawerInStack?: boolean;
/**
* Drawer content (DrawerHeader / DrawerBody / DrawerFooter).
*/
children: React.ReactNode;
};

/**
* Encapsulates the reanimated slide-in surface + fading overlay for the native
* Drawer. Mirrors the web `AnimatedDrawerContainer` styled component but drives
* `translateX`/`opacity` via reanimated shared values instead of CSS transitions.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [MAJOR] · code-quality-critique · confidence: 8/10

Problem: The animation useEffect captures onExitComplete in the withTiming callback but excludes it from the dependency array [isVisible, screenWidth]. If onExitComplete (which wraps handleExitComplete → onUnmount) changes identity between renders, the animation's completion callback will invoke a stale closure. This can result in onUnmount never being called or being called with outdated state after the exit animation finishes.

Suggestion: Store onExitComplete in a ref to avoid re-triggering the animation on every identity change, or add it to the dependency array. The ref approach is preferable to avoid restarting the animation mid-flight.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: stored onExitComplete in a ref (onExitCompleteRef) that is kept in sync via a separate useEffect. The reanimated withTiming callback now reads onExitCompleteRef.current instead of the captured closure, so it always invokes the latest identity without re-triggering the animation. See PR #3754. [resolved by agent]

const AnimatedDrawerContainer = ({
isVisible,
showOverlay,
onOverlayPress,
onExitComplete,
accessibilityLabel,
isFirstDrawerInStack = false,
children,
}: AnimatedDrawerContainerProps): React.ReactElement => {
const { theme } = useTheme();
const screenWidth = Dimensions.get('window').width;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · code-quality-critique · confidence: 7/10

Problem: Dimensions.get('window').width is captured at render time and is not reactive to dimension changes (device rotation, window resize). While the effect depends on screenWidth, the component may not re-render on rotation unless something else triggers it. useWindowDimensions() is the recommended React Native hook that subscribes to dimension changes and guarantees re-renders. Note: BottomSheet.native.tsx uses the same pattern, so this is consistent with the existing codebase but suboptimal.

Suggestion: Replace const screenWidth = Dimensions.get('window').width; with const { width: screenWidth } = useWindowDimensions(); (import from 'react-native').

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · code-quality-critique · confidence: 7/10

Problem: Dimensions.get('window').width is captured once at mount and stored in screenWidth. If the device orientation changes (portrait↔landscape) or the window is resized, the drawer will use a stale screen width for its slide animation distance.

Suggestion: Replace with useWindowDimensions() hook from 'react-native' which re-renders on dimension changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [MINOR] · code-quality-critique · confidence: 7/10

Problem: Dimensions.get('window').width is not reactive to screen dimension changes (rotation, fold/unfold, split-screen). If the screen rotates while the drawer is open, the exit animation translates to the stale portrait width.

Suggestion: Replace with useWindowDimensions() for reactivity.

// Always initialize the shared values at the CLOSED / off-screen state
// (translateX = screenWidth, opacity = 0) so there is a "from" frame to animate FROM
// when the drawer mounts open. The Portal mounts fresh on open, so this container
// mounts with `isVisible = true`; the mount effect below then drives `withTiming` to
// the open state, producing the slide-in.
//
// Initializing from the current visibility instead (open → translateX 0) meant that on
// open the surface was already at its resting position, so the enter `withTiming(0)`
// had nothing to animate and the panel snapped into place instantly. The exit still
// animated (0 → screenWidth) which is why only the enter transition looked broken.
const translateX = useSharedValue(screenWidth);
const surfaceOpacity = useSharedValue(0);
const overlayOpacity = useSharedValue(0);

const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles;

React.useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [MAJOR] · code-quality-critique · confidence: 9/10

Problem: The useEffect that drives withTiming animations has no cleanup function. Every other reanimated component in the codebase (Spinner, ProgressBar, Skeleton) calls cancelAnimation on each shared value in the useEffect cleanup to prevent the UI-thread worklet from continuing to animate after unmount. If AnimatedDrawerContainer unmounts mid-animation (e.g. rapid open/close toggle), the withTiming worklets on translateX, surfaceOpacity, and overlayOpacity continue running on the UI thread with no component to update, risking memory leaks or native crashes.

Suggestion: Add a cleanup return to the useEffect that cancels all three shared values: cancelAnimation(translateX); cancelAnimation(surfaceOpacity); cancelAnimation(overlayOpacity); Also add cancelAnimation to the import from 'react-native-reanimated'.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Added cancelAnimation to the reanimated import and a cleanup useEffect that cancels all three shared values (translateX, surfaceOpacity, overlayOpacity) on unmount, matching the pattern used by other reanimated components (Spinner, ProgressBar, Skeleton).

React.useEffect(() => {
  return () => {
    cancelAnimation(translateX);
    cancelAnimation(surfaceOpacity);
    cancelAnimation(overlayOpacity);
  };
}, [translateX, surfaceOpacity, overlayOpacity]);

[resolved by agent]

// Mirror web's Drawer surface transition (Drawer.web.tsx): enter uses
// `duration.xmoderate` + `easing.entrance`, exit uses `duration.moderate` +
// `easing.exit`. Previously enter used the slower `gentle` (480ms), which made the
// native open feel sluggish compared to web's 360ms slide-in.
const enterConfig = {
duration: theme.motion.duration.xmoderate,
easing: theme.motion.easing.entrance,
};
const exitConfig = {
duration: theme.motion.duration.moderate,
easing: theme.motion.easing.exit,
};
const config = isVisible ? enterConfig : exitConfig;

// When this drawer is the first in a stack (another drawer is open on top),
// offset the resting position slightly to the left so the first drawer peeks
// out behind the stacked drawer — matching web's stacked-drawer positioning.
const stackOffset = isFirstDrawerInStack ? -theme.spacing[5] : 0;
translateX.value = withTiming(isVisible ? stackOffset : screenWidth, config);
surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config);
overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => {
if (finished && !isVisible && onExitComplete) {
runOnJS(onExitComplete)();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, screenWidth]);

// Cancel all in-flight animations on unmount to prevent UI-thread worklets
// from continuing to animate shared values after the component is gone.
React.useEffect(() => {
return () => {
cancelAnimation(translateX);
cancelAnimation(surfaceOpacity);
cancelAnimation(overlayOpacity);
};
}, [translateX, surfaceOpacity, overlayOpacity]);

const surfaceAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: surfaceOpacity.value,
transform: [{ translateX: translateX.value }],
};
});

const overlayAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: overlayOpacity.value,
};
});

return (
<>
{showOverlay ? (
<Pressable
onPress={onOverlayPress}
style={fillStyle}
{...makeAccessible({ role: 'button', label: 'Dismiss' })}
>
<StyledOverlay style={overlayAnimatedStyle} />
</Pressable>
) : null}
<StyledDrawerWrapper style={[shadow, surfaceAnimatedStyle]}>
<StyledDrawerSurface
{...makeAccessible({
role: 'dialog',
modal: true,
label: accessibilityLabel,
})}
>
{children}
</StyledDrawerSurface>
</StyledDrawerWrapper>
</>
);
};

export { AnimatedDrawerContainer };
Loading
Loading