diff --git a/client/src/components/CafeCard.tsx b/client/src/components/CafeCard.tsx index c9912bf6..bfd80de7 100644 --- a/client/src/components/CafeCard.tsx +++ b/client/src/components/CafeCard.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react'; import { styled } from 'styled-components'; -import useIntersection from '../hooks/useIntersection'; import type { Cafe } from '../types'; import Image from '../utils/Image'; import CafeActionBar from './CafeActionBar'; @@ -9,23 +8,15 @@ import CafeSummary from './CafeSummary'; type CardProps = { cafe: Cafe; - onIntersect?: (intersection: IntersectionObserverEntry) => void; }; const CafeCard = (props: CardProps) => { - const { cafe, onIntersect } = props; + const { cafe } = props; const [isShowDetail, setIsShowDetail] = useState(false); const [currentImageIndex, setCurrentImageIndex] = useState(0); const ref = useRef(null); - const intersection = useIntersection(ref, { threshold: 0.7 }); - - useEffect(() => { - if (intersection) { - onIntersect?.(intersection); - } - }, [intersection?.isIntersecting]); useEffect(() => { const handleScroll = () => { diff --git a/client/src/components/ScrollSnapContainer.tsx b/client/src/components/ScrollSnapContainer.tsx new file mode 100644 index 00000000..fbec07a1 --- /dev/null +++ b/client/src/components/ScrollSnapContainer.tsx @@ -0,0 +1,395 @@ +import type React from 'react'; +import type { HTMLAttributes, MouseEventHandler, TouchEventHandler, WheelEventHandler } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +type TimingFn = (x: number) => number; + +type BoxModel = { + pageY: number; + height: number; +}; + +type TouchPoint = { + x: number; + y: number; +}; + +type MachineState = + // 아무런 동작도 하지 않는 상태 + | { + label: 'idle'; + } + // 터치 방향(좌우 혹은 상하)에 따라 스와이프를 해야할 지 결정하는 상태 + // 이 상태에선 실질적으로 스와이프가 동작하지 않는다 + | { + label: 'touchdecision'; + originTouchPoint: TouchPoint; + } + // 터치의 움직임에 따라 스크롤이 동작하는 상태 + | { + label: 'touchmove'; + prevPosition: number; + recentPositionHistory: { timestamp: number; position: number }[]; + } + // 터치가 끝났고 스크롤이 가장 가까운 스냅 포인트로 이동하는(애니메이션) 상태 + | { + label: 'snap'; + startedAt: number; + 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 ScrollSnapVirtualItemsProps = { + scrollPosition: number; + items: Item[]; + itemRenderer: (item: Item, index: number) => React.ReactNode; +}; + +const ScrollSnapVirtualItems = (props: ScrollSnapVirtualItemsProps) => { + const { scrollPosition, items, itemRenderer } = 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 = [ + indexedItems[focusedIndex - 1] ?? indexedItems[indexedItems.length - 1], + indexedItems[focusedIndex], + indexedItems[focusedIndex + 1] ?? indexedItems[0], + ]; + const visiblePosition = mod(scrollPosition, 1); + + return ( +
+ {visibleItems.map(({ item, index }) => 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 { + activeIndex, + onActiveIndexChange, + scrollPosition, + onScrollPositionChange, + items, + itemRenderer, + enableRolling, + timingFn, + ...divProps + } = props; + + const [, setNothing] = useState({}); + const rerender = () => setNothing({}); + + const [boxModel, setBoxModel] = useState(null); + + // 마지막 아이템에서 첫 번째 아이템으로 넘어가려고 할 때 허용할 지, 허용하지 않을 지 + // 여부를 결정하는 함수 + const clampPosition = enableRolling + ? (position: number) => mod(position, items.length) + : (position: number) => Math.max(0, Math.min(position, items.length - 1)); + + const setScrollPosition = (scrollPosition: number) => { + onScrollPositionChange(clampPosition(scrollPosition)); + }; + + const setActiveIndex = (activePosition: number) => { + onActiveIndexChange(clampPosition(activePosition)); + }; + + const containerRef = useRef(null); + + useEffect(() => { + const $container = containerRef.current; + if ($container === null) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries.pop(); + if (!entry) return; + + const { y: pageY, height } = entry.contentRect; + setBoxModel({ pageY, height }); + }); + observer.observe($container); + + return () => observer.disconnect(); + }, []); + + const [machineState, setMachineState] = useState({ + label: 'idle', + }); + const transitionMachineState = (state: MachineState) => { + setMachineState(state); + }; + + // returns normalized by container box height. + // if touch is inside of screen, then 0.0 ~ 1.0 + // or outside, it can be negative or greater than 1.0 + const normalizePageY = (pageY: number): number => { + if (!boxModel) return 0; + + return (pageY - boxModel.pageY) / boxModel.height; + }; + + const onTouchStart = (touchPoint: TouchPoint) => { + transitionMachineState({ + label: 'touchdecision', + originTouchPoint: touchPoint, + }); + }; + + const onTouchDecision = (touchPoint: TouchPoint) => { + if (machineState.label !== 'touchdecision') return; + + // swipe decision + const { originTouchPoint } = machineState; + const [rx, ry] = [originTouchPoint.x - touchPoint.x, originTouchPoint.y - touchPoint.y].map(Math.abs); + if (Math.abs(rx) < SWIPE_TOUCH_DECISION_RADIUS || Math.abs(ry) < SWIPE_TOUCH_DECISION_RADIUS) { + return; + } + + // not my direction + if (ry < rx) { + transitionMachineState({ label: 'idle' }); + return; + } + + const position = normalizePageY(touchPoint.y); + // transition state to 'touchmove' + transitionMachineState({ + label: 'touchmove', + prevPosition: position, + recentPositionHistory: [{ timestamp: Date.now(), position }], + }); + }; + + const onTouchMove = (touchPoint: TouchPoint) => { + if (machineState.label === 'touchdecision') { + onTouchDecision(touchPoint); + return; + } + + if (machineState.label !== 'touchmove') return; + + const { prevPosition, recentPositionHistory } = machineState; + const position = normalizePageY(touchPoint.y); + + // process swipe movement + const delta = prevPosition - position; + setScrollPosition(scrollPosition + delta); + + // transition itself + transitionMachineState({ + label: 'touchmove', + prevPosition: position, + recentPositionHistory: [...recentPositionHistory, { timestamp: Date.now(), position }].filter( + ({ timestamp }) => Date.now() - timestamp < SWIPE_FAST_SCROLL_TIME_DURATION, + ), + }); + }; + + const onTouchEnd = () => { + onSnapStart(); + + if (machineState.label !== 'touchmove') return; + + const { prevPosition, recentPositionHistory } = machineState; + + const recentPosition = recentPositionHistory[0]?.position; + if (!recentPosition) return; + + const accelDelta = recentPosition - prevPosition; + + if (Math.abs(accelDelta) > SWIPE_FAST_SCROLL_DISTANCE_RATIO) { + const positionOffset = accelDelta > 0 ? 1 : -1; + setActiveIndex(activeIndex + positionOffset); + return; + } + setActiveIndex(Math.round(scrollPosition)); + }; + + const onSnapStart = () => { + if (machineState.label === 'snap') return; + + // transition state to 'snap' + transitionMachineState({ + label: 'snap', + startedAt: Date.now(), + startedPosition: scrollPosition, + }); + onSnap(); + }; + + const onSnap = () => { + if (machineState.label !== 'snap') return; + + // transition 'snap' itself + const { startedAt, startedPosition } = machineState; + const x = (Date.now() - startedAt) / SWIPE_SNAP_TRANSITION_DURATION; + if (x > 1) { + // transition to 'idle' + setScrollPosition(activeIndex); + transitionMachineState({ label: 'idle' }); + return; + } + + // 정방향 거리와 역방향 거리를 비교하여 가까운 거리로 transition + const candidateDistance1 = activeIndex - startedPosition; + const candidateDistance2 = (items.length - Math.abs(candidateDistance1)) * (candidateDistance1 < 0 ? 1 : -1); + + const transitionDistance = + Math.abs(candidateDistance1) < Math.abs(candidateDistance2) ? candidateDistance1 : candidateDistance2; + + const processTransition = () => { + // scrollTo(startedPosition + timingFn(x) * transitionDistance); + setScrollPosition(startedPosition + timingFn(x) * transitionDistance); + rerender(); // force trigger re-render + }; + requestAnimationFrame(processTransition); + }; + + if (machineState.label === 'snap') onSnap(); + console.log(machineState.label); + + const handleTouchStart: TouchEventHandler = (event) => { + const touch = event.touches[0]; + if (!touch) return; + event.stopPropagation(); + + onTouchStart({ x: touch.pageX, y: touch.pageY }); + }; + + const handleTouchMove: TouchEventHandler = (event) => { + const touch = event.touches[0]; + if (!touch) return; + event.stopPropagation(); + + onTouchMove({ x: touch.pageX, y: touch.pageY }); + }; + + const handleTouchEnd: TouchEventHandler = (event) => { + event.stopPropagation(); + onTouchEnd(); + }; + + const handleMouseDown: MouseEventHandler = (event) => { + event.stopPropagation(); + onTouchStart({ x: event.pageX, y: event.pageY }); + }; + + const handleMouseMove: MouseEventHandler = (event) => { + event.stopPropagation(); + onTouchMove({ x: event.pageX, y: event.pageY }); + }; + + const handleMouseUp: MouseEventHandler = (event) => { + event.stopPropagation(); + onTouchEnd(); + }; + + const handleMouseLeave: MouseEventHandler = (event) => { + event.stopPropagation(); + 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(); + }; + + return ( + + + + ); +}; + +const Container = styled.div` + cursor: grab; + user-select: none; + overflow-y: hidden; +`; + +export default ScrollSnapContainer; diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 4379ceb4..75c565dc 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -1,51 +1,41 @@ import { useState } from 'react'; -import { styled } from 'styled-components'; import CafeCard from '../components/CafeCard'; +import ScrollSnapContainer from '../components/ScrollSnapContainer'; import useCafes from '../hooks/useCafes'; -import useUser from '../hooks/useUser'; +import type { Cafe } from '../types'; +import { easeOutExpo } from '../utils/timingFunctions'; const PREFETCH_OFFSET = 2; const HomePage = () => { - const { data: user } = useUser(); const { cafes, fetchNextPage, isFetching, hasNextPage } = useCafes(); - const [activeCafe, setActiveCafe] = useState(cafes[0]); + + const [scrollPosition, setScrollPosition] = useState(0); + const [activeIndex, setActiveIndex] = useState(0); const shouldFetch = hasNextPage && !isFetching && - cafes.findIndex((cafe) => cafe.id === activeCafe.id) + PREFETCH_OFFSET >= cafes.length; + cafes.findIndex((cafe) => cafe.id === cafes[activeIndex].id) + PREFETCH_OFFSET >= cafes.length; if (shouldFetch) { fetchNextPage(); } + const itemRenderer = (cafe: Cafe) => ; + return ( - - {cafes.map((cafe) => ( - { - if (intersection.isIntersecting) { - setActiveCafe(cafe); - } - }} - /> - ))} - + ); }; export default HomePage; - -const CardList = styled.ul` - scroll-snap-type: y mandatory; - overflow-y: scroll; - height: 100%; - - & > * { - scroll-snap-align: start; - scroll-snap-stop: always; - } -`; diff --git a/client/src/styles/GlobalStyle.ts b/client/src/styles/GlobalStyle.ts index 3bd27fc7..c60a4751 100644 --- a/client/src/styles/GlobalStyle.ts +++ b/client/src/styles/GlobalStyle.ts @@ -19,6 +19,7 @@ const GlobalStyle = createGlobalStyle` body { position: fixed; + overflow-y: hidden; display: flex; justify-content: center; diff --git a/client/src/utils/timingFunctions.ts b/client/src/utils/timingFunctions.ts new file mode 100644 index 00000000..1f36297a --- /dev/null +++ b/client/src/utils/timingFunctions.ts @@ -0,0 +1,3 @@ +export const easeOutExpo = (x: number): number => { + return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); +};