본문 바로가기
React/Native

EXPO) gorhom의 BOTTOM SHEET에 대해서 잠깐 알아보기

by 후르륵짭짭 2025. 3. 15.
728x90
반응형

전쟁 기념관에서 찍은 사진

 

안녕하세요. 짭짭이입니다.

이번에는 EXPO의 Bottom Sheet 라이브러리와 

expo Router에서 Modal 방식으로 View를 나오게 할 때 주의사항에 대해 말씀드릴려고 합니다.

 

https://github.com/gorhom/react-native-bottom-sheet

 

GitHub - gorhom/react-native-bottom-sheet: A performant interactive bottom sheet with fully configurable options 🚀

A performant interactive bottom sheet with fully configurable options 🚀 - gorhom/react-native-bottom-sheet

github.com

gorhom의 Bottom Sheet를 사용했습니다. (요즘 reat native의 매력에 빠졌습니다 ㅋㅋㅋㅋ.)

이놈이 정말 사용하기 쉽습니다. 

저는 EXPO를 사용하니 아래와 같이 명령어를 통해서 깔아주고

npx expo install react-native-reanimated react-native-gesture-handler

 

저 같은 경우 Expo Router를 사용하니 _layout.tsx에 아래와 같이 코드를 작성해 줍니다.

import { Tabs } from "expo-router";
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function TabsLayout() {
    return (
    <GestureHandlerRootView style={{flex:1}}>
    <Tabs>
    <Tabs.Screen name="index" />
    <Tabs.Screen name="tabSecond" />
    </Tabs>
    </GestureHandlerRootView>
)
}

여기서 중요한건 Bottom Sheet를 Drag 할 수 있도록 하는 Gesture 인식 컴포넌트를 가장 상위에 넣어줘야합니다.

<GestureHandlerRootView style={{flex:1}}>

</GestureHandlerRootView>

 

이제 Index.tsx에 아래와 같이 작성해줘야합니다.

import {
  BottomSheetModal,
  BottomSheetModalProvider,
} from '@gorhom/bottom-sheet';

export default function HomeScreen() {
  const modalRef = useRef<BottomSheetModal>(null)
  
  function openFiltersModal() {
    modalRef?.current?.present()
  }

  function closeFiltersModal() {
    modalRef.current?.close()
  }
  
  return (<BottomSheetModalProvider>
  <View style={styles.container}>
  <FiltersModal bottomModalRef={modalRef}
         filters={filters}
         setFilters={setFilters}
         onClose={closeFiltersModal}
        onApply={applyFilters}
        onReset={resetFilters}/>
   </View>
  
  </BottomSheetModalProvider>)
  
}

 

BottomSheetModalProvider 하위에 FilterModal를 감싸줍니다. 

이때 가장 상위 뷰인 HomeScreen Component에서 ModalView를 다룰 것이기 때문에 

BottomSheetModal의 Reference를 useReference로 전달해줍니다.

export type Props = {
  bottomModalRef: RefObject<BottomSheetModal>
  filters: Record<string, any>
  setFilters: Dispatch<SetStateAction<Record<string, any>>>
  onClose: () => void
  onApply: () => void
  onReset: () => void
};

export function FiltersModal(props : Props) {

    const snapPoints = useMemo(() => ['85%'], []);

    const handleSheetChanges = useCallback((index: number, position: number) => {
        console.log('handleSheetChanges', index, position);
    }, []);

    console.log(snapPoints)

    return (
        <BottomSheetModal
            ref={props.bottomModalRef}
            snapPoints={snapPoints}
            index={0} // 이 줄을 유지합니다.
            onChange={handleSheetChanges}
            enablePanDownToClose={true}
            enableDynamicSizing={false}
            backdropComponent={CustomBackrop}
        >
            <BottomSheetView style={styles.contentContainer}>
                <View style={styles.content}>
                    <Text style={styles.filterText}>Filters</Text>
                    {
                        (Object.keys(sections) as (keyof typeof sections)[]).map((sectionName, index) => {
                            const sectionView = sections[sectionName];
                            let title = capitalize(sectionName)
                            let sectionData = { 
                                data: data.filters[sectionName],
                                modalProps: props,
                                filterName: sectionName
                            }
                            return (
                                <Animated.View key={index} entering={FadeInDown.delay(index * 100).springify().damping(10)}>
                                    <SectionView 
                                        title={title}
                                        content={sectionView(sectionData)}
                                    />
                                </Animated.View>
                            );
                        })
                    }
                </View>

                <Animated.View entering={FadeInDown.delay(550).springify().damping(20)}  style={styles.buttons}>
                    <Pressable style={[styles.button, styles.resetButton]} onPress={props.onReset}>
                        <Text style={[styles.buttonText , {color:theme.colors.neutral(0.9)}]}>Reset</Text>
                    </Pressable>
                    <Pressable style={[styles.button, styles.applyButton]} onPress={props.onApply}>
                        <Text style={[styles.buttonText , {color:theme.colors.white}]}>Apply</Text>
                    </Pressable>
                </Animated.View>

            </BottomSheetView>
        </BottomSheetModal>
    )
}

FilterModal은 위와 같이 작성되어 있습니다.

<BottomSheetModal 
	ref={props.bottomModalRef}
            snapPoints={snapPoints}
            index={0} // 이 줄을 유지합니다.
            onChange={handleSheetChanges}
            enablePanDownToClose={true}
            enableDynamicSizing={false}
            backdropComponent={CustomBackrop}> // Modal View
	<BottomSheetView style={styles.contentContainer}>
 		//원하는 뷰를 여기서 그려주면 됩니다. 
	</BottomSheetView>
</BottomSheetModal>

기본적인 구조는 이렇게 되어 있습니다.

ref={props.bottomModalRef} // 내가 Bottom Sheet를 다른 곳에서 제어할 때 참조자를 넣어줍니다.
const snapPoints = useMemo(() => ['85%'], []);

snapPoints={snapPoints} //snapPoint는 제공하는 index에 따라 보여주는 정도를 다르게 합니다.
index={0} // 이 줄을 유지합니다. snapPoint가 0일 때 85% 정도 보여주도록 합니다.
const handleSheetChanges = useCallback((index: number, position: number) => {
        console.log('handleSheetChanges', index, position);
    }, []);

onChange={handleSheetChanges} // 변화를 감지할 수 있습니다.
enablePanDownToClose={true} // 아래로 끌면 사라지도록 하는 겁니다.
//animatedIndex는 BottomSheet의 몇퍼센트 레인지에 도달했는지에 대한 Index이고 
//Style은 BottomSheet의 현재 Style을 의미한다.
function CustomBackrop({animatedIndex, style}: BottomSheetBackdropProps) {

    console.log(animatedIndex.value, style)

    const containerAnimatedStyle = useAnimatedStyle( () : ViewStyle => {
        let opacity = interpolate(
            animatedIndex.value,
            [-1,0], //index가 -1 일 때 Opacity 0 / index가 0 일 때 opacity 1
            [0,1],
            Extrapolation.CLAMP
        )

        console.log("Opcaity",opacity)

        return {
            opacity : opacity
        }
    })

    const blurView = <BlurView intensity={25} tint={"dark"} style={StyleSheet.absoluteFill} />

    const containerStyle = [
        StyleSheet.absoluteFill,
        style,
        styles.overlay,
        containerAnimatedStyle
    ]

    return (
       <Animated.View style={containerStyle}>
           {blurView}
       </Animated.View>
    )
}

backdropComponent={CustomBackrop} // Blure와 같은 뒷 화면에 View를 넣을 수 있습니다.

 

함수에 대해서는 아래 Reference를 보면 자세히 알 수 있습니다.

https://gorhom.dev/react-native-bottom-sheet/modal

 

Modal | React Native Bottom Sheet

A performant interactive bottom sheet modal with fully configurable options 🚀

gorhom.dev

 

추가적으로 BottomSheetView에 또다른 SectionView를 추가하였는데,

const sections = {
    "order": (props: OrderProps) => <OrderView {...props}/>,
    "orientation":(props: OrderProps) => <OrderView {...props}/>,
    "type": (props: OrderProps) => <OrderView {...props}/>,
    "colors": (props: OrderProps) => <ColorFilterView {...props}/>,
}

// FilterModal.tsx
let sectionData = { 
    data: data.filters[sectionName],
    modalProps: props,
    filterName: sectionName
}

return (
    <Animated.View key={index} entering={FadeInDown.delay(index * 100).springify().damping(10)}>
        <SectionView 
            title={title}
            content={sectionView(sectionData)}
        />
    </Animated.View>
);

// FilterView.tsx
type SectionProps = {
    title: string;
    content: React.ReactNode;
};

export function SectionView(props: SectionProps) {
    return (
        <View style={styles.sectionContainer}>
            <Text style={styles.sectionTitle}>{props.title}</Text>
            {props.content}
        </View>
    )
}


