-
Notifications
You must be signed in to change notification settings - Fork 197
feat(rn): add React Native support for Drawer #3645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d087a3
d8b4901
fa1e8e2
a67abf9
2b72c09
28254fd
b597d7a
e2c0c27
c24756a
0bd408e
41150b3
3c3c678
7cf80da
3a9c638
43f2425
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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, | ||
| }; | ||
| }); | ||
|
|
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: replaced |
||
| */ | ||
| 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. | ||
| */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: stored |
||
| const AnimatedDrawerContainer = ({ | ||
| isVisible, | ||
| showOverlay, | ||
| onOverlayPress, | ||
| onExitComplete, | ||
| accessibilityLabel, | ||
| isFirstDrawerInStack = false, | ||
| children, | ||
| }: AnimatedDrawerContainerProps): React.ReactElement => { | ||
| const { theme } = useTheme(); | ||
| const screenWidth = Dimensions.get('window').width; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [MINOR] · code-quality-critique · confidence: 7/10 Problem: Suggestion: Replace
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [MINOR] · code-quality-critique · confidence: 7/10 Problem: Suggestion: Replace with
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: Added 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 }; | ||
There was a problem hiding this comment.
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.