-
Notifications
You must be signed in to change notification settings - Fork 2
[feat/#40] 바텀시트 구현 #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
155e729
f66346b
f921d8a
83ab5f2
ee13c9d
b62637e
a312aa6
30492d3
9bfce67
6e6fe58
827551d
7a6781e
33705c7
b3cff8a
cf6b7ad
beca89a
a08a17c
d4c4e11
dcf386b
b17484f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,54 +1,7 @@ | ||
| import * as styles from "./test.css"; | ||
| import { | ||
| ArrowLeft, | ||
| ArrowRight, | ||
| Banner, | ||
| Benefit, | ||
| BottomSheetHeart, | ||
| BottomSheetHeartFill, | ||
| ChatFloating, | ||
| Check, | ||
| ChevronDown, | ||
| ChevronRightRounded, | ||
| ChevronRightSharp, | ||
| ChevronUp, | ||
| Delete, | ||
| Home, | ||
| Logo, | ||
| MakerHeart, | ||
| MakerHeartFill, | ||
| Search, | ||
| Share, | ||
| ShoppingCart, | ||
| Star, | ||
| } from "./assets/svg"; | ||
| import BottomSheetTest from "@/shared/components/bottom-sheet/test/bottom-sheet-test"; | ||
|
|
||
| function App() { | ||
| return ( | ||
| <div className={styles.test}> | ||
| <MakerHeart width={24} height={24} /> | ||
| <MakerHeartFill width={24} height={24} /> | ||
| <BottomSheetHeart width={24} height={24} /> | ||
| <BottomSheetHeartFill width={50} height={50} /> | ||
| <ArrowLeft width={24} height={24} /> | ||
| <ArrowRight width={24} height={24} /> | ||
| <ChevronDown width={24} height={24} /> | ||
| <ChevronRightRounded width={24} height={24} /> | ||
| <ChevronRightSharp width={24} height={24} /> | ||
| <ChevronUp width={24} height={24} /> | ||
| <Banner width={200} height={200} /> | ||
| <Benefit width={24} height={24} /> | ||
| <ChatFloating width={24} height={24} /> | ||
| <Check width={24} height={24} /> | ||
| <Delete width={24} height={24} /> | ||
| <Home width={24} height={24} /> | ||
| <Logo width={24} height={24} /> | ||
| <Search width={24} height={24} /> | ||
| <Share width={24} height={24} /> | ||
| <ShoppingCart width={24} height={24} /> | ||
| <Star width={24} height={24} /> | ||
| </div> | ||
| ); | ||
| return <BottomSheetTest />; | ||
| } | ||
|
|
||
| export default App; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { style, keyframes } from "@vanilla-extract/css"; | ||
| import { color } from "@/shared/styles/tokens/color.css"; | ||
|
|
||
| // overlay 애니메이션 정의 | ||
| const fadeIn = keyframes({ | ||
| from: { opacity: 0 }, | ||
| to: { opacity: 1 }, | ||
| }); | ||
|
|
||
| // sheet 애니메이션 정의 | ||
| const slideUp = keyframes({ | ||
| from: { transform: "translateY(100%)" }, | ||
| to: { transform: "translateY(0)" }, | ||
| }); | ||
|
|
||
| // overlay: 전체 바텀 시트의 레이아웃 컨테이너 | ||
| // backdrop과 sheet의 부모 컨테이너, flex-end로 sheet를 화면 하단에 배치 | ||
| export const overlay = style({ | ||
| position: "fixed", | ||
| top: 0, | ||
| left: 0, | ||
| right: 0, | ||
| bottom: 0, | ||
| zIndex: 1000, // TODO: zIndex 토큰화 | ||
| display: "flex", | ||
| alignItems: "flex-end", // bottom sheet는 화면 아래에서 올라와야 하므로, overlay의 bottom에 위치시켜야 함 | ||
| justifyContent: "center", | ||
| animation: `${fadeIn} 0.2s ease-out`, | ||
| }); | ||
|
|
||
| // 반투명 검은 배경, 사용자 클릭 시 바텀시트 닫는 용도 | ||
| export const backdrop = style({ | ||
| position: "absolute", | ||
| top: 0, | ||
| left: 0, | ||
| right: 0, | ||
| bottom: 0, | ||
| backgroundColor: "rgba(0, 0, 0, 0.35)", | ||
| }); | ||
|
|
||
| // 실제 바텀시트(흰색 박스)) | ||
| export const sheet = style({ | ||
| position: "relative", // overlay 기준으로 상대적인 위치 설정 | ||
| width: "100%", | ||
| maxHeight: "90dvh", // | ||
| backgroundColor: color.white[100], | ||
| borderTopLeftRadius: "16px", | ||
| borderTopRightRadius: "16px", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. px out
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. border-radius는 의도적으로
반면
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 와 죄송합니다 단순 오타인 줄 알고 px out 이렇게 남겼는데 이런 뜻이 있었군요 이 무지함에 야유를......;;;; 그럼에도 친절한 설명 감사합니다 ㅜㅜ!!!!!! 완전히 이해 되었습니다 |
||
| display: "flex", | ||
| flexDirection: "column", | ||
| zIndex: 1001, // TODO: zIndex 토큰화 | ||
| animation: `${slideUp} 0.3s ease-out`, | ||
| }); | ||
|
|
||
| export const content = style({ | ||
| padding: "0 1.6rem 1.6rem 1.6rem", | ||
| overflowY: "auto", | ||
| flex: 1, // sheet가 column이므로 content가 sheet의 모든 공간을 차지하도록 설정 | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import React, { useEffect } from "react"; | ||
| import { createPortal } from "react-dom"; | ||
| import * as styles from "./bottom-sheet.css"; | ||
| import { useBottomSheetDrag } from "./hooks/use-bottom-sheet-drag"; | ||
| import DragHandler from "@/shared/components/drag-handler/drag-handler"; | ||
|
|
||
| interface Props { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| const BottomSheet = ({ isOpen, onClose, children }: Props) => { | ||
| const { | ||
| sheetRef, | ||
| contentRef, | ||
| handleTouchStart, | ||
| handleTouchMove, | ||
| handleTouchEnd, | ||
| handleDragHandlerMove, | ||
| } = useBottomSheetDrag({ onClose }); | ||
|
|
||
| // 바텀시트가 열렸을 때 뒷배경 스크롤 방지 | ||
| useEffect(() => { | ||
| if (isOpen) { | ||
| const originalOverflow = document.body.style.overflow; | ||
| document.body.style.overflow = "hidden"; | ||
|
|
||
| return () => { | ||
| // isOpen이 변경되거나 컴포넌트가 언마운트될 때 실행, body의 overflow를 원래 값으로 복구 | ||
| document.body.style.overflow = originalOverflow; | ||
| }; | ||
| } | ||
| }, [isOpen]); | ||
|
|
||
| if (!isOpen) return null; | ||
|
|
||
| return createPortal( | ||
| // TODO: 접근성 고려 | ||
| <div className={styles.overlay}> | ||
| <div className={styles.backdrop} onClick={onClose} /> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 시간 여유가 있다면!! overlay/backdrop 클릭 시 바텀시트가 바로 사라지지 않고 부드럽게 아래로 내려가는 애니메이션 후 닫히도록 개선하면 UX가 더 자연스러울 것 같아요! 현재는 클릭 즉시 unmount되는데, isClosing 같은 상태를 추가해서 애니메이션 완료 후 onClose()가 호출되도록 하면 드래그로 닫을 때와 일관된 모션을 제공할 수 있을 것 같습니닷. |
||
| <div ref={sheetRef} className={styles.sheet}> | ||
| <DragHandler | ||
| onTouchStart={handleTouchStart} | ||
| onTouchMove={handleDragHandlerMove} | ||
| onTouchEnd={handleTouchEnd} | ||
| /> | ||
| <div | ||
| ref={contentRef} | ||
| className={styles.content} | ||
| onTouchStart={handleTouchStart} | ||
| onTouchMove={handleTouchMove} | ||
| onTouchEnd={handleTouchEnd}> | ||
| {children} | ||
| </div> | ||
| </div> | ||
| </div>, | ||
| document.body | ||
| ); | ||
| }; | ||
|
|
||
| export default BottomSheet; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 병합 충돌 방지하기 위해 임시로 bottom-sheet 폴더 아래에 bottom-sheet용 hook 폴더를 위치시키신 걸까요??
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 바텀시트에서만 사용되는 훅이라 바텀시트 구현 코드와 함께 묶이는게 좋을 거라고 생각해 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import React, { useRef, useState } from "react"; | ||
|
|
||
| interface UseBottomSheetDragProps { | ||
| onClose: () => void; | ||
| } | ||
|
|
||
| // 상수 정의 | ||
| const CLOSE_THRESHOLD = 200; // sheet을 닫기 위한 최소 드래그 거리 (px) | ||
| const CLOSE_TRANSITION_DURATION = 200; // sheet 닫힐 때 transition 시간 (ms) | ||
| const RETURN_TRANSITION_DURATION = 300; // sheet 원위치 복귀 시 transition 시간 (ms) | ||
|
|
||
| export const useBottomSheetDrag = ({ onClose }: UseBottomSheetDragProps) => { | ||
| const sheetRef = useRef<HTMLDivElement>(null); | ||
| const contentRef = useRef<HTMLDivElement>(null); | ||
| const [startY, setStartY] = useState(0); // 드래그를 시작한 y좌표 | ||
| const [sheetDragStartY, setSheetDragStartY] = useState<number | null>(null); // sheet을 내리기 시작한 y좌표 | ||
jstar000 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const [dragDistance, setDragDistance] = useState(0); // sheet이 내려간 거리 | ||
| const [isDragging, setIsDragging] = useState(false); | ||
|
||
|
|
||
| // 드래그 시작 | ||
| const handleTouchStart = (e: React.TouchEvent) => { | ||
| setStartY(e.touches[0].clientY); // 드래그를 시작한 y좌표 세팅 | ||
| setIsDragging(true); | ||
| }; | ||
|
|
||
| // sheet을 드래그하는 로직 | ||
| const moveSheet = (currentY: number) => { | ||
| if (sheetDragStartY === null) { | ||
| setSheetDragStartY(currentY); | ||
| } | ||
|
|
||
| const sheetDeltaY = currentY - (sheetDragStartY ?? currentY); | ||
| setDragDistance(sheetDeltaY); | ||
|
|
||
| if (sheetRef.current) { | ||
| sheetRef.current.style.transition = "none"; // handleTouchEnd에서 추가된 transition 제거 -> sheet가 드래그에 즉각 반응 | ||
| sheetRef.current.style.transform = `translateY(${sheetDeltaY}px)`; | ||
| } | ||
| }; | ||
|
|
||
| // content 영역 드래그 | ||
| const handleTouchMove = (e: React.TouchEvent) => { | ||
| if (!isDragging) return; | ||
|
|
||
| const currentY = e.touches[0].clientY; // 현재 드래그 y좌표 | ||
| const deltaY = currentY - startY; // 드래그 이동 거리 | ||
|
|
||
| // 드래그가 위에서 아래로 진행될 때 if문 진입(content를 위로 스크롤하거나, 바텀시트를 닫기 위한 드래그 액션) | ||
| if (deltaY > 0) { | ||
| // content의 최상단에 도달했을때 if문 진입 및 sheet 드래그 | ||
| if (contentRef.current && contentRef.current.scrollTop === 0) { | ||
| moveSheet(currentY); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // drag handler 영역 드래그 | ||
| const handleDragHandlerMove = (e: React.TouchEvent) => { | ||
| if (!isDragging) return; | ||
|
|
||
| const currentY = e.touches[0].clientY; | ||
| const deltaY = currentY - startY; | ||
|
|
||
| if (deltaY > 0) { | ||
| // drag handler 조작 시 scrollTop 체크 없이 바로 sheet 드래그 | ||
| moveSheet(currentY); | ||
| } | ||
| }; | ||
|
|
||
| // 드래그 종료 | ||
| const handleTouchEnd = () => { | ||
| setIsDragging(false); | ||
|
|
||
| if (sheetRef.current) { | ||
| // CLOSE_THRESHOLD 이상 드래그하면 닫기 | ||
| if (dragDistance > CLOSE_THRESHOLD) { | ||
| // 부드럽게 아래로 내려가며 닫기 | ||
| sheetRef.current.style.transition = `transform ${CLOSE_TRANSITION_DURATION}ms ease-in`; | ||
| sheetRef.current.style.transform = "translateY(100%)"; | ||
|
|
||
| // transition 완료 후 onClose 호출 | ||
| setTimeout(() => { | ||
| onClose(); | ||
| }, CLOSE_TRANSITION_DURATION); | ||
| } else { | ||
| // sheet 원위치로 복귀 | ||
| sheetRef.current.style.transition = `transform ${RETURN_TRANSITION_DURATION}ms ease-out`; | ||
| sheetRef.current.style.transform = "translateY(0)"; | ||
| } | ||
| } | ||
|
|
||
| setDragDistance(0); | ||
| setSheetDragStartY(null); | ||
| }; | ||
|
|
||
| return { | ||
| sheetRef, | ||
| contentRef, | ||
| handleTouchStart, | ||
| handleTouchMove, | ||
| handleTouchEnd, | ||
| handleDragHandlerMove, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| // 바텀시트 테스트용 | ||
| // 추후 삭제 예정 | ||
|
|
||
| import { style } from "@vanilla-extract/css"; | ||
| import { typographyVars } from "@/shared/styles/typography.css"; | ||
| import { color } from "@/shared/styles/tokens/color.css"; | ||
|
|
||
| export const container = style({ | ||
| padding: "2rem", | ||
| minHeight: "100vh", | ||
| }); | ||
|
|
||
| export const title = style({ | ||
| ...typographyVars.heading1, | ||
| marginBottom: "2rem", | ||
| color: color.black[200], | ||
| }); | ||
|
|
||
| export const openButton = style({ | ||
| padding: "1.2rem 2.4rem", | ||
| backgroundColor: color.black[200], | ||
| color: color.white[100], | ||
| border: "none", | ||
| borderRadius: "8px", | ||
| ...typographyVars.body2, | ||
| marginBottom: "2rem", | ||
| }); | ||
|
|
||
| export const scrollContent = style({ | ||
| marginTop: "2rem", | ||
| }); | ||
|
|
||
| export const paragraph = style({ | ||
| ...typographyVars.body3, | ||
| color: color.gray[100], | ||
| marginBottom: "1rem", | ||
| }); | ||
|
|
||
| export const sheetTitle = style({ | ||
| ...typographyVars.caption1, | ||
| color: color.black[200], | ||
| marginBottom: "1rem", | ||
| }); | ||
|
|
||
| export const sheetDescription = style({ | ||
| ...typographyVars.body3, | ||
| color: color.gray[100], | ||
| marginBottom: "2rem", | ||
| }); | ||
|
|
||
| export const sheetContent = style({ | ||
| display: "flex", | ||
| flexDirection: "column", | ||
| gap: "1rem", | ||
| }); | ||
|
|
||
| export const sheetItem = style({ | ||
| padding: "1.6rem", | ||
| backgroundColor: color.white[200], | ||
| borderRadius: "8px", | ||
| ...typographyVars.body3, | ||
| color: color.black[200], | ||
| border: `1px solid ${color.gray[300]}`, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // 바텀시트 테스트용 | ||
| // 추후 삭제 예정 | ||
|
|
||
| import { useState } from "react"; | ||
| import BottomSheet from "../bottom-sheet"; | ||
| import * as styles from "./bottom-sheet-test.css"; | ||
|
|
||
| const BottomSheetTest = () => { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
|
|
||
| const handleOpen = () => setIsOpen(true); | ||
| const handleClose = () => setIsOpen(false); | ||
|
|
||
| return ( | ||
| <div className={styles.container}> | ||
| <h1 className={styles.title}>BottomSheet Test Page</h1> | ||
| <button className={styles.openButton} onClick={handleOpen}> | ||
| 바텀시트 열기 | ||
| </button> | ||
|
|
||
| <div className={styles.scrollContent}> | ||
| {Array.from({ length: 50 }, (_, i) => ( | ||
| <p key={i} className={styles.paragraph}> | ||
| 뒷배경 스크롤 테스트 {i + 1} | ||
| </p> | ||
| ))} | ||
| </div> | ||
|
|
||
| <BottomSheet isOpen={isOpen} onClose={handleClose}> | ||
| <h2 className={styles.sheetTitle}>바텀시트 제목</h2> | ||
| <p className={styles.sheetDescription}> | ||
| 아래로 드래그하거나 뒷배경을 탭하면 닫힘 | ||
| </p> | ||
| <div className={styles.sheetContent}> | ||
| {Array.from({ length: 30 }, (_, i) => ( | ||
| <div key={i} className={styles.sheetItem}> | ||
| {i + 1} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </BottomSheet> | ||
|
Comment on lines
+29
to
+41
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. <BottomSheet>
<BottomSheet.Trigger>
<button>바텀시트 열기</button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header>바텀시트 제목</BottomSheet.Header>
<BottomSheet.Description>
아래로 드래그하거나 뒷배경을 탭하면 닫힘
</BottomSheet.Description>
. . .
</BottomSheet.Content>
</BottomSheet>요런 인터페이스를 가지면 느좋일듯해요.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 하 이까지 찾아와주셔서 리뷰까지 남겨주시고 감사합니다ㅠㅠ 작성해주신 Compound Component 패턴도 미나미미나때 다뤘었던 내용이랑 밀접한 관련이 있었네요 '어떻게' 대신 '무엇을' 할 지 집중할 수 있도록 해주는 선언적인 구현 방식! 현재
Compound 방식 <BottomSheet>
<BottomSheet.Trigger>
<button>열기</button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header>제목</BottomSheet.Header>
<BottomSheet.Description>설명</BottomSheet.Description>
<div>본문</div>
</BottomSheet.Content>
</BottomSheet>=> 사용처에서는 무엇을 렌더링할 지만 선언하면 됨 => 바텀시트 내부 동작(드래그, 애니메이이션 로직, 이벤트 핸들링 등)은 절차적일 수밖에 없으므로 절차적으로 구현, 사용처에서는 Compound Component 등 적절한 구현방식을 사용해 절차적인 내부 동작을 감추고 깔끔한 인터페이스만 노출하도록 수정해보겠습니다! 합세 끝나고 꼬옥 적용해볼게요 지금 수정하면 API 연결 못할 듯...ㅋㅋㅋ 미미나 듣고 어떤 상황에서 선언적으로 구현하는게 적합할지, 어떤 상황에서 절차적으로 구현하는게 적합할지에 대해서 합세 코드 다시 보면서 고민해봤는데, 합세에서 담당한 컴포넌트 지연로딩 구현은 <LazySection fallback={<LoadingFallback />}>
<ProductDetail />
</LazySection>이렇게 구현해 사용하는 쪽에서 선언적으로 사용할 수 있도록 구현, 내부로직은 절차적으로 구현한 것 같아요. 사실 선언적/절차적을 의식하고 구현한 건 아니었는데 미미나 듣고, 달아주신 리뷰 확인하고, 구현한 코드 다시 확인해보니 눈에 들어오네요ㅎㅎ 어떻게 구현해야할 지에 대해 큰~~ 깨달음을 주셨습니다 강민하사랑해 그리고 선생님저 질문이 생겼는데
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
그쵸. 결국에 우리가 어떤 코드를 써도 결국엔 컴퓨터(cpu)의 절차적인 명령어로 처리가 됨.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아하 이해했어요 앞으로 선언적/절차적 신경쓰고 추상화 레벨, 책임 분리, 캡슐화 고려하면서 구현해보겠습니다ㅎㅎ 리뷰 감사합니다~~ |
||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default BottomSheetTest; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sheet이 overlay를 기준으로 너비가 100으로 설정 되어서 아래와 같이 pc로 접속했을 때는 전체 화면을 차지하는 것 같아요!
두 가지 선택지가 있을 텐데 저는 후자로 통일감을 주는 게 낫다고 생각하는데 어떻게 생각하시나요??
(혹시 이거 의도하신 거라면 이유를 설명해 주시면 될 것 같습니다 ㅎㅎㅎ)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저희가 모바일 웹앱이라 컴포넌트를 구현할 때 웹 환경까지 고려해서 구현하지는 않아서
width: 100%로 설정해뒀습니다..!overlay는 backdrop과 sheet 자식 컴포넌트를 담는 용도로만 사용하고, 크기는 자식 컴포넌트들에서 조절하면 좋을 것 같아 sheet의 width를
375px로 변경했어요!