본문 바로가기
개발

리액트로 BottomSheet 개발하기: 가이드와 팁

by 싼쵸 2024. 6. 30.
반응형

 

프로젝트를 하던  중에 디자이너분께서 바텀시트 기능이 추가되어 개발을 하게되었다.

사실 처음에는 라이브러리가 있을 줄 알고 큰 걱정안하고 기간안에 가능하다고 답변을 드렸다.

하지만 찾아보니 라이브러리가 없는 상황이어서 내가 직접 개발해야 되는 상황이 됐다.

일단 나의 영원한 친구 GPT의 도움을 받아 어찌저찌 구현은 성공했는데 뭔가 부족해서 없애고 

구글링으로 서칭 후 재 개발 하게됐다.

 

이글은 나 같은 분들이 조금이나마 개발의 편의를 제공하기 위해 쓴다.

먼저 구현하는데 큰 도움을 주신 개발자 Boris님, Wayne Kim 그리고 최종 문제에 인사이트를 주신 스터디를 같이 하고 있는 B님께 먼저 감사말씀을 올립니다.

 

나는 두분이 올려주신글을 참고하여 구현에 성공할 수 있었다.

두분이 올려주신 코드를 고대로 활용해서 구현에 성공했다면 너무 좋아겠지만 역시 인생이 쉽지않듯

저도 그대로 안되더라고요 ㅠㅠ 

그래서 저의 삽질기를 정리할겸 글을 써봅니다.

부족하지만 너그러운 마음으로 보셨으면 좋겠습니다.

 

 

 

바텀시트는 2개로 영역을 구분한다.

 

  • 바텀 시트 헤더
  • 바텀 시트 콘텐츠 

 

바텀 헤더

import React from "react";
import styled from "styled-components";
import bottomSheet from "src/assets/ic/bottomSheet.svg";

const Header = ({ isOpen, setIsOpen, sheet }) => {
  const handleClick = () => {
    setIsOpen(false);
    sheet.current.style.setProperty("top", "382px");
  };

  return (
    <HeaderWrapper>
      <div className="flex justify-center" onClick={handleClick}>
        {isOpen && <img src={bottomSheet} alt="화살표" />}
      </div>
    </HeaderWrapper>
  );
};

export default Header;

const HeaderWrapper = styled.div`
  height: 24px;
  border-top-left-radius: 12px;
  border-bottom-right-radius: 12px;
  position: relative;
  padding-top: 12px;
  padding-bottom: 4px;
`;

 

바텀시트

const BottomSheet = ({ data }) => {
  const { sheet, content, isOpen, setIsOpen } = useBottomSheet();

  return (
    <>
      <Wrapper ref={sheet} isOpen={isOpen}>
        <Header isOpen={isOpen} setIsOpen={setIsOpen} sheet={sheet} />
        <BottomSheetContent ref={content}>
          <div className="mb-2 text-18 font-semibold tracking-[-0.36px] text-gray9">
            최근 사용 요청서
          </div>
          <UsuallyRequestCard data={data} />
        </BottomSheetContent>
      </Wrapper>
    </>
  );
};

export default BottomSheet;

const Wrapper = styled(motion.div)`
  position: fixed;
  width: 100%;
  flex-grow: 1;
  top: ${({ isOpen }) => (isOpen ? `${MIN_Y}px` : "382px")};
  background: ${({ theme }) => theme.light};
  border-radius: 20px 20px 0 0;
  height: 100dvh;
  transition: top 500ms ease-out;
  display: flex;
  flex-direction: column;
  @media (min-width: 500px) {
    width: 500px;
  }
`;

const BottomSheetContent = styled.div`
  padding: 0px 20px 140px;
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
  flex-grow: 1;
`;

 

내가 도움을 받은 코드와의 차이점은 top이랑 height 다.

나의 경우 컨텐츠에 스크롤이 생기는 긴경우여서 height로 변화를 주면 왜그러진 모르겠지만 안에 컨텐츠 스크롤이 동작을 안했다.

도움받은 코드

 

그래서 엄청난 삽질이 시작됐다.

삽질을 하던중 스터디 하는 분께 도움을 요청했는데, 그분께서 개발자도구의 레이어라 도구를 알려주셨다.

