Last active
May 16, 2024 10:58
-
-
Save yngfoxx/7aaeee666fa5a66c72da85f83e35d3db to your computer and use it in GitHub Desktop.
React Native Action Sheet
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
useRef, | |
useMemo, | |
useState, | |
useEffect, | |
useContext, | |
useCallback, | |
createContext, | |
} from 'react' | |
import { | |
Animated, | |
Dimensions, | |
StyleSheet, | |
LayoutRectangle, | |
} from 'react-native' | |
import { | |
GestureEvent, | |
PanGestureHandler, | |
GestureHandlerRootView, | |
HandlerStateChangeEvent, | |
PanGestureHandlerEventPayload, | |
} from 'react-native-gesture-handler'; | |
import { Subject } from 'rxjs'; | |
import { useDisclose } from '@app/utils/hooks'; | |
import { Modal, Box } from '@gluestack-ui/themed'; | |
import { ICompProps } from '@app/components/types'; | |
import { useKeyboard } from '@app/utils/hooks'; | |
import { useSafeAreaInsets } from 'react-native-safe-area-context'; | |
export type ActionSheetContextProps = { | |
isOpen: boolean | |
contentBtm?: Animated.Value | |
contentAnim?: Animated.Value | |
dragSubject?: Subject<number> | |
closeContent?: () => void | |
contentLayout: LayoutRectangle | null | |
setContentLayout?: (v: LayoutRectangle) => void | |
} | |
export const ActionSheetContext = createContext<ActionSheetContextProps>({ | |
isOpen: false, | |
contentLayout: null, | |
}) | |
export const ActionSheetBackdrop = (props: ICompProps<'Box'>) => { | |
const ctx = useContext(ActionSheetContext) | |
if (!ctx) return null | |
if (!ctx?.closeContent) return null | |
return ( | |
<Box | |
opacity={0} | |
bgColor='$black' | |
{...props} | |
top={0} | |
left={0} | |
right={0} | |
bottom={0} | |
position='absolute' | |
onPointerUp={ctx.closeContent} | |
onTouchStart={ctx.closeContent} | |
/> | |
) | |
} | |
export const ActionSheetContent = ({ children } : { children: React.ReactNode }) => { | |
const kbd = useKeyboard() | |
const dim = Dimensions.get('window') | |
const ctx = useContext(ActionSheetContext) | |
const kbh = useMemo(() => kbd.height, [kbd]); | |
if (!ctx) return null; | |
if (!ctx?.contentBtm) return null; | |
if (!ctx?.contentAnim) return null; | |
if (!ctx?.setContentLayout) return null; | |
return ( | |
<Animated.View | |
style={[style.contentAnimView, { | |
maxHeight: dim.height, | |
bottom: ctx.contentBtm, | |
paddingBottom: kbd.isVisible && ctx.isOpen ? kbh : 0, | |
transform: [{translateY: ctx.contentAnim}], | |
}]} | |
onLayout={v => ctx.setContentLayout!(v.nativeEvent.layout)} | |
>{ctx?.isOpen && children}</Animated.View> | |
) | |
} | |
export const ActionSheetDragIndicator = () => { | |
const ctx = useContext(ActionSheetContext) | |
const dragTransY = useRef<number>(0) | |
/** | |
* The most recent move distance is gestureState.move{X,Y} | |
* The accumulated gesture distance since becoming responder is | |
* gestureState.d{x,y} | |
**/ | |
const handlePanResponderMove = useCallback((e: GestureEvent<PanGestureHandlerEventPayload>) => { | |
if (!ctx?.dragSubject) return; | |
let { translationY } = e.nativeEvent; | |
if (translationY < 0) return; | |
ctx?.dragSubject!.next(translationY) | |
}, [ctx]) | |
/** | |
* The user has released all touches while this view is the | |
* responder. This typically means a gesture has succeeded | |
**/ | |
const handlePanResponderRelease = useCallback((e?: HandlerStateChangeEvent<Record<string, unknown>>) => { | |
if (!ctx.closeContent) return; | |
if (!ctx?.dragSubject) return; | |
if (!ctx.contentLayout) return; | |
// TODO: Check snap region (for multi snap regions) | |
// TODO: Snap to closest snap region or close when at close snap region | |
const contentHeight = ctx.contentLayout.height! - 20 | |
const closeRange = contentHeight - (contentHeight * 0.5); | |
if (dragTransY.current > closeRange) { | |
ctx.closeContent() | |
} else { | |
ctx.dragSubject!.next(0) | |
} | |
}, [ctx]) | |
useEffect(() => { | |
const sub = ctx?.dragSubject?.subscribe({ | |
next: v => { dragTransY.current = v }, | |
}) | |
return () => { | |
sub?.unsubscribe() | |
} | |
}, []) | |
return ( | |
<PanGestureHandler | |
onEnded={handlePanResponderRelease} | |
onFailed={handlePanResponderRelease} | |
onGestureEvent={handlePanResponderMove} | |
> | |
<Box | |
py={7} | |
alignSelf='center' | |
> | |
<Box | |
h={6} | |
w={70} | |
rounded='$2xl' | |
$dark-bgColor='$coolGray600' | |
$light-bgColor='$coolGray300' | |
/> | |
</Box> | |
</PanGestureHandler> | |
) | |
} | |
export const ActionSheet = ({ | |
onClose, | |
children, | |
isOpen = false, | |
} : { | |
isOpen?: boolean | |
onClose?: () => void | |
children?: React.ReactNode | |
}) => { | |
const dim = Dimensions.get('window') | |
const sheet = useDisclose(isOpen) | |
const content = useDisclose(false) | |
const safeArea = useSafeAreaInsets() | |
const dragSubject = useMemo(() => new Subject<number>(), []) | |
const contentAnim = useMemo(() => new Animated.Value(0), []) | |
const contentBtm = useMemo(() => new Animated.Value(-(dim.height)), []) | |
const [ contentLayout, setContentLayout ] = useState<LayoutRectangle|null>(null) | |
/** | |
* ? Open action sheet | |
*/ | |
const openContent = useCallback(() => { | |
sheet.onOpen() | |
content.onOpen() | |
setTimeout(() => Animated.timing(contentBtm, { | |
toValue: 0, | |
duration: 400, | |
useNativeDriver: false, | |
}).start()) | |
}, [sheet, content, safeArea, contentBtm]) | |
/** | |
* ? Close action sheet | |
*/ | |
const closeContent = () => { | |
if (!onClose) return; | |
content.onClose() | |
Animated.timing(contentBtm, { | |
duration:400, | |
useNativeDriver:false, | |
toValue: -(Dimensions.get('window').height), | |
}).start(() => { | |
sheet.onClose() | |
onClose() | |
Animated.timing(contentAnim, { | |
duration:0, toValue:0, | |
useNativeDriver:false | |
}).start() | |
}) | |
} | |
const closeSheet = () => { | |
content.onClose() | |
Animated.timing(contentBtm, { | |
duration:400, useNativeDriver:false, | |
toValue: -(Dimensions.get('window').height), | |
}).start(() => { | |
sheet.onClose() | |
Animated.timing(contentAnim, { | |
duration:0, toValue:0, | |
useNativeDriver:false | |
}).start() | |
}) | |
} | |
/** | |
* ? Reactively toggle action sheet | |
*/ | |
useEffect(() => { | |
(isOpen ? openContent : closeSheet)() | |
}, [isOpen]) | |
/** | |
* ? Start drag listener on component mount | |
*/ | |
useEffect(() => { | |
const sub = dragSubject.subscribe({ | |
// ? Animate content to drag location | |
next: v => Animated.timing(contentAnim, { | |
toValue:v, | |
duration:.1, | |
useNativeDriver:false | |
}).start() | |
}) | |
return () => { | |
sub.unsubscribe() | |
} | |
}, []) | |
return ( | |
<GestureHandlerRootView> | |
<Modal | |
w='$full' | |
h='$full' | |
isOpen={sheet.isOpen} | |
> | |
<ActionSheetContext.Provider value={{ | |
contentBtm, | |
dragSubject, | |
contentAnim, | |
closeContent, | |
contentLayout, | |
setContentLayout, | |
isOpen: sheet.isOpen, | |
}}> | |
<ActionSheetBackdrop /> | |
{ children } | |
</ActionSheetContext.Provider> | |
</Modal> | |
</GestureHandlerRootView> | |
) | |
} | |
const style = StyleSheet.create({ | |
contentAnimView: { | |
left: 0, | |
right: 0, | |
position: 'absolute' | |
} | |
}) |
snapPoints does not exist. All the props are missing, right?
Nope no snapPoints yet, it is a TODO at line 114
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
snapPoints does not exist. All the props are missing, right?