상세 컨텐츠

본문 제목

react native 에서 백드롭, 바텀시트 구현하기 (Backdrop)

개발

by 호박너구리의 블로그 2022. 2. 5. 18:30

본문

 

앱 개발에 있어서 가장 많이 사용되는 디자인 요소 중 하나가 바로 백드롭 (Backdrop), 바텀시트(Bottom Sheet) 입니다. 스크린 이동을 시키지 않고, 바로 뜨는 화면에서 쉽게 동작을 이끌어내거나 주의를 끌 수 있기 때문인데요. 오늘은 이러한 기능을 구현하기 위한 방법에 대해 알아보겠습니다.

 

 

들어가며: 백드롭(Backdrop)이란?

백드롭이란 (아래 그림과 같이) 기존의 스크린 위에 새로운 화면이 뜨는 방식의 UI를 뜻합니다. 인스타그램의 프로필 스크린에서 프로필 이름을 누르거나, 옵션을 누를 때 아래에서 올라오는 스크린을 말하는 것이죠. 아래에서 올라오는 화면이라는 의미로 바텀 시트라고 부르기도 합니다.

 

출처. Material Design

 

 

방법1: 라이브러리 활용하기

새로운 기능을 써야할 때 가장 편리한 방법은 라이브러리를 활용하는 것입니다. 검색하다가 몇가지 라이브러리를 찾을 수 있었는데요. 제공하는 기능도 많고 좋아보였는데, 다운로드 수가 적은 것이 조금 걱정이더라고요. 

 

- react-native-backdrop

- react-native-gesture-bottom-sheet

- react-native-raw-bottom-sheet

 

만약 스크롤 유무 등을 선택할 수 있는 다양한 형태의 백드롭을 활용한다면 라이브러리를 활용하는 것을 추천드립니다. 다만 저는 스크롤 없이 열렸다 닫히는 백드롭만 사용할 예정이라서, 라이브러리를 활용하지 않고 직접 개발했습니다.

 

 

방법2: 직접 개발하기

아래는 스크롤 기능은 빼고, 하단에서 스르륵 올라왔다가 내려가는 애니메이션이 추가된 백드롭 컴포넌트입니다. 백드롭이 생성되었을 때 배경화면을 얼마나 흐리게 할 것인지 등은 아래 style에서 직접 설정하시면 됩니다!

import React, { 
  ReactNode, 
  useCallback, 
  useEffect, 
  useMemo, 
  useRef, 
  useState 
} from 'react'
import { 
  Animated, 
  BackHandler, 
  Dimensions, 
  LayoutChangeEvent, 
  Pressable, 
  StyleProp, 
  StyleSheet, 
  View, 
  ViewStyle 
} from "react-native"

interface BackdropProps {
  visible: boolean
  children: ReactNode
  onClickClose?: () => void
  afterOpen?: () => void
  afterClose?: () => void
  closeOnBackButton?: boolean
  backdropStyle?: StyleProp<ViewStyle>
  containerStyle?: StyleProp<ViewStyle>
}

const { height } = Dimensions.get('window');