레이어는 화면 전체로 볼 수 있다. 그래서 내 바텀시트가 어떻게 동작되는 지 정확히 볼 수 있었다.

화면을 계속 보다가 아예 top으로 위치를 끌어 오리면 될 것 같다는 생각이 들어서 적용해봤다.

그랬더니 정확히 내가 원하는 대로 동작도 하고 스크롤까지 무사히 동작했다.

 

 


 



useBottomSheet

바텀시는 정확히 이해 하기 위해서 이 코드를 이해하는게 굉장히 중요했다.

사실 아직 나도 잘 모르기에 기록하면서 한번 이해하려고 적는다.

import React, { useRef, useEffect, useState } from "react";

export const MIN_Y = 60; // 바텀시트가 최대로 올라 갔을때의 Y값
export const MAX_Y = window.innerHeight - 60; // 여백을 설정 // 바텀시트가 최대로 내려 갔을때의 Y값
export const BOTTOM_SHEET_HEIGHT = window.innerHeight - MIN_Y;

export default function useBottomSheet() {
  const sheet = useRef(null);
  const content = useRef(null);
  const [isOpen, setIsOpen] = useState(false);

  const metrics = useRef({
    touchStart: {
      sheetY: 0,
      touchY: 0,
    },
    touchMove: {
      prevTouchY: 0,
      movingDirection: "none",
    },
    isContentAreaTouched: false,
  });

  useEffect(() => {
    const canUserMoveBottomSheet = (e) => {
      const { touchMove, isContentAreaTouched } = metrics.current;

      if (!isContentAreaTouched) {
        return true;
      }

      if (sheet.current && sheet.current.getBoundingClientRect().y !== MIN_Y) {
        return true;
      }
      // 스크롤을 더 이상 올릴 것이 없다면, 바텀시트를 움직이는 것이 자연스럽습니다.
      // Safari 에서는 bounding 효과 때문에 scrollTop 이 음수가 될 수 있습니다. 따라서 0보다 작거나 같음 (<=)으로 검사합니다.

      if (
        touchMove.movingDirection === "down" &&
        content.current.scrollTop <= 10
      ) {
        sheet.current.style.setProperty("transform", "translateY(0)");
        sheet.current.style.setProperty("top", "382px");
        setIsOpen(false);
      }

      return false;
    };

    const handleTouchStart = (e) => {
      if (sheet.current) {
        const { touchStart } = metrics.current;
        touchStart.sheetY = sheet.current.getBoundingClientRect().y;
        touchStart.touchY = e.touches[0].clientY;
      }
    };

    const handleTouchMove = (e) => {
      const { touchStart, touchMove } = metrics.current;
      const currentTouch = e.touches[0];

      if (touchMove.prevTouchY === undefined) {
        touchMove.prevTouchY = touchStart.touchY;
      }

      if (touchMove.prevTouchY === 0) {
        touchMove.prevTouchY = touchStart.touchY;
      }

      if (touchMove.prevTouchY < currentTouch.clientY) {
        touchMove.movingDirection = "down";
      }

      if (touchMove.prevTouchY > currentTouch.clientY) {
        touchMove.movingDirection = "up";
      }

      if (canUserMoveBottomSheet(e)) {
        e.preventDefault();

        const touchOffset = currentTouch.clientY - touchStart.touchY;
        let nextSheetY = touchStart.sheetY + touchOffset;

        if (nextSheetY <= MIN_Y) {
          nextSheetY = MIN_Y;
        }

        if (nextSheetY >= MAX_Y) {
          nextSheetY = MAX_Y;
        }

        if (sheet.current) {
          sheet.current.style.setProperty("transform", `translateY(0px)`);
          // sheet.current.style.setProperty("top", "382px");
        }
      } else {
        // document.body.style.overflowY = "hidden";
      }
    };

    const handleTouchEnd = () => {
      // document.body.style.overflowY = "auto";
      const { touchMove } = metrics.current;

      if (sheet.current) {
        const currentSheetY = sheet.current.getBoundingClientRect().y;

        if (currentSheetY !== MIN_Y) {
          if (touchMove.movingDirection === "down") {
            sheet.current.style.setProperty("top", "382px");
            setIsOpen(false);
          }

          if (touchMove.movingDirection === "up") {
            sheet.current.style.setProperty("top", `${MIN_Y}px`);
            // sheet.current.style.setProperty("transform", `translateY(-330px)`);
            // sheet.current.style.setProperty(
            //   "transform",
            //   `translateY(${MIN_Y - MAX_Y}px)`,
            // );
            setIsOpen(true);
          }
        }
      }

      metrics.current = {
        touchStart: {
          sheetY: 0,
          touchY: 0,
        },
        touchMove: {
          prevTouchY: 0,
          movingDirection: "none",
        },
        isContentAreaTouched: false,
      };
    };

    const sheetElement = sheet.current;
    sheetElement?.addEventListener("touchstart", handleTouchStart);
    sheetElement?.addEventListener("touchmove", handleTouchMove);
    sheetElement?.addEventListener("touchend", handleTouchEnd);

    return () => {
      sheetElement?.removeEventListener("touchstart", handleTouchStart);
      sheetElement?.removeEventListener("touchmove", handleTouchMove);
      sheetElement?.removeEventListener("touchend", handleTouchEnd);
    };
  }, []);

  useEffect(() => {
    const handleTouchStart = () => {
      metrics.current.isContentAreaTouched = true;
    };

    const handleTouchEnd = () => {
      metrics.current.isContentAreaTouched = false;
    };

    content.current?.addEventListener("touchstart", handleTouchStart);

    return () => {
      content.current?.removeEventListener("touchstart", handleTouchStart);
    };
  }, []);

  return { sheet, content, isOpen, setIsOpen };
}

 

