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'
}
})
@yngfoxx
Copy link
Author

yngfoxx commented Apr 8, 2024

Required Packages

yarn add rxjs
yarn add react-native-gesture-handler

Gluestack Typescript 🤕

import { ComponentProps, ElementRef } from 'react'
import * as GUI from '@gluestack-ui/themed'


type tGUI = typeof GUI

type kGUI = keyof tGUI

export type ICompPropsT<T extends kGUI> = ComponentProps<tGUI[T]>

export type ICompRef<T extends kGUI>    = (ICompPropsT<T>)['ref']

export type ICompProps<T extends kGUI>  = Omit<ICompPropsT<T>, 'ref'>

export type IHookRef<T extends kGUI> = ElementRef<tGUI[T]>

useDisclose Hook

/**
 * Disclose hook
 */
export const useDisclose = (defaultValue: boolean = false) => {
    const [isOpen, setIsOpen] = useState<boolean>(defaultValue)
    const onOpen = useCallback(() => setIsOpen(true), [isOpen])
    const onClose = useCallback(() => setIsOpen(false), [isOpen])
    const toggle = useCallback((v?:boolean) => {
        if (v !== undefined) {
            setIsOpen(v);
        } else {
            setIsOpen(!isOpen)
        }
    }, [isOpen])
    return { isOpen, onOpen, onClose, toggle }
}

useKeyboard Hook

/**
 * Keyboard hook
 */
import { Keyboard, type KeyboardEvent } from 'react-native';

export const useKeyboard = () => {

    const [ height, setHeight ] = useState(0);
    const [ isVisible, setIsVisible ] = useState(false);

    useEffect(() => {

        function onKeyboardWillShow(e: KeyboardEvent) {
            setIsVisible(true)
        }

        function onKeyboardWillHide(e: KeyboardEvent) {
            setIsVisible(false)
        }

        function onKeyboardDidShow(e: KeyboardEvent) { // Remove type here if not using TypeScript
            setHeight(e.endCoordinates.height);
        }

        function onKeyboardDidHide() {
            setHeight(0);
        }

        const willShowSubscription = Keyboard.addListener('keyboardWillShow', onKeyboardWillShow);
        const willHideSubscription = Keyboard.addListener('keyboardWillHide', onKeyboardWillHide);
        const didShowSubscription = Keyboard.addListener('keyboardDidShow', onKeyboardDidShow);
        const didHideSubscription = Keyboard.addListener('keyboardDidHide', onKeyboardDidHide);

        return () => {
            willShowSubscription.remove();
            willHideSubscription.remove();
            didShowSubscription.remove();
            didHideSubscription.remove();
        };
    }, []);

    return {
        height,
        isVisible,
    };
}

@yngfoxx
Copy link
Author

yngfoxx commented Apr 8, 2024

Example

export default function Page() {
    const { isOpen, onOpen, onClose } = useDisclose(false)
    
    return (
        <Box>
            <Button py={10} onPress={onOpen}>
                <ButtonText>{'Open ActionSheet'}</ButtonText>
            </Button>

            <ActionSheet isOpen={isOpen} onClose={onClose}>
                <ActionSheetBackdrop opacity={.5} />
                <ActionSheetContent>
                    <VStack
                        p={10}
                        w='$full'
                        minHeight={200}
                        borderTopLeftRadius='$2xl'
                        borderTopRightRadius='$2xl'
                        $light-bgColor='$coolGray50'
                        $dark-bgColor='$coolGray900'
                    >
                        <ActionSheetDragIndicator />
                        <VStack mt={20} gap={20}>
                            <Button py={10} onPress={onClose}>
                                <ButtonText>{'Close ActionSheet'}</ButtonText>
                            </Button>
                        </VStack>
                    </VStack>
                </ActionSheetContent>
            </ActionSheet>
        </Box>
    )
}

@yngfoxx
Copy link
Author

yngfoxx commented Apr 9, 2024

Example ( Keyboard Aware )

Implemented with keyboard hook! here

@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