Bottom Sheet? Drawer? 부르는 명칭이 정확하진 않지만 머 여튼 이런 식으로 하단에 위치해있고, 드래그를 하면 추가적인 정보를 확인할 수 있는 UI를 일컫는다. 보통 모바일 앱에서 많이 사용하는 UI!
이번에도 역시 많은 삽질을 했다 ... 😇 차근차근 구현 과정을 기록해보겠다.
나는 다음과 같이 구조를 잡았다.
Back Layer는 배경으로 깔릴 View을 말한다. 여기에 BackgroundOverlay 같은 걸 깔아서 후처리를 해주려고 했는데,, 이것도 후에 서술.
Header는 열리지 않았을 때 보일 부분을 지칭한다. 여기에는 잡을 수 있다는 표시인 Handle도 포함되어있다.
Content는 실제로 Bottom Sheet에 담길 콘텐츠를 의미한다.
DOM 구조는 다음과 같이 잡았다.
const BottomSheet = () => {
return (
<>
{/* <BackgroundOverlay />*/}
<SheetBackground>
<BottomHeader>
<HandleBar />
</BottomHeader>
<SheetContentWrapper>
<SheetContent>까꿍 안녕</SheetContent>
</SheetContentWrapper>
</SheetBackground>
</>
);
};
그리고 스타일을 잡아줬는데, 포인트만 집어보겠다.
const SheetBackground = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100lvh;
background: white;
box-shadow: 0 0 10px 1px rgba(0, 0, 0, 0.5);
border-radius: 24px 24px 0 0;
padding: 12px 0 24px 0;
will-change: transform;
`;
position을 absolute로 두고 top과 left의 값을 각각 0으로 지정해주었다. 이게 또 중요한 포인트. 나중에 후술하겠지만 position을 top으로 둘 것이냐, bottom으로 둘 것이냐에 따라 계산하는 값이 달라지기 때문에 이를 알고있어야 한다. 덕분에 애 좀 먹었다
또~ height를 100lvh로 잡아주었다. 이건 두 방향이 있었는데, 첫 번째로 content의 영역만큼만 보여주고 싶어서 fit-content로 해주었다. 그랬더니 아래와 같은 짤림이 나오드라요.
요게 싫어서 content의 크기가 어떻든 100lvh로 잡으면 짤림이 나오진 않겠다 싶어서 최대로 지정해주었다.
마지막으로 will-change를 지정해주었다. 난 넘 속상한 게 컴퓨터에서는 잘 되는데 모바일에서 확인해보면 부드럽지가 않아서😢 요 속성이라도 넣으면 부드럽게 동작할까 싶어서. 느낌상 더 부드러운 것 같기도 하고 .... ?
사실 sheet가 열리면 뒤에 overlay 배경이 깔린다.
하지만 아까도 말했다시피 모바일에선 버벅임이 엄청 심해서, 효과가 많아서 그런가 싶어서 빼둔 상태이다. 아니면 그냥 내 폰이 안 좋은 것일 수도...^^!
const BackgroundOverlay = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100dvh;
background: black;
`;
<BackgroundOverlay
variants={{
opened: {
backdropFilter: 'blur(1px)',
pointerEvents: 'all',
opacity: 0.7,
},
closed: {
backdropFilter: 'blur(0px)',
pointerEvents: 'none',
opacity: 0,
},
}}
/>
열리면 backdrop-filter로 blur를 넣고, opacity를 0.7로 맞춰준다. 닫히면 초기화하고 pointer-events를 none으로 바꿔준다. none으로 바꾸주지 않으면 투명해도 뒷 배경이 클릭되지 않는다.
Bottom Sheet는 기본적으로 위 아래로 움직인다. 이를 위해 framer motion에서 제공하는 drag 속성을 써먹었다. drag 속성값에 y를 주면 Direction Lock이 가능해서 X방향은 고정되고 Y방향으로만 움직일 수 있다.
<SheetBackground drag="y" />
쓩~ㅋㅋ
저렇게 날아가면 안 되기 때문에 움직임에 제한을 두어야 한다. 그 속성이 바로 dragConstraints이다.
<SheetBackground drag="y" dragConstraints={{ top: 0, bottom: 0 }} />
그런 다음 dragElastic 이라는 속성도 지정해야 한다. 설정해놓은 제약범위가 지나면 바로 Drag를 중지시키는 게 아니라, 제약 범위에서 벗어날수록 제약 범위로 들어가려고 하는 탄성이라고 해야하나? 그런 걸 설정해주는 것. 이걸 설정해주어야 어느 정도 끌려가는 모션을 줘서 사용자에게 열고 있다는 피드백을 줄 수 있다.
설정하지 않아도 default 값으로 0.5가 설정되어 있는데, 좀 줄이는 게 내 취향에 맞더라(ㅋㅋ)
▼ dragElastic: false
제약 범위를 벗어나면 아예 안 움직인다. (근데 이것도 좀 자체 버그가 있는지 velocity가 있는 드래그면 pointerUp 될 때 0.01초정도 움직였다가 돌아온다)
▼ dragElastic: 0.5 (default)
기본값. 어느정도 부드럽게 따라온다. 엘라스틴 했어요~
▼ dragElastic: 0.2
내가 선호하는 값. 적당한 저항감이 마음에 들어~
이제 저렇게 땡기면 위로 열리든지, 아래로 닫히든지 둘 중 하나의 상태를 가지게 된다. 각 상태에 따라서 어디에 위치할지를 정해주어야 한다.
먼저 어디에 위치할지를 정하기 전에 알아봐야 할 것이 있다. 앞서 언급했던 position에 관한 것.
HTML Element 객체에는 위치를 조정할 수 있는 속성이 존재한다. top/bottom/left/right. 각각의 속성은 position이라는 속성에 따라 의미하는 게 달라진다.
Position: static일 때
아무런 효과를 가지지 않는다. 속성을 지정해도 영향이 없다.
Position: relative일 때
해당 객체가 가진 위치에서 상대적인 offset 값을 가지게 된다. 현재 객체가 위에서부터 문서 흐름에 의해 100px에 위치해 있고, 여기에 top: -10px를 지정하면 100px에서부터 10px위인 90px이 된다.
Position: absolute일 때
absolute가 되면 문서 흐름에서 벗어나고, 레이아웃 영역에서도 차지하지 않는다. 다만, 부모들 중에서 relative 속성을 가진 제일 가까운 부모를 기준으로 위치를 잡게 된다.
또, 움직임을 설정할 수 있는 속성 중에 transform이라는 속성이 있다. 요 속성은 문서 흐름에 상관없이 위치를 조정할 수 있다. 위치 뿐만 아니라 다른 시각적인 효과도 가능하고, 보이는 모습만 변경시키는 거라 실제 위치는 고정되어 있다. (레이아웃에 영향을 주지 않는다)
무엇보다 이 transform 속성은 GPU 가속을 통해 계산되기 때문에 더 부드러운 애니메이션이 가능하다. 다만, transform 속성에 들어가는 모든 효과가 GPU 가속을 하는 건 아니고 3D가 들어가는 효과들에서 가속을 한다. 2D Coordinate 자체에선 적용되지 않는다. 그래서 꼼수로 Z축을 그냥 0으로만 두고 가속 이점을 누리기도 하는 듯. (framer motion도 그렇다)
아무튼 요는 둘 다 움직임에 대한 것이므로 잘 생각하지 않으면 원하는 대로 위치를 잡을 수 없다. 그래서 초기 위치를 잘 잡아야 한다.
BottomSheet는 문서 흐름에 벗어난 객체이므로 absolute로 지정해주고 시작해야 한다. 그리고 여기에서 생각을 해봐야 할 것이, 어디를 기준을 잡고 위치를 조정하는 것이 좋을 것이냐는 점이다.
내가 생각한 기준점은 맨 위 / 맨 아래 둘 중 하나였다. 맨 위로 잡으면 정방향이라고 해야 하나, 위치 자체가 위에서 아래로 흐르는 값을 가지고 있기 때문에 알아보기가 편할 것 같았다. 맨 아래로 잡으면 눈에 보이는 그대로 보여주는 height만 지정해주면 될 것 같아서 계산하기 편할 것 같았다.
처음으로 시도한 건 맨 아래였다. bottom: 0으로 지정해주고 위치를 잡아주면 되겠다 생각했는데, 100vh에 대한 이슈가 있어서, 이를 bottom을 사용해서 대응을 하려면 꽤... 정말 꽤나 까다로웠다. 사실 translateY 속성과 맞물려서 위치가 오락가락 해서 더 어려웠던 것 같기도
아, 그리고 bottom의 기준은 부모의 height이고, 거기 안쪽에 들러붙는 거다. 그러니까 부모 바깥에서 0이 시작하는 게 아니라, 부모의 height - 내 height가 시작인 것.
이렇게 말이지... 그러니 계산이 좀 많이 복잡해지더라^^* 따라서 bottom이 아니라 top을 잡고 위치를 잡기 시작했다.
먼저 closed 상태에 따른 위치를 잡아야 했다. 목적은 Bottom Sheet의 Header만 보이는 것이었으므로 계산을 하면 다음과 같았다.
즉, 100dvh에서 header Height만큼 빼준 값이 되겠다. 계산은 calc 함수를 이용했다.
<BottomSheet style={{ top: "calc(100dvh - headerHeight)" }} />
그 다음 요게 열리면 어디까지 열리는지 정해야 한다. 특정 높이까지 올라가게 만들어야 하나 싶었는데, 그렇게 고정된 값으로 설정하는 것보단 좀 담긴 콘텐츠 내용에 따라서 유연하게 만들고 싶었다.
그래서 콘텐츠의 크기를 측정해서, 그 만큼 보여질 수 있게 설정하였고, 만약 그 콘텐츠의 크기가 100dvh - headerHeight 를 넘는다면 최대 크기를 정해주는 것이 좋겠다고 생각했다.
const [contentRef, contentBounds] = useMeasure();
const headerHeight = 50
const expandedHeight = useMemo(
() => Math.min(contentBounds.height + headerHeight, window.innerHeight - headerHeight),
[contentBounds.height]
);
<BottomSheet style={{ top: "calc(100dvh - expandedHeight)" }}>
<ContentWrapper ref={contentRef}>
<SheetContent>
까꿍 안녕
</SheetContent>
</ContentWrapper>
</BottomSheet>
Math.min() 메소드를 이용해서 콘텐츠의 크기와 최대 뷰포트 크기 중에 더 작은 값을 선택하도록 했다. 그리고 이 값은 변경될 일이 없으니 memorize 해주었다.
각각의 위치를 알았으니, 이제 그 위치 사이를 애니메이팅 테스트를 해야한다. 가볍게 테스트해보기 위해서 onTap 이벤트에서 상태를 바꿔보겠다.
const [isOpened, setIsOpened] = useState(false);
return (
<SheetBackground
animate={
isOpened
? { top: `calc(100dvh - ${expandedHeight}px)` }
: { top: `calc(100dvh - 50px)` }
}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.2}
onTap={() => setIsOpned(!isOpened)}
>
<BottomHeader>
<HandleBar style={{ borderRadius: 9999 }} />
</BottomHeader>
<SheetContentWrapper
style={{ height: 500 }}
ref={contentRef}
>
<SheetContent>까꿍 안녕</SheetContent>
</SheetContentWrapper>
</SheetBackground>
)
잘 작동한다!
이제 이것을 label 기반의 framer motion animation으로 만들어보자. 먼저 animate에 대한 state가 있어야 한다.
const [isOpened, setIsOpened] = useState(false);
const animateState = isOpened ? "opened" : "closed";
그리고 이것을 animate 속성에 넣어주고, 각 상태에 대한 variants를 지정해준다.
<SheetBackground
animate={animateState}
variants={{
opened: { top: `calc(100dvh - ${expandedHeight}px)` },
closed: { top: `calc(100dvh - 60px)` },
}}
/>
이렇게 되면~ animate에 어떤 label이 들어오냐에 따라 애니메이션이 바뀌게 되고, 해당 애니메이션에 대한 정보는 variants에서 각각 확인할 수 있다.
상태가 바뀔, 그러니까 드래그를 했을 때 상태를 바꿀 조건은 다음과 같다.
상태 변경 조건
- 드래그를 한 양이 일정 수준을 넘을 것.
- 넘지 않더라도, velocity가 높은 것.
이 위의 조건을 만족한다면 사용자가 상태를 변경시킬 의지가 있는 것으로 판단하고 상태를 변경하면 된다. 이 트리거는 Framer Motion에서 제공하는 onDragEnd에서 처리하도록 하겠다.
onDragEnd={(event, info) => {
// y가 음수이면 위로, 양수이면 아래로
const offsetThreshold = 150;
const deltaThreshold = 5;
const isOverOffsetThreshold = Math.abs(info.offset.y) > offsetThreshold;
const isOverDeltaThreshold = Math.abs(info.delta.y) > deltaThreshold;
const isOverThreshold = isOverOffsetThreshold || isOverDeltaThreshold;
if (!isOverThreshold) return;
const newIsOpened = info.offset.y < 0;
setIsOpened(newIsOpened);
}}
onDragEnd에 넘어오는 info는 PanInfo이다. 드래그에 대한 여러 정보들이 담겨있는데, 여기에서 offset은 이동한 양을 가리키고, delta는 미분값(ㅋㅋ)을 가리킨다. 그러니까, 순간적으로 변하는 양이 크다면 delta의 값이 커진다.
알아야 할 것은 여기에 offset이나 delta는 방향을 가진다. 양수면 아래로 향하는 것이고, 음수면 위로 향하는 것이다. 어디까지나 기준은 포인터가 눌렸을 때의 y값이 기준이므로, 위로 간다면 y값이 작아지니 당연히 음수가 나올 것이며, 아래로 간다면 y값이 커지지 양수가 나올 것이다.
따라서~ 임계값을 넘는지 아닌지 자체는 절대값을 이용해서 판단하면 되고, 이 액션이 열려는 행위인지 닫으려는 행위인지는 양수인지 음수인지를 판단하면 된다. 이는 offset이나 delta 둘 다 마찬가지이므로 하나만 판단하면 된다.
Resizing이 이뤄지고 나면 dragConstraints이 갑자기 뿅 사라진다. 사실 로직 문제라기보단 Framer motion 자체의 문제이다. Framer Motion은 얼른 이슈 해결을 해라! ㅠㅠ
이걸 좀 더 발전시켜서 npm에 업로드를 한번 해볼까.. 싶기도!
현재 BottomSheet는 전체가 Draggable 한 상태이다. 그래서 자식 콘텐츠를 드래그해도 이벤트 전파가 되어서 drag가 된다.
그러므로, 자식 콘텐츠를 드래그할 필요는 없으므로 아래와 같은 방식으로 드래그를 막았었다.
<SheetContentWrapper
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={false}
style={{ height: 500, zIndex: 999 }}
ref={contentRef}
>
<SheetContent>{children}</SheetContent>
</SheetContentWrapper>
자식 콘텐츠 자체에도 drag를 먹여서 이벤트를 뺏어오는 방법이었다. 움직이지 않게 constraints를 먹이고, elastic을 false로 멕이면 고정된다.
다만,, 이 방법을 쓰면 정적인 콘텐츠는 괜찮겠지만, 콘텐츠 내부에서 터치 및 드래그와 관련된 기능이 존재한다면 요 wrapper가 이벤트를 전파받아서 콘텐츠 내부의 이벤트가 진행되지 않는다.
요 현상을 막으려면 조금 목적의식을 바꿔야 한다. '자식 콘텐츠 드래그 방지'가 아니라, '필요한 부분에만 드래그하기'로.
즉, BottomSheet 전체가 아니라 Header에만 드래그가 먹히게 하면 되는 것이다. 방법은 다음과 같다.
const dragControls = useDragControls();
<BottomSheet
dragControls={dragControls}
dragListener={false}
>
<BottomHeader onPointerDown={(e) => dragControls.start(e)}>
<HandleBar style={{ borderRadius: 9999 }} />
</BottomHeader>
<SheetContentWrapper>
<SheetContent>{children}</SheetContent>
</SheetContentWrapper>
</BottomSheet>
Framer Motion에서 제공하는 useDragControls 훅을 사용해서, drag 이벤트가 일어나는 시점을 manually 지정해주는 방법이다. Header 자체를 드래그하기 때문에 user-select 속성을 none으로 바꿔줘야 부드럽게 작동한다.
진짜 이거 때문에 개삽질했네...^^* 난 오늘도 성장했다....
안녕하세요 주현님 7개월차 FE 코린이입니다..! 이번에 하는 프로젝트에서 바텀시트를 처음으로 구현해보는중이라 시행착오가 많은데 글 보고 많은 도움 되고 있습니다! 혹시 실례가 안된다면 바텀시트 구현하신 코드 깃허브 링크 공유 해주실수 있나요??