metrics 사용하는 값 설명

 

touchStart

  • sheetY: 바텀시트의 최상단 모서리 값
  • touchY: 내가 터치한 곳의 Y값

touchMove: 터치하고 움직일때

  • prevTouchY: 움직이는 동안의 Y값
  • movingDirection: 움직이는 방향

isContentAreaTouched: 컨텐츠 영역을 터치하고 있는지

 

자이제 사용한 코드를 분할해서 하나씩 설명해볼게요.

 


canUserMoveBottomSheet : 바텀시트가 움직일 수 있는지를 체크

useEffect(() => {
    const canUserMoveBottomSheet = (e) => {
      const { touchMove, isContentAreaTouched } = metrics.current;

      if (!isContentAreaTouched) {
        return true;
      }


      if (sheet.current && sheet.current.getBoundingClientRect().y !== MIN_Y) {
        return true;
      }
      

      if (
        touchMove.movingDirection === "down" &&
        content.current.scrollTop <= 10
      ) {
        sheet.current.style.setProperty("transform", "translateY(0)");
        sheet.current.style.setProperty("top", "382px");
        setIsOpen(false);
      }

      return false;
    };

 

  
 if (!isContentAreaTouched)
바텀시트에서 컨텐츠 영역이 아닌 부분은 터치하면 바텀시트를 움직인다.
 
if (sheet.current && sheet.current.getBoundingClientRect().y !== MIN_Y)
  바텀시트가 최대로 올라와 있는 상태가 아니면 ㅅ바텀시트를 움직일수 있다.


getBoundingClientRect() 설명

getBoundingClientRect() 메서드는 웹 개발에서 HTML 요소의 크기와 위치를 가져오는 데 사용되는 매우 유용한 JavaScript 메서드입니다. 이 메서드는 요소의 뷰포트에 대한 정보를 반환합니다. 이를 통해 요소의 위치와 크기를 정확히 파악할 수 있으며, 이 정보는 다양한 인터랙션이나 애니메이션을 구현할 때 매우 유용합니다.

반환 값

getBoundingClientRect() 메서드는 DOMRect 객체를 반환하며, 이 객체는 요소의 크기와 뷰포트 내 위치에 대한 다음과 같은 속성을 포함합니다:

  • left: 요소의 왼쪽 경계와 뷰포트의 왼쪽 경계 사이의 거리.
  • top: 요소의 위쪽 경계와 뷰포트의 위쪽 경계 사이의 거리.
  • right: 요소의 오른쪽 경계와 뷰포트의 왼쪽 경계 사이의 거리.
  • bottom: 요소의 아래쪽 경계와 뷰포트의 위쪽 경계 사이의 거리.
  • width: 요소의 너비.
  • height: 요소의 높이.
  • x: 요소의 왼쪽 경계와 뷰포트의 왼쪽 경계 사이의 거리 (left와 동일).
  • y: 요소의 위쪽 경계와 뷰포트의 위쪽 경계 사이의 거리 (top와 동일).

 

if (touchMove.movingDirection === "down" &&content.current.scrollTop <= 10)

터치방향을 아래로 내리고 scrollTop 이 10 보다 아래면 다시 원래 스타일대로 변경한다.

sheet.current.style.setProperty("transform", "translateY(0)");
sheet.current.style.setProperty("top", "382px");

 

 

handleTouchStart : touchstart시 실행되는 함수

    const handleTouchStart = (e) => {
      if (sheet.current) {
        const { touchStart } = metrics.current;
        touchStart.sheetY = sheet.current.getBoundingClientRect().y;
        touchStart.touchY = e.touches[0].clientY;
      }
    };

 

 touchStart.sheetY = sheet.current.getBoundingClientRect().y

 

  • sheet.current.getBoundingClientRect() 메서드를 호출하여 sheet 요소의 위치와 크기를 가져옵니다.
  • 이 중 y 속성은 요소의 상단 경계가 뷰포트의 상단 경계로부터 얼마나 떨어져 있는지를 나타냅니다.
  • 이 값을 touchStart.sheetY에 저장하여 터치가 시작될 때의 sheet 요소의 Y 좌표를 기록합니다.

touchStart.touchY = e.touches[0].clientY;

  • e.touches[0]는 터치 이벤트 객체에서 첫 번째 터치를 나타냅니다.
  • clientY는 터치 지점의 Y 좌표를 나타냅니다.
  • 이 값을 touchStart.touchY에 저장하여 터치가 시작될 때의 터치 지점의 Y 좌표를 기록합니다.

 

 

handleTouchMove : touchmove시 실행되는 함수

    const handleTouchMove = (e) => {
      const { touchStart, touchMove } = metrics.current;
      const currentTouch = e.touches[0];

      if (touchMove.prevTouchY === undefined) {
        touchMove.prevTouchY = touchStart.touchY;
      }

      if (touchMove.prevTouchY === 0) {
        touchMove.prevTouchY = touchStart.touchY;
      }

      if (touchMove.prevTouchY < currentTouch.clientY) {
        touchMove.movingDirection = "down";
      }

      if (touchMove.prevTouchY > currentTouch.clientY) {
        touchMove.movingDirection = "up";
      }

      if (canUserMoveBottomSheet(e)) {
        e.preventDefault();

        const touchOffset = currentTouch.clientY - touchStart.touchY;
        let nextSheetY = touchStart.sheetY + touchOffset;

        if (nextSheetY <= MIN_Y) {
          nextSheetY = MIN_Y;
        }

        if (nextSheetY >= MAX_Y) {
          nextSheetY = MAX_Y;
        }

        if (sheet.current) {
          sheet.current.style.setProperty("transform", `translateY(0px)`);
        }
      } else {
      }
    };

 

터치이동 초기화

if (touchMove.prevTouchY === undefined)
 { touchMove.prevTouchY = touchStart.touchY; }
if (touchMove.prevTouchY === 0)
 { touchMove.prevTouchY = touchStart.touchY; }
  • touchMove.prevTouchY가 정의되지 않았거나 0인 경우, 이를 touchStart.touchY로 초기화합니다. 이는 처음 터치 시작 지점을 기준으로 터치 이동을 추적하기 위함입니다.

이동 방향 설정

if (touchMove.prevTouchY < currentTouch.clientY) 
{ touchMove.movingDirection = "down"; }
if (touchMove.prevTouchY > currentTouch.clientY)
 { touchMove.movingDirection = "up"; }
  • 이전 터치 지점(touchMove.prevTouchY)과 현재 터치 지점(currentTouch.clientY)을 비교하여 이동 방향을 설정합니다.
    • 이전 터치 지점이 현재 터치 지점보다 작으면(prevTouchY < clientY) "down"으로 설정.
    • 이전 터치 지점이 현재 터치 지점보다 크면(prevTouchY > clientY) "up"으로 설정.
 

요소 이동 허용 여부 및 이동 처리

if (canUserMoveBottomSheet(e)) { 
e.preventDefault(); 
const touchOffset = currentTouch.clientY - touchStart.touchY; 
let nextSheetY = touchStart.sheetY + touchOffset;
if (nextSheetY <= MIN_Y) { nextSheetY = MIN_Y; }
if (nextSheetY >= MAX_Y) { nextSheetY = MAX_Y; }
if (sheet.current) { sheet.current.style.setProperty("transform", `translateY(0px)`); } } else { }
  • canUserMoveBottomSheet(e) 함수가 true를 반환하면, 요소를 이동시킬 수 있다고 판단하고, 기본 터치 이벤트를 방지(e.preventDefault())합니다.
  • touchOffset은 초기 터치 지점과 현재 터치 지점 간의 차이를 계산합니다.
  • nextSheetY는 요소의 새로운 Y 좌표로, 초기 요소의 Y 좌표(sheetY)에 touchOffset을 더한 값입니다.
  • nextSheetY가 최소 Y 좌표(MIN_Y)보다 작으면 MIN_Y로 설정하고, 최대 Y 좌표(MAX_Y)보다 크면 MAX_Y로 설정합니다.
  • sheet.current가 존재하면, 요소의 transform 속성을 translateY(0px)로 설정하여 요소의 위치를 업데이트합니다.

 

 

handleTouchEnd : touchend시 실행되는 함수

요소의 현재 위치 가져오기

if (sheet.current) { const currentSheetY = sheet.current.getBoundingClientRect().y;

  • sheet.current이 유효한지 확인하고, 유효하다면 sheet 요소의 현재 Y 좌표를 getBoundingClientRect().y를 통해 가져옵니다.
 

요소의 위치와 방향에 따른 처리

if (currentSheetY !== MIN_Y){ 
if (touchMove.movingDirection === "down") { 
sheet.current.style.setProperty("top", "382px"); setIsOpen(false); 
}

if (touchMove.movingDirection === "up") { 
sheet.current.style.setProperty("top", `${MIN_Y}px`); setIsOpen(true); } 
}
  • currentSheetY가 MIN_Y와 다른 경우, 즉 요소가 최소 위치에 있지 않은 경우에만 아래의 로직을 실행합니다.
  • 터치 이동 방향이 "down"이면:
    • sheet 요소의 top 속성을 "382px"로 설정하여 요소를 아래로 이동시킵니다.
    • setIsOpen(false)를 호출하여 요소의 상태를 닫힌 상태로 설정합니다.
  • 터치 이동 방향이 "up"이면:
    • sheet 요소의 top 속성을 MIN_Y로 설정하여 요소를 위로 이동시킵니다.
    • setIsOpen(true)를 호출하여 요소의 상태를 열린 상태로 설정합니다.

 


결과적으로 원하는 바텀시트를 구현하는 데 성공해 오랜만에 성취감을 느낀 기능이었다.

구현함에 있는 아래 첨부한 2분의 글이 없었다면 정말 힘들었을 거다. 

다시한번 이자리를 빌어 2분께 감사드린다.

항상 개발을 하다보면 고비를 만나는데 노력하다 보면 그 고비를 어찌어찌 넘어가는데 그게 개발의 참 매력인가 ㅋㅋㅋ

진짜 매일 매일이 도전이다 나는 행복한 삶을 살고 있다.





Boris님 글

 

리액트에서 Bottom Sheet 만들기

최근 프로젝트에서 Bottom Sheet를 만들어야 하는 일이 있었다. bottom sheet는 화면 아래단에 위치하며 스크롤로 펴고 닫을 수 있는 화면을 의미하는데 아래 사진을 보면 이해가 빠를 것이다. 출처 : ht

velog.io

 

Wayne Kim님 글

 

How to Make a Bottom Sheet for Web

TouchEvent를 이용하여 Bottom Sheet를 직접 만들어봅니다.

blog.mathpresso.com

 

 

 

 

반응형

댓글