-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #513 from woowacourse-teams/fix/480-swipe-is-too-f…
…ast-with-touchpad ScrollSnap Overhaul
- Loading branch information
Showing
13 changed files
with
322 additions
and
160 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
client/src/components/ScrollSnap/components/ScrollSnap.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
113 changes: 113 additions & 0 deletions
113
client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSS.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
`; |
36 changes: 36 additions & 0 deletions
36
client/src/components/ScrollSnap/components/ScrollSnapCSS/ScrollSnapCSSItemList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.