// OrderView.tsx
export function OrderView(props: OrderProps) {

    function onSelect(item: string) {
        props.modalProps.setFilters((prevFilters) => ({
            ...prevFilters,
            [props.filterName]: item,
        }));
    }

    console.log("OrderView Rendering", props.modalProps.filters)

    return (
        <View style={styles.orderViewContainer}>
            {
                props.data && <FlashList 
                    data={props.data}
                    horizontal={true} // 수평으로 설정합니다.
                    keyExtractor={(data) => data}
                    extraData={props.modalProps.filters} //FlashList는 data가 변경될 때 Rendering이 되도록 하는데 extraData로 추가적으로 렌더링 할 수 있게 한다.
                    estimatedItemSize={50}
                    renderItem={({ item }) => {
                        console.log("OrderView FlashList Render")
                        let isActive = props.modalProps.filters[props.filterName] === item;
                        let backgroundColor = isActive ? theme.colors.neutral(0.7): "white";
                        let color = isActive ? "white" : theme.colors.neutral(0.7);
                        return (
                            <Pressable key={item}
                            onPress={()=> {
                                onSelect(item)
                            }}
                            style={[styles.outlinedButton,{backgroundColor: backgroundColor}]}>
                                <Text style={[styles.outlinedButtonText,{color:color}]}>{capitalize(item)}</Text>
                            </Pressable>
                        )
                    }}
                    contentContainerStyle={styles.flashListContent} // 이 줄을 추가합니다.
                />
            }
        </View>
    )
}

 

그런데 정말 중요한 것은 

ModalView를 사용할 때, 높이와 크기에 대해서 정확하게 지정을 할 수 있어야합니다.

그래야 안정적으로 View가 나올 수 있게 됩니다.

 

또다른 예시의 코드 입니다.

import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Button } from 'react-native';
import { router } from 'expo-router';
import BottomSheet, { BottomSheetFlashList, BottomSheetView, SNAP_POINT_TYPE, useBottomSheetSpringConfigs } from '@gorhom/bottom-sheet';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function TabSecondPage() {

  const bottomSheetRef = useRef<BottomSheet>(null);

  const handleSheetChanges = useCallback((index: number, position: number, type: SNAP_POINT_TYPE) => {
    console.log('handleSheetChanges', index, position, type);
  }, []);

  const animationConfigs = useBottomSheetSpringConfigs({
    damping: 100,
    overshootClamping: true,
    restDisplacementThreshold: 0.1,
    restSpeedThreshold: 0.1,
    stiffness: 500,
  });

  const data = useMemo<string[]>(
    () =>
      Array(50)
        .fill(0)
        .map((_, index) => `index-${index}`),
    []
  );

  const snapPoints = useMemo(() => ["25%", "50%"], []);

  const handleSnapPress = useCallback((index: number) => {
    bottomSheetRef.current?.snapToIndex(index);
  }, []);
  const handleClosePress = useCallback(() => {
    bottomSheetRef.current?.close();
  }, []);

  const renderItem = useCallback(({ item }: { item: string }) => {
    return (
      <View key={item} style={styles.itemContainer}>
        <Text>{item}</Text>
      </View>
    );
  }, []);

  return (
    <View style={styles.container}>
      <Button title="Snap To 50%" onPress={() => handleSnapPress(1)} />
      <Button title="Snap To 25%" onPress={() => handleSnapPress(0)} />
      <Button title="Close" onPress={() => handleClosePress()} />

      <BottomSheet
        ref={bottomSheetRef}
        index={0} // 이 줄을 유지합니다.
        onChange={handleSheetChanges}
        snapPoints={snapPoints}
        animationConfigs={animationConfigs}
        enableDynamicSizing={false}
        >
        <BottomSheetFlashList
          data={data}
          keyExtractor={(item) => item}
          renderItem={renderItem}
          estimatedItemSize={43.3}
        />
        <BottomSheetView style={styles.contentContainer}>
          <Text>Awesome 🎉</Text>
        </BottomSheetView>
      </BottomSheet>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  title: {
    fontSize: 24,
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#007BFF',
    padding: 10,
    borderRadius: 5,
  },
  linkText: {
    color: '#FFFFFF',
    textAlign: 'center',
  },
  contentContainer: {
    flex: 1,
    padding: 36,
    alignItems: 'center',
  },
  itemContainer: {
    padding: 6,
    margin: 6,
    backgroundColor: "#eee",
  },
});
728x90
반응형

댓글