const Backdrop = ({
  visible,
  children,
  onClickClose = () => {},
  afterOpen = () => {},
  afterClose = () => {},
  closeOnBackButton = false,
  backdropStyle = {},
  containerStyle = {}
}: BackdropProps) => {
  const [contentHeight, setContentHeight] = useState(0);
  const transitionY = useRef(new Animated.Value(height));
  const animationConfig = {
    useNativeDriver: true,
    duration: 50,
    speed: 14,
    bounciness: 4,
  }

  const animationStart = useCallback(() => {
    Animated.spring(
      transitionY.current, 
      {
        toValue: 0,
        ...animationConfig,
      }
    ).start(() => afterOpen());
  }, [afterOpen]);

  const animationEnd = useCallback(() => {
    Animated.spring(
      transitionY.current, 
      {
        toValue: contentHeight,
        ...animationConfig,
      }
    ).start(() => afterClose());
  }, [contentHeight, afterClose]);

  const onLayout = (event: LayoutChangeEvent) => {
    if (!contentHeight || !visible) {
      transitionY.current.setValue(event.nativeEvent.layout.height);
      setContentHeight(event.nativeEvent.layout.height);
    }
  };

  const clampedTransition = useMemo(
    () =>
      transitionY.current.interpolate({
        inputRange: [0, contentHeight || 1],
        outputRange: [
          contentHeight > height ? contentHeight - height : 0,
          contentHeight || 1,
        ],
        extrapolate: 'clamp',
      }),
    [contentHeight],
  );

  const clampedOpacity = useMemo(
    () =>
      transitionY.current.interpolate({
        inputRange: [0, contentHeight || 1],
        outputRange: [1, 0],
        extrapolate: 'clamp',
      }),
    [contentHeight],
  );

  const clampedContentOpacity = useMemo(
    () =>
      transitionY.current.interpolate({
        inputRange: [
          0,
          contentHeight ? contentHeight / 1.1 : 0.95,
          contentHeight || 1,
        ],
        outputRange: [1, 1, 0],
        extrapolate: 'clamp',
      }),
    [contentHeight],
  );

  const handleClose = () => {
    onClickClose()
    return true
  }

  useEffect(() => {
    if (visible) {
      animationStart()
    } else {
      animationEnd()
    }
  }, [visible, animationStart, animationEnd]);

  useEffect(() => {
    if (closeOnBackButton) {
      BackHandler.addEventListener('hardwareBackPress', handleClose);
    }
      
    return () => {
      if (closeOnBackButton) {
        BackHandler.removeEventListener('hardwareBackPress', handleClose);
      }
    }
  }, [closeOnBackButton, handleClose]);

  return (
    <View pointerEvents="box-none" style={styles.wrapper}>
      <Animated.View
        style={[
          styles.overlayStyle,
          backdropStyle,
          {
            opacity: clampedOpacity,
          },
        ]}
        pointerEvents={visible ? 'auto' : 'none'}
      >
        <Pressable
          style={styles.overlayTouchable}
          onPress={handleClose}
        />
      </Animated.View>

      <Animated.View
        pointerEvents="box-none"
        accessibilityLiveRegion="polite"
        style={[
          styles.contentContainer,
          {
            transform: [
              {
                translateY: clampedTransition,
              },
            ],
            opacity: clampedContentOpacity,
          },
        ]}>
        <View
          style={containerStyle}
          onLayout={onLayout}
        >
          {children}
        </View>
      </Animated.View>
    </View>
  )
}

const styles = StyleSheet.create({
  wrapper: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 0,
    bottom: 0,
    zIndex: 100,
    elevation: 10,
    justifyContent: 'flex-end',
    flex: 1,
  },
  overlayTouchable: {
    flex: 1,
  },
  contentContainer: {
    flex: 1,
    justifyContent: 'flex-end',
  },
  overlayStyle: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 0,
    bottom: 0,
    backgroundColor: 'rgba(0, 0, 0, 0.5)'
  },
})

export default Backdrop

 

그리고 이러한 Backdrop 컴포넌트를 이용할 때에는 아래와 같이 컴포넌트를 import하여 내부에 원하는 형태의 컴포넌트를 구성하면 됩니다. (그리고 뒤로가기를 눌렀을 때 닫히게 만들고 싶은 경우가 많아서 closeOnBackButton props도 추가하였습니다)

import React, { useState } from 'react'
import {
  View,
  Pressable,
  Text
} from 'react-native'
import Backdrop from 'src/components/Backdrop' // 컴포넌트를 만든 경로에서 import

const TestScreen = () => {
  const [isBackdropOpened, setIsBackdropOpened] = useState(false)

  return (
    <View>
      <Pressable onPress={() => setIsBackdropOpened(true)}>
      	<Text>백드롭 열기</Text>
      </Pressable>
      
      <Backdrop
      	visible={isBackdropOpened}
        onClickClose={() => setIsBackdropOpened(false)}
        closeOnBackButton
        containerStyle={{ backgroundColor: '#ffffff' }}
      >
        <View>
          <Text>
            짜잔!
          </Text>
        </View>
      </Backdrop>
    </View>
  )
}

export default TestScreen

 

 

오늘은 백드롭 구현에 대해 한 번 살펴보았는데요. 콘텐츠가 도움이 되었길 바라며, 그럼 오늘도 모두들 해피코딩하세요 :)

 

728x90

관련글 더보기

댓글 영역