Created
September 23, 2023 17:52
-
-
Save solo5star/ca5788e3c2b75c3c8ab017a4fb97c438 to your computer and use it in GitHub Desktop.
scroll snap implementation with JavaScript(TypeScript)
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
import { | |
RefObject, | |
TouchEventHandler, | |
useRef, | |
useState, | |
useSyncExternalStore, | |
} from "react"; | |
const useDOMHeight = (ref: RefObject<HTMLElement>) => { | |
return useSyncExternalStore( | |
(callback) => { | |
const observer = new ResizeObserver(callback); | |
if (ref.current) observer.observe(ref.current); | |
return () => observer.disconnect(); | |
}, | |
() => ref.current?.clientHeight ?? 1 | |
); | |
}; | |
function easeOutExpo(x: number): number { | |
return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); | |
} | |
function easeOutBounce(x: number): number { | |
const n1 = 7.5625; | |
const d1 = 2.75; | |
if (x < 1 / d1) { | |
return n1 * x * x; | |
} else if (x < 2 / d1) { | |
return n1 * (x -= 1.5 / d1) * x + 0.75; | |
} else if (x < 2.5 / d1) { | |
return n1 * (x -= 2.25 / d1) * x + 0.9375; | |
} else { | |
return n1 * (x -= 2.625 / d1) * x + 0.984375; | |
} | |
} | |
function App() { | |
// 스크롤 위치를 나타내는 값이며 터치 움직임이 누적된 값이기도 하다 | |
const [scrollPosition, setScrollPosition] = useState(0); | |
const [prevTouch, setPrevTouch] = useState<React.Touch | null>(null); | |
const containerRef = useRef<HTMLDivElement>(null); | |
const height = useDOMHeight(containerRef); | |
type RecentPositionHistory = Array<{ position: number; timestamp: number }>; | |
const [recentPositionHistory, setRecentPositionHistory] = | |
useState<RecentPositionHistory>([]); | |
// snap 애니메이션을 위한 상태들 | |
const [snapStartedAt, setSnapStartedAt] = useState(0); | |
const [snapStartedPosition, setSnapStartedPosition] = useState(0); | |
const [snapTargetPosition, setSnapTargetPosition] = useState(0); | |
const handleTouchMove: TouchEventHandler = (event) => { | |
const touch = event.touches[0]!; | |
setPrevTouch(touch); | |
if (!prevTouch) return; | |
const diff = (touch.pageY - prevTouch.pageY) / height; | |
const position = scrollPosition + diff; | |
setScrollPosition(position); | |
// 최근 100ms 이내의 터치 포인트들을 기억 | |
setRecentPositionHistory((prev) => | |
[...prev, { position, timestamp: Date.now() }].filter( | |
({ timestamp }) => Date.now() - timestamp < 100 | |
) | |
); | |
}; | |
const handleTouchEnd: TouchEventHandler = () => { | |
// 터치 종료. 다음 터치 입력에서는 사용하지 않으므로 삭제 처리 | |
setPrevTouch(null); | |
setRecentPositionHistory([]); | |
// snap 애니메이션을 발생시킨다 | |
setSnapStartedAt(Date.now()); | |
setSnapStartedPosition(scrollPosition); | |
// 빠르게 스와이프하였는지? | |
const fastSwipeDistance = | |
recentPositionHistory[0].position - scrollPosition; | |
if (Math.abs(fastSwipeDistance) > 0.03) { | |
setSnapTargetPosition( | |
Math.round(scrollPosition) + (fastSwipeDistance > 0 ? -1 : 1) | |
); | |
return; | |
} | |
// 가까운 요소로 이동 | |
setSnapTargetPosition(Math.round(scrollPosition)); | |
}; | |
// snap 중인 경우 애니메이션 처리 | |
if (Date.now() - snapStartedAt < 300) { | |
requestAnimationFrame(() => { | |
const progress = (Date.now() - snapStartedAt) / 300; | |
const position = | |
snapStartedPosition + | |
(snapTargetPosition - snapStartedPosition) * easeOutExpo(progress); | |
setScrollPosition(position); | |
}); | |
} | |
// snap 종료 처리 | |
else if (snapStartedAt !== 0) { | |
setSnapStartedAt(0); | |
setScrollPosition(snapTargetPosition); | |
} | |
return ( | |
// 스크롤 컨테이너 | |
<div | |
ref={containerRef} | |
onTouchMove={handleTouchMove} | |
onTouchEnd={handleTouchEnd} | |
style={{ | |
width: "200px", | |
height: "400px", | |
overflow: "hidden", | |
border: "2px solid black", | |
resize: "vertical", | |
}} | |
> | |
{/* 자식 요소들을 이동 처리하기 위한 컨테이너 */} | |
<div | |
style={{ | |
height: "100%", | |
transform: `translateY(${scrollPosition * height}px)`, | |
}} | |
> | |
{Array(10) | |
.fill(undefined) | |
.map((_, index) => ( | |
<div | |
style={{ | |
height: "100%", | |
background: `hsl(${index * 30}, 100%, 70%)`, | |
}} | |
> | |
{index} | |
</div> | |
))} | |
</div> | |
</div> | |
); | |
} | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment