Skip to content

Instantly share code, notes, and snippets.

@solo5star
Created September 23, 2023 17:52
Show Gist options
  • Save solo5star/ca5788e3c2b75c3c8ab017a4fb97c438 to your computer and use it in GitHub Desktop.
Save solo5star/ca5788e3c2b75c3c8ab017a4fb97c438 to your computer and use it in GitHub Desktop.
scroll snap implementation with JavaScript(TypeScript)
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