From 13eaee555e7983aa8b54b275ace0ba409cfd8e1f Mon Sep 17 00:00:00 2001 From: solo5star Date: Fri, 29 Sep 2023 17:52:39 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=84=B0=EC=B9=98=ED=8C=A8=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=99=80=EC=9D=B4=ED=94=84=EA=B0=80=20=EB=84=88=EB=AC=B4=20?= =?UTF-8?q?=EB=B9=A0=EB=A5=B4=EA=B2=8C=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ScrollSnapContainer.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/components/ScrollSnapContainer.tsx b/client/src/components/ScrollSnapContainer.tsx index 33144c4c..fe333e29 100644 --- a/client/src/components/ScrollSnapContainer.tsx +++ b/client/src/components/ScrollSnapContainer.tsx @@ -66,6 +66,12 @@ const SWIPE_FAST_SCROLL_DISTANCE_RATIO = 0.03; // 0 = 다음 아이템으로 완전히 넘어가야 스와이프 판정 가능 const SWIPE_WHEEL_SCROLL_VALID_RATIO = 0.1; +// 스크롤로 스와이프 시: +// 터치패드로 스와이프 시 너무 많은 스와이프가 되기 때문에 +// 일정 스피드 이하에서는 스와이프가 되지 않도록 한다 +// Windows 11 기준으로 마우스 스크롤의 delta 스피드는 200이다 +const SWIPE_WHEEL_SCROLL_MIN_DELTA_SPEED = 80; + type ScrollSnapVirtualItemProps = PropsWithChildren<{ // 이전 아이템인지, 현재 아이템인지, 이후 아이템인지 여부를 나타내는 숫자 offset: -1 | 0 | 1; @@ -164,10 +170,10 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { onActiveIndexChange, scrollPosition, onScrollPositionChange, + timingFn, items, itemRenderer, enableRolling, - timingFn, ...divProps } = props; @@ -386,6 +392,7 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { const handleWheel: WheelEventHandler = (event) => { if (event.shiftKey) return; + if (Math.abs(event.deltaY) < SWIPE_WHEEL_SCROLL_MIN_DELTA_SPEED) return; event.stopPropagation(); From 0fd28fb5eed0d241e62de91a9e7aad9bbe40a5ee Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 10 Oct 2023 10:53:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Revert=20"fix:=20=ED=84=B0=EC=B9=98?= =?UTF-8?q?=ED=8C=A8=EB=93=9C=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=8B=9C=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84=EA=B0=80=20?= =?UTF-8?q?=EB=84=88=EB=AC=B4=20=EB=B9=A0=EB=A5=B4=EA=B2=8C=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 13eaee555e7983aa8b54b275ace0ba409cfd8e1f. --- client/src/components/ScrollSnapContainer.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/client/src/components/ScrollSnapContainer.tsx b/client/src/components/ScrollSnapContainer.tsx index 00528e9b..8723ae81 100644 --- a/client/src/components/ScrollSnapContainer.tsx +++ b/client/src/components/ScrollSnapContainer.tsx @@ -67,12 +67,6 @@ const SWIPE_FAST_SCROLL_DISTANCE_RATIO = 0.03; // 0 = 다음 아이템으로 완전히 넘어가야 스와이프 판정 가능 const SWIPE_WHEEL_SCROLL_VALID_RATIO = 0.1; -// 스크롤로 스와이프 시: -// 터치패드로 스와이프 시 너무 많은 스와이프가 되기 때문에 -// 일정 스피드 이하에서는 스와이프가 되지 않도록 한다 -// Windows 11 기준으로 마우스 스크롤의 delta 스피드는 200이다 -const SWIPE_WHEEL_SCROLL_MIN_DELTA_SPEED = 80; - type ScrollSnapVirtualItemProps = PropsWithChildren<{ // 이전 아이템인지, 현재 아이템인지, 이후 아이템인지 여부를 나타내는 숫자 offset: -1 | 0 | 1; @@ -171,10 +165,10 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { onActiveIndexChange, scrollPosition, onScrollPositionChange, - timingFn, items, itemRenderer, enableRolling, + timingFn, ...divProps } = props; @@ -393,7 +387,6 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { const handleWheel: WheelEventHandler = (event) => { if (event.shiftKey) return; - if (Math.abs(event.deltaY) < SWIPE_WHEEL_SCROLL_MIN_DELTA_SPEED) return; event.stopPropagation(); From 3f2d18c1a658dd5304e9aec8ef30740d23cb9350 Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 10 Oct 2023 16:53:17 +0900 Subject: [PATCH 3/3] feat: ScrollSnap Overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ScrollSnapContainer 단일 파일을 여러 파일로 분리 * ScrollSnap 으로 이름 변경 * CSS scroll-snap으로 동작하는 ScrollSnapCSS 구현 * ScrollSnapImpl 에서 onWheel 제거 * `navigator.userAgent`에 따라 CSS scroll-snap를 사용할 지 결정하는 mode props 추가 --- .../src/components/CafeDetailBottomSheet.tsx | 2 +- client/src/components/CafeMenuBottomSheet.tsx | 2 +- client/src/components/ImageModal.tsx | 2 +- .../ScrollSnap/components/ScrollSnap.tsx | 26 +++ .../ScrollSnapCSS/ScrollSnapCSS.tsx | 113 +++++++++++ .../ScrollSnapCSS/ScrollSnapCSSItemList.tsx | 36 ++++ .../ScrollSnapImpl/ScrollSnapImpl.tsx} | 182 +++--------------- .../ScrollSnapImpl/ScrollSnapImplItemList.tsx | 61 ++++++ .../ScrollSnap/components/ScrollSnapItem.tsx | 27 +++ .../ScrollSnap}/hooks/useScrollSnapGuard.ts | 8 +- client/src/components/ScrollSnap/index.ts | 1 + client/src/components/ScrollSnap/types.ts | 18 ++ client/src/pages/HomePage.tsx | 4 +- 13 files changed, 322 insertions(+), 160 deletions(-) create mode 100644 client/src/components/ScrollSnap/components/ScrollSnap.tsx create mode 100644 client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSS.tsx create mode 100644 client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSSItemList.tsx rename client/src/components/{ScrollSnapContainer.tsx => ScrollSnap/components/ScrollSnapImpl/ScrollSnapImpl.tsx} (66%) create mode 100644 client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImplItemList.tsx create mode 100644 client/src/components/ScrollSnap/components/ScrollSnapItem.tsx rename client/src/{ => components/ScrollSnap}/hooks/useScrollSnapGuard.ts (65%) create mode 100644 client/src/components/ScrollSnap/index.ts create mode 100644 client/src/components/ScrollSnap/types.ts diff --git a/client/src/components/CafeDetailBottomSheet.tsx b/client/src/components/CafeDetailBottomSheet.tsx index 11a19f36..fede0f8a 100644 --- a/client/src/components/CafeDetailBottomSheet.tsx +++ b/client/src/components/CafeDetailBottomSheet.tsx @@ -2,11 +2,11 @@ import { Suspense, useEffect } from 'react'; import { BsBoxArrowUpRight, BsGeoAlt, BsX } from 'react-icons/bs'; import { styled } from 'styled-components'; import useCafeMenus from '../hooks/useCafeMenus'; -import useScrollSnapGuard from '../hooks/useScrollSnapGuard'; import type { Theme } from '../styles/theme'; import type { Cafe } from '../types'; import CafeMenuMiniList from './CafeMenuMiniList'; import OpeningHoursDetail from './OpeningHoursDetail'; +import useScrollSnapGuard from './ScrollSnap/hooks/useScrollSnapGuard'; type CafeDetailBottomSheetProps = { cafe: Cafe; diff --git a/client/src/components/CafeMenuBottomSheet.tsx b/client/src/components/CafeMenuBottomSheet.tsx index 54b94a9e..f220fc97 100644 --- a/client/src/components/CafeMenuBottomSheet.tsx +++ b/client/src/components/CafeMenuBottomSheet.tsx @@ -3,12 +3,12 @@ import { createPortal } from 'react-dom'; import { BsX } from 'react-icons/bs'; import { styled } from 'styled-components'; import useCafeMenus from '../hooks/useCafeMenus'; -import useScrollSnapGuard from '../hooks/useScrollSnapGuard'; import type { Theme } from '../styles/theme'; import type { Cafe } from '../types'; import Resource from '../utils/Resource'; import CafeMenuList from './CafeMenuList'; import ImageModal from './ImageModal'; +import useScrollSnapGuard from './ScrollSnap/hooks/useScrollSnapGuard'; type CafeMenuBottomSheetProps = { cafe: Cafe; diff --git a/client/src/components/ImageModal.tsx b/client/src/components/ImageModal.tsx index 335bb9a1..81dd7677 100644 --- a/client/src/components/ImageModal.tsx +++ b/client/src/components/ImageModal.tsx @@ -1,8 +1,8 @@ import type { MouseEventHandler } from 'react'; import { useState } from 'react'; import { styled } from 'styled-components'; -import useScrollSnapGuard from '../hooks/useScrollSnapGuard'; import Resource from '../utils/Resource'; +import useScrollSnapGuard from './ScrollSnap/hooks/useScrollSnapGuard'; type ImageModalProps = { imageUrls: string[]; diff --git a/client/src/components/ScrollSnap/components/ScrollSnap.tsx b/client/src/components/ScrollSnap/components/ScrollSnap.tsx new file mode 100644 index 00000000..fb93b50e --- /dev/null +++ b/client/src/components/ScrollSnap/components/ScrollSnap.tsx @@ -0,0 +1,26 @@ +import type { ScrollSnapProps } from '../types'; +import ScrollSnapCSS from './ScrollSnapCSS/ScrollSnapCSS'; +import ScrollSnapImpl from './ScrollSnapImpl/ScrollSnapImpl'; + +const isMobile = window.navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(android)|(webOS)/i); + +type ScrollSnapWithMode = ScrollSnapProps & { + // auto일 시 userAgent의 값에 따라 mode를 결정 + // css일 시 ScrollSnapCSS 사용 + // impl일 시 ScrollSnapImpl 사용 + mode?: 'auto' | 'css' | 'impl'; +}; + +const ScrollSnap = (props: ScrollSnapWithMode) => { + const { mode = 'auto', ...restProps } = props; + + if (mode === 'auto') { + return isMobile ? : ; + } + if (mode === 'css') { + return ; + } + return ; +}; + +export default ScrollSnap; diff --git a/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSS.tsx b/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSS.tsx new file mode 100644 index 00000000..9574e7f5 --- /dev/null +++ b/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSS.tsx @@ -0,0 +1,113 @@ +import type { UIEventHandler } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import type { ScrollSnapProps } from '../../types'; +import ScrollSnapCSSItemList from './ScrollSnapCSSItemList'; + +// CSS scroll-snap의 scrollPosition과 parent에서 내려준 scrollPosition의 불일치를 +// 어느정도 허용할 것인지 설정 +// 값이 0일 경우 오차를 허용하지 않음 +const SCROLL_UNMATCH_TOLERANCE = 0.1; + +// CSS scroll-snap에서 나타나는 glitch를 고치기 위한 상수 +// 스크롤이 너무 많이 바뀌었을 경우 캔슬한다 +const SCROLL_SNAP_GLITCH_RANGE = 1; + +const ScrollSnapCSS = (props: ScrollSnapProps) => { + const { + activeIndex, + onActiveIndexChange, + scrollPosition: parentScrollPosition, + onScrollPositionChange, + items, + itemRenderer, + enableRolling, + timingFn, + ...divProps + } = props; + const [clientScrollPosition, setClientScrollPosition] = useState(parentScrollPosition); + const [shouldSync, setShouldSync] = useState(true); + + // 마지막 아이템에서 첫 번째 아이템으로 넘어가려고 할 때 허용할 지, 허용하지 않을 지 + // 여부를 결정하는 함수 + const clampPosition = (position: number) => Math.max(0, Math.min(position, items.length - 1)); + + const setActiveIndex = (activePosition: number) => { + onActiveIndexChange(clampPosition(activePosition)); + }; + + const containerRef = useRef(null); + + const getDomScrollPosition = () => { + const $element = containerRef.current; + if (!($element instanceof HTMLDivElement)) { + return clientScrollPosition; + } + return $element.scrollTop / $element.clientHeight; + }; + + const handleScroll: UIEventHandler = (event) => { + const domScrollPosition = getDomScrollPosition(); + const refinedPosition = + Math.abs(domScrollPosition - Math.round(domScrollPosition)) < 0.01 + ? Math.round(domScrollPosition) + : domScrollPosition; + + // A glitch has been detected. However, fixing this may actually impair the user experience, + // so it has been commented out. + // + // const seemsGlitch = Math.abs(clientScrollPosition - refinedPosition) > SCROLL_SNAP_GLITCH_RANGE; + // if (seemsGlitch) { + // setShouldSync(true); + // return; + // } + setActiveIndex(Math.round(refinedPosition)); + onScrollPositionChange(clampPosition(refinedPosition)); + + setClientScrollPosition(refinedPosition); + setShouldSync(false); + }; + + useLayoutEffect(() => { + const $element = containerRef.current; + if (!($element instanceof HTMLDivElement)) return; + + if (shouldSync) { + $element.scrollTo({ top: $element.clientHeight * clientScrollPosition }); + setShouldSync(false); + } + }, [clientScrollPosition, shouldSync]); + + // 부모에서 내려준 scroll position이 DOM의 scroll position과 + // 불일치가 심한지 여부 + const shouldSyncWithParent = () => { + const domScrollPosition = getDomScrollPosition(); + return Math.abs(domScrollPosition - parentScrollPosition) > SCROLL_UNMATCH_TOLERANCE; + }; + + // DOM scroll position과 parent에서 내려준 scroll position이 mismatch할 때 + // scroll을 강제로 이동시킬 필요가 있음 + if (shouldSyncWithParent() && !shouldSync) { + setClientScrollPosition(parentScrollPosition); + setShouldSync(true); + } + + return ( + + + + ); +}; + +export default ScrollSnapCSS; + +const Container = styled.div` + scroll-snap-type: y mandatory; + overflow-y: scroll; + width: 100%; + height: 100%; + + & > * { + scroll-snap-align: start; + } +`; diff --git a/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSSItemList.tsx b/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSSItemList.tsx new file mode 100644 index 00000000..5b7f222c --- /dev/null +++ b/client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSSItemList.tsx @@ -0,0 +1,36 @@ +import ScrollSnapItem from '../ScrollSnapItem'; + +// 음수 mod 시 양수 값을 얻기 위한 함수 +const mod = (n: number, m: number) => ((n % m) + m) % m; + +type ScrollSnapCSSItemListProps = { + scrollPosition: number; + items: Item[]; + itemRenderer: (item: Item, index: number) => React.ReactNode; + // focus된 아이템에서 일정 범위만큼 아이템을 렌더링 + renderDistance?: number; +}; + +/** + * DOM scroll box에 의존적인 리스트 + * + * CSS scroll-snap 사용 시 이 컴포넌트를 사용하여야 한다 + */ +const ScrollSnapCSSItemList = (props: ScrollSnapCSSItemListProps) => { + const { scrollPosition, items, itemRenderer, renderDistance = 2 } = props; + + const focusedIndex = mod(Math.floor(scrollPosition), items.length); + const visiblePosition = mod(scrollPosition, 1); + + return items.map((item, index) => { + const offset = index - focusedIndex; + + return ( + + {Math.abs(offset) <= renderDistance && itemRenderer(item, index)} + + ); + }); +}; + +export default ScrollSnapCSSItemList; diff --git a/client/src/components/ScrollSnapContainer.tsx b/client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImpl.tsx similarity index 66% rename from client/src/components/ScrollSnapContainer.tsx rename to client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImpl.tsx index 8723ae81..d98b72e3 100644 --- a/client/src/components/ScrollSnapContainer.tsx +++ b/client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImpl.tsx @@ -1,10 +1,29 @@ -import type React from 'react'; -import type { HTMLAttributes, MouseEventHandler, PropsWithChildren, TouchEventHandler, WheelEventHandler } from 'react'; +import type { MouseEventHandler, TouchEventHandler } from 'react'; import { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; -import useEffectEvent from '../shims/useEffectEvent'; +import useEffectEvent from '../../../../shims/useEffectEvent'; +import { easeOutExpo } from '../../../../utils/timingFunctions'; +import type { ScrollSnapProps } from '../../types'; +import ScrollSnapImplItemList from './ScrollSnapImplItemList'; -type TimingFn = (x: number) => number; +// 터치 스와이프가 수직 혹은 수평 방향인지 판단할 때 샘플링하는 거리 +const SWIPE_TOUCH_DECISION_RADIUS = 4; + +// 스와이프 시 다음 아이템으로 이동하는 시간(ms) +const SWIPE_SNAP_TRANSITION_DURATION = 300; + +// 빠르게 휙 스와이프 시: +// 스와이프 판정을 위해 직전 {value}ms 터치 포인트와 비교 +const SWIPE_FAST_SCROLL_TIME_DURATION = 100; + +// 빠르게 휙 스와이프 시: +// 직전 터치 포인트와 비교하여 {value}% 만큼 이동하였다면 스와이프 처리 +// 작을 수록 짧게 스와이프해도 판정됨 +// 클 수록 넓게 스와이프해야 판정됨 +const SWIPE_FAST_SCROLL_DISTANCE_RATIO = 0.03; + +// 음수 mod 시 양수 값을 얻기 위한 함수 +const mod = (n: number, m: number) => ((n % m) + m) % m; type BoxModel = { pageY: number; @@ -40,126 +59,7 @@ type MachineState = startedPosition: number; }; -// 음수 mod 시 양수 값을 얻기 위한 함수 -const mod = (n: number, m: number) => ((n % m) + m) % m; - -// 터치 스와이프가 수직 혹은 수평 방향인지 판단할 때 샘플링하는 거리 -const SWIPE_TOUCH_DECISION_RADIUS = 4; - -// 스와이프 시 다음 아이템으로 이동하는 시간(ms) -const SWIPE_SNAP_TRANSITION_DURATION = 300; - -// 빠르게 휙 스와이프 시: -// 스와이프 판정을 위해 직전 {value}ms 터치 포인트와 비교 -const SWIPE_FAST_SCROLL_TIME_DURATION = 100; - -// 빠르게 휙 스와이프 시: -// 직전 터치 포인트와 비교하여 {value}% 만큼 이동하였다면 스와이프 처리 -// 작을 수록 짧게 스와이프해도 판정됨 -// 클 수록 넓게 스와이프해야 판정됨 -const SWIPE_FAST_SCROLL_DISTANCE_RATIO = 0.03; - -// 스크롤로 스와이프 시: -// 너무 빠른 스와이프를 방지하기 위해 현재 아이템의 {value}%만큼 보일 때 -// 스크롤 스와이프 유효화 처리 -// 값이 작을수록 빠른 스크롤 스와이프가 더 많이 차단된다 -// 1 = 스크롤 시 무조건 스와이프 판정 -// 0 = 다음 아이템으로 완전히 넘어가야 스와이프 판정 가능 -const SWIPE_WHEEL_SCROLL_VALID_RATIO = 0.1; - -type ScrollSnapVirtualItemProps = PropsWithChildren<{ - // 이전 아이템인지, 현재 아이템인지, 이후 아이템인지 여부를 나타내는 숫자 - offset: -1 | 0 | 1; - // 0.0 ~ 1.0 - position: number; -}>; - -const ScrollSnapVirtualItem = (props: ScrollSnapVirtualItemProps) => { - const { offset, position, children } = props; - - return ( -
- {children} -
- ); -}; - -type ScrollSnapVirtualItemsProps = { - scrollPosition: number; - items: Item[]; - itemRenderer: (item: Item, index: number) => React.ReactNode; - enableRolling?: boolean; -}; - -const ScrollSnapVirtualItems = (props: ScrollSnapVirtualItemsProps) => { - const { scrollPosition, items, itemRenderer, enableRolling } = props; - - // position of item, which user sees - // always positive integer - // 현재 화면에 표시되고 있는 아이템의 index - const focusedIndex = mod(Math.floor(scrollPosition), items.length); - - const indexedItems = items.map((item, index) => ({ index, item })); - - // 현재 화면의 아이템 및 위, 아래의 아이템을 표시한다 - const visibleItems = enableRolling - ? [ - indexedItems.at(focusedIndex - 1), // 현재에서 상단 (혹은 끝) 아이템 - indexedItems.at(focusedIndex), // 현재 아이템 - indexedItems.at((focusedIndex + 1) % indexedItems.length), // 현재에서 하단 (혹은 첫) 아이템 - ] - : [ - indexedItems[focusedIndex - 1], // 현재에서 상단 아이템 - indexedItems[focusedIndex], // 현재 아이템 - indexedItems[focusedIndex + 1], // 현재에서 하단 아이템 - ]; - const visiblePosition = mod(scrollPosition, 1); - - return ( -
- {([0, 1, -1] as const) - .map((visibleIndex) => ({ ...visibleItems[1 + visibleIndex], visibleIndex })) - .map(({ item, index, visibleIndex }) => - item && typeof index === 'number' ? ( - - {itemRenderer(item, index)} - - ) : ( - - ), - )} -
- ); -}; - -type ScrollSnapContainerProps = HTMLAttributes & { - // position of currently active item. (equiv with index) - // always 0..items.length (positive integer) - activeIndex: number; - onActiveIndexChange: (activeIndex: number) => void; - // scroll position of container - scrollPosition: number; - onScrollPositionChange: (scrollPosition: number) => void; - timingFn: TimingFn; - items: Item[]; - itemRenderer: (item: Item, index: number) => React.ReactNode; - // enable continuity scrolling at the end of item - enableRolling?: boolean; -}; - -const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { +const ScrollSnapImpl = (props: ScrollSnapProps) => { const { activeIndex, onActiveIndexChange, @@ -168,7 +68,7 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { items, itemRenderer, enableRolling, - timingFn, + timingFn = easeOutExpo, ...divProps } = props; @@ -385,29 +285,6 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { onTouchEnd(); }; - const handleWheel: WheelEventHandler = (event) => { - if (event.shiftKey) return; - - event.stopPropagation(); - - // wheel moved while snap-ing - if (machineState.label === 'snap') { - const { startedPosition } = machineState; - const transitionDistance = activeIndex - scrollPosition; - - if ( - // check wheel direction is same with transitioning direction - transitionDistance * event.deltaY > 0 && - // determines how fast swipe rolls when wheel move - // larger value rolls more faster - Math.abs(transitionDistance) > SWIPE_WHEEL_SCROLL_VALID_RATIO - ) - return; - } - setActiveIndex(activeIndex + (event.deltaY > 0 ? 1 : -1)); - onSnapStart(); - }; - const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { switch (event.key) { case 'ArrowUp': @@ -438,9 +315,11 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} - onWheel={handleWheel} + // Disable wheel + // see https://github.com/woowacourse-teams/2023-yozm-cafe/issues/480) + // onWheel={handleWheel} > - (props: ScrollSnapContainerProps) => { const Container = styled.div` cursor: grab; user-select: none; + position: relative; overflow-y: hidden; `; -export default ScrollSnapContainer; +export default ScrollSnapImpl; diff --git a/client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImplItemList.tsx b/client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImplItemList.tsx new file mode 100644 index 00000000..9cb41cfb --- /dev/null +++ b/client/src/components/ScrollSnap/components/ScrollSnapImpl/ScrollSnapImplItemList.tsx @@ -0,0 +1,61 @@ +// 음수 mod 시 양수 값을 얻기 위한 함수 +const mod = (n: number, m: number) => ((n % m) + m) % m; + +type ScrollSnapImplItemListProps = { + scrollPosition: number; + items: Item[]; + itemRenderer: (item: Item, index: number) => React.ReactNode; + enableRolling?: boolean; +}; + +const ScrollSnapImplItemList = (props: ScrollSnapImplItemListProps) => { + const { scrollPosition, items, itemRenderer, enableRolling } = props; + + // position of item, which user sees + // always positive integer + // 현재 화면에 표시되고 있는 아이템의 index + const focusedIndex = mod(Math.floor(scrollPosition), items.length); + + const indexedItems = items.map((item, index) => ({ index, item })); + + // 현재 화면의 아이템 및 위, 아래의 아이템을 표시한다 + const visibleItems = enableRolling + ? [ + indexedItems.at(focusedIndex - 1), // 현재에서 상단 (혹은 끝) 아이템 + indexedItems.at(focusedIndex), // 현재 아이템 + indexedItems.at((focusedIndex + 1) % indexedItems.length), // 현재에서 하단 (혹은 첫) 아이템 + ] + : [ + indexedItems[focusedIndex - 1], // 현재에서 상단 아이템 + indexedItems[focusedIndex], // 현재 아이템 + indexedItems[focusedIndex + 1], // 현재에서 하단 아이템 + ]; + const visiblePosition = mod(scrollPosition, 1); + + return ( +
+ {([0, 1, -1] as const) + .map((visibleIndex) => ({ ...visibleItems[1 + visibleIndex], visibleIndex })) + .map(({ item, index, visibleIndex }) => ( +
+ {item && typeof index === 'number' && itemRenderer(item, index)} +
+ ))} +
+ ); +}; + +export default ScrollSnapImplItemList; diff --git a/client/src/components/ScrollSnap/components/ScrollSnapItem.tsx b/client/src/components/ScrollSnap/components/ScrollSnapItem.tsx new file mode 100644 index 00000000..990b2edc --- /dev/null +++ b/client/src/components/ScrollSnap/components/ScrollSnapItem.tsx @@ -0,0 +1,27 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +type ScrollSnapItemProps = PropsWithChildren<{ + // 이전 아이템인지, 현재 아이템인지, 이후 아이템인지 여부를 나타내는 숫자 + // -1 | 0 | 1 혹은 number + offset: number; + // 0.0 ~ 1.0 + position: number; +}>; + +/** + * scroll snap 컨테이너에서 snap 처리가 될 아이템 + * + * 높이는 부모의 100%로 고정되어 있다 + */ +const ScrollSnapItem = (props: ScrollSnapItemProps) => { + const { offset, position, children } = props; + + return {children}; +}; + +export default ScrollSnapItem; + +const Container = styled.div` + height: 100%; +`; diff --git a/client/src/hooks/useScrollSnapGuard.ts b/client/src/components/ScrollSnap/hooks/useScrollSnapGuard.ts similarity index 65% rename from client/src/hooks/useScrollSnapGuard.ts rename to client/src/components/ScrollSnap/hooks/useScrollSnapGuard.ts index 050e442d..a1915edc 100644 --- a/client/src/hooks/useScrollSnapGuard.ts +++ b/client/src/components/ScrollSnap/hooks/useScrollSnapGuard.ts @@ -2,9 +2,9 @@ import type { EventHandler, SyntheticEvent } from 'react'; import { useCallback, useMemo } from 'react'; /** - * ScrollSnapContainer의 하위 요소에서 열린 모달에서 스크롤 혹은 스와이프를 할 시 - * ScrollSnapContainer에 전파되어 스와이프가 동작하는 문제가 발생합니다. - * ScrollSnapContainer의 스와이프가 동작하지 않도록 갖가지 이벤트의 propagation을 막아주는 훅입니다. + * ScrollSnap의 하위 요소에서 열린 모달에서 스크롤 혹은 스와이프를 할 시 + * ScrollSnap에 전파되어 스와이프가 동작하는 문제가 발생합니다. + * ScrollSnap의 스와이프가 동작하지 않도록 갖가지 이벤트의 propagation을 막아주는 훅입니다. */ const useScrollSnapGuard = () => { const preventPropagation: EventHandler = useCallback((event) => { @@ -20,7 +20,7 @@ const useScrollSnapGuard = () => { onMouseMove: preventPropagation, onMouseUp: preventPropagation, onMouseLeave: preventPropagation, - onWheel: preventPropagation, + // onWheel: preventPropagation, }), [preventPropagation], ); diff --git a/client/src/components/ScrollSnap/index.ts b/client/src/components/ScrollSnap/index.ts new file mode 100644 index 00000000..b01e8789 --- /dev/null +++ b/client/src/components/ScrollSnap/index.ts @@ -0,0 +1 @@ +export { default } from './components/ScrollSnap'; diff --git a/client/src/components/ScrollSnap/types.ts b/client/src/components/ScrollSnap/types.ts new file mode 100644 index 00000000..43fae2c4 --- /dev/null +++ b/client/src/components/ScrollSnap/types.ts @@ -0,0 +1,18 @@ +import type { ComponentPropsWithoutRef } from 'react'; + +export type TimingFn = (x: number) => number; + +export type ScrollSnapProps = ComponentPropsWithoutRef<'div'> & { + // position of currently active item. (equiv with index) + // always 0..items.length (positive integer) + activeIndex: number; + onActiveIndexChange: (activeIndex: number) => void; + // scroll position of container + scrollPosition: number; + onScrollPositionChange: (scrollPosition: number) => void; + timingFn?: TimingFn; + items: Item[]; + itemRenderer: (item: Item, index: number) => React.ReactNode; + // enable continuity scrolling at the end of item + enableRolling?: boolean; +}; diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 75c565dc..38c3f510 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import CafeCard from '../components/CafeCard'; -import ScrollSnapContainer from '../components/ScrollSnapContainer'; +import ScrollSnap from '../components/ScrollSnap'; import useCafes from '../hooks/useCafes'; import type { Cafe } from '../types'; import { easeOutExpo } from '../utils/timingFunctions'; @@ -25,7 +25,7 @@ const HomePage = () => { const itemRenderer = (cafe: Cafe) => ; return ( -