Skip to content

Instantly share code, notes, and snippets.

@yngfoxx
Last active May 16, 2024 10:58
Show Gist options
  • Save yngfoxx/7aaeee666fa5a66c72da85f83e35d3db to your computer and use it in GitHub Desktop.
Save yngfoxx/7aaeee666fa5a66c72da85f83e35d3db to your computer and use it in GitHub Desktop.
React Native Action Sheet
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'
}
})
@Rossella-Mascia-Neosyn
Copy link

works all perfectly only thing I do not see ActionSheetDragIndicator

@yngfoxx
Copy link
Author

yngfoxx commented Apr 17, 2024

@Rossella-Mascia-Neosyn You might need to edit the ActionSheetDragIndicator at line 150, most of the colours used in the snippet are custom,
will change that now.

@Rossella-Mascia-Neosyn
Copy link

Screenshot 2024-04-18 at 12 28 40
snapPoints does not exist. All the props are missing, right?

@yngfoxx
Copy link
Author

yngfoxx commented Apr 18, 2024

Screenshot 2024-04-18 at 12 28 40 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