Skip to content

Commit

Permalink
Merge pull request #513 from woowacourse-teams/fix/480-swipe-is-too-f…
Browse files Browse the repository at this point in the history
…ast-with-touchpad

ScrollSnap Overhaul
  • Loading branch information
solo5star authored Oct 11, 2023
2 parents 01a4ddc + cfb0a26 commit 4f239ee
Show file tree
Hide file tree
Showing 13 changed files with 322 additions and 160 deletions.
2 changes: 1 addition & 1 deletion client/src/components/CafeDetailBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { Suspense, useEffect } from 'react';
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 CafeMenuMiniList from './CafeMenuMiniList';
import OpeningHoursDetail from './OpeningHoursDetail';
import QueryErrorBoundary from './QueryErrorBoundary';
import useScrollSnapGuard from './ScrollSnap/hooks/useScrollSnapGuard';

type CafeDetailBottomSheetProps = {
cafe: Cafe;
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/CafeMenuBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ 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 QueryErrorBoundary from './QueryErrorBoundary';
import useScrollSnapGuard from './ScrollSnap/hooks/useScrollSnapGuard';

type CafeMenuBottomSheetProps = {
cafe: Cafe;
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/ImageModal.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down
26 changes: 26 additions & 0 deletions client/src/components/ScrollSnap/components/ScrollSnap.tsx
Original file line number Diff line number Diff line change
@@ -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<Item> = ScrollSnapProps<Item> & {
// auto일 시 userAgent의 값에 따라 mode를 결정
// css일 시 ScrollSnapCSS 사용
// impl일 시 ScrollSnapImpl 사용
mode?: 'auto' | 'css' | 'impl';
};

const ScrollSnap = <Item,>(props: ScrollSnapWithMode<Item>) => {
const { mode = 'auto', ...restProps } = props;

if (mode === 'auto') {
return isMobile ? <ScrollSnapImpl {...restProps} /> : <ScrollSnapCSS {...restProps} />;
}
if (mode === 'css') {
return <ScrollSnapCSS {...restProps} />;
}
return <ScrollSnapImpl {...restProps} />;
};

export default ScrollSnap;
Original file line number Diff line number Diff line change
@@ -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 = <Item,>(props: ScrollSnapProps<Item>) => {
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<HTMLDivElement>(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 (
<Container {...divProps} ref={containerRef} onScroll={handleScroll}>
<ScrollSnapCSSItemList scrollPosition={clientScrollPosition} items={items} itemRenderer={itemRenderer} />
</Container>
);
};

export default ScrollSnapCSS;

const Container = styled.div`
scroll-snap-type: y mandatory;
overflow-y: scroll;
width: 100%;
height: 100%;
& > * {
scroll-snap-align: start;
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import ScrollSnapItem from '../ScrollSnapItem';

// 음수 mod 시 양수 값을 얻기 위한 함수
const mod = (n: number, m: number) => ((n % m) + m) % m;

type ScrollSnapCSSItemListProps<Item> = {
scrollPosition: number;
items: Item[];
itemRenderer: (item: Item, index: number) => React.ReactNode;
// focus된 아이템에서 일정 범위만큼 아이템을 렌더링
renderDistance?: number;
};

/**
* DOM scroll box에 의존적인 리스트
*
* CSS scroll-snap 사용 시 이 컴포넌트를 사용하여야 한다
*/
const ScrollSnapCSSItemList = <Item,>(props: ScrollSnapCSSItemListProps<Item>) => {
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 (
<ScrollSnapItem key={index} offset={offset} position={visiblePosition}>
{Math.abs(offset) <= renderDistance && itemRenderer(item, index)}
</ScrollSnapItem>
);
});
};

export default ScrollSnapCSSItemList;
Loading

0 comments on commit 4f239ee

Please sign in to comment.