04손을 뗄 때의 속도로 다음 장 여부를 계산하는 스와이프 플립
스와이프가 끝나기 직전 몇 프레임의 이동 거리로 속도 값을 계산해, 그 값이 기준을 넘으면 다음 페이지로 넘어가고 못 넘으면 제자리로 되돌아간다.
쓰임모바일 매뉴얼 뷰어 넘기기
SWIPE_FLIP_VELOCITY_PX_SvelocityNowspringEase

<main class="stage">
<div class="pvf is-demo" id="pvfRoot" tabindex="0" aria-label="페이지 스와이프 카드">
<div class="pvf__stack" id="pvfStack">
<div class="pvf__cardBack" id="pvfCardBack"></div>
<div class="pvf__card" id="pvfCard"></div>
</div>
<p class="pvf__hint" id="pvfHint">좌우로 밀기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pvf {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: grid;
place-items: center;
outline: none;
}
.pvf__stack { position: relative; width: 62%; height: 84%; }
.pvf__cardBack,
.pvf__card {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: $sp-4;
box-sizing: border-box;
}
.pvf__cardBack {
transform: scale(.94) translateY(6%);
box-shadow: 0 8px 16px rgba(23, 20, 26, .18);
}
.pvf__card {
cursor: grab;
touch-action: none;
will-change: transform;
}
.pvf__title { height: 10px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-3; }
.pvf__line { height: 6px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-2; }
.pvf__fig { height: 24%; margin-top: $sp-3; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvf__hint {
position: absolute;
bottom: $sp-2;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .5);
transition: opacity $dur-base ease;
}
@media (max-width: 340px) {
.pvf__stack { width: 74%; }
}
@media (prefers-reduced-motion: reduce) {
.pvf__card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvf {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: grid;
place-items: center;
outline: none;
}
.pvf__stack {
position: relative;
width: 62%;
height: 84%;
}
.pvf__cardBack,
.pvf__card {
position: absolute;
inset: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
box-sizing: border-box;
}
.pvf__cardBack {
transform: scale(0.94) translateY(6%);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.18);
}
.pvf__card {
cursor: grab;
touch-action: none;
will-change: transform;
}
.pvf__title {
height: 10px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 12px;
}
.pvf__line {
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 8px;
}
.pvf__fig {
height: 24%;
margin-top: 12px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvf__hint {
position: absolute;
bottom: 8px;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
transition: opacity 300ms ease;
}
@media (max-width: 340px) {
.pvf__stack {
width: 74%;
}
}
@media (prefers-reduced-motion: reduce) {
.pvf__card {
transition: none;
}
}(function () {
// 기법: 포인터를 떼기 직전 몇 개의 샘플(좌표+시각)만 남겨 두고, 그 구간의 이동 거리를
// 경과 시간으로 나눠 px/s 속도를 계산한다. 기준(SWIPE_FLIP_VELOCITY_PX_S)을 넘으면
// 카드가 화면 밖으로 날아가 다음 페이지로 넘어가고, 못 넘으면 스프링으로 제자리에 되돌아간다.
// 자동 시연도 같은 push/release 함수를 그대로 호출해 실제 입력과 동일 경로를 탄다.
var root = document.getElementById('pvfRoot');
var card = document.getElementById('pvfCard');
var cardBack = document.getElementById('pvfCardBack');
var hint = document.getElementById('pvfHint');
// 변형 04: SWIPE_FLIP_VELOCITY_PX_S 300 → 600 (전값이 소스 초기값)
var SWIPE_FLIP_VELOCITY_PX_S = 300;
var LOOP_MS = 2000;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var pageSeed = 430041;
function paintPage(el) {
var rnd = seedRand(pageSeed);
pageSeed += 7;
var html = '<div class="pvf__title" style="width:' + (44 + rnd() * 16) + '%"></div>';
for (var i = 0; i < 6; i += 1) html += '<div class="pvf__line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.3) html += '<div class="pvf__fig"></div>';
el.innerHTML = html;
}
paintPage(card);
paintPage(cardBack);
var dragging = false, samples = [], dragStartX = 0, cardStartX = 0;
var curX = 0, curRot = 0, flying = false, flyAnim = null, springAnim = null;
function pushSample(x, t) { samples.push({ x: x, t: t }); if (samples.length > 10) samples.shift(); }
function render() {
card.style.transform = 'translateX(' + curX.toFixed(1) + 'px) rotate(' + curRot.toFixed(2) + 'deg)';
card.style.opacity = String(Math.max(0, 1 - Math.abs(curX) / 460));
}
function velocityNow(nowT) {
if (samples.length < 2) return 0;
var windowStart = nowT - 120, first = samples[0];
for (var i = 0; i < samples.length; i += 1) { if (samples[i].t >= windowStart) { first = samples[i]; break; } }
var last = samples[samples.length - 1];
var dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.x - first.x) / dt;
}
function startDrag(x, t) { dragging = true; flying = false; springAnim = null; samples = []; dragStartX = x; cardStartX = curX; pushSample(x, t); }
function moveDrag(x, t) {
if (!dragging) return;
var dx = x - dragStartX;
curX = cardStartX + dx;
curRot = dx / 22;
pushSample(x, t);
render();
}
function release(t) {
if (!dragging) return;
dragging = false;
var v = velocityNow(t);
if (Math.abs(v) >= SWIPE_FLIP_VELOCITY_PX_S) flyOut(v < 0 ? -1 : 1, t);
else springBack(t);
}
function flyOut(dir, nowT) {
flying = true;
flyAnim = { fromX: curX, fromRot: curRot, toX: dir * 640, toRot: dir * 26, start: nowT, dur: 260 };
}
function springBack(nowT) {
springAnim = { fromX: curX, toX: 0, fromRot: curRot, toRot: 0, start: nowT, dur: 520 };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function springEase(t) {
// 오버슛이 있는 감쇠 스프링 근사
var d = 7.2, w = 5.6;
return 1 - Math.exp(-d * t) * Math.cos(w * t);
}
card.addEventListener('pointerdown', function (e) { markActive(); card.setPointerCapture(e.pointerId); startDrag(e.clientX, e.timeStamp); });
card.addEventListener('pointermove', function (e) { if (dragging) moveDrag(e.clientX, e.timeStamp); });
card.addEventListener('pointerup', function (e) { release(e.timeStamp); });
card.addEventListener('pointercancel', function (e) { release(e.timeStamp); });
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); hint.style.opacity = '0'; } }
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
markActive();
flyOut(e.key === 'ArrowLeft' ? -1 : 1, lastTs || 0);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; hint.style.opacity = '0'; }
var fired = {};
var SCHEDULE = [
{ t: 20, run: function (nowT) { paintPage(cardBack); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 420, run: function (nowT) { moveDrag(-360, nowT); } },
{ t: 640, run: function (nowT) { release(nowT); } },
{ t: 950, run: function (nowT) { paintPage(card); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 1350, run: function (nowT) { moveDrag(-70, nowT); } },
{ t: 1650, run: function (nowT) { release(nowT); } }
];
var lastTs = null, elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
elapsed = ts - (tick.originTs === undefined ? (tick.originTs = ts) : tick.originTs);
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (flyAnim) {
var p = Math.max(0, Math.min(1, (ts - flyAnim.start) / flyAnim.dur));
var e = easeOutCubic(p);
curX = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
curRot = flyAnim.fromRot + (flyAnim.toRot - flyAnim.fromRot) * e;
render();
if (p >= 1) { flyAnim = null; flying = false; }
} else if (springAnim) {
var sp = Math.max(0, Math.min(1, (ts - springAnim.start) / springAnim.dur));
var se = springEase(sp);
curX = springAnim.fromX + (springAnim.toX - springAnim.fromX) * se;
curRot = springAnim.fromRot + (springAnim.toRot - springAnim.fromRot) * se;
render();
if (sp >= 1) { curX = 0; curRot = 0; render(); springAnim = null; }
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerSwipeVelocityPageFlip.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type PointerEvt = { clientX: number; timeStamp: number; pointerId: number; preventDefault(): void };
export default function PdfViewerSwipeVelocityPageFlip() {
const rootRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const cardBackRef = useRef<HTMLDivElement>(null);
const hintRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, card = cardRef.current, cardBack = cardBackRef.current, hint = hintRef.current;
if (!root || !card || !cardBack || !hint) return;
// 변형 04: SWIPE_FLIP_VELOCITY_PX_S 300 → 600 (전값이 소스 초기값)
const SWIPE_FLIP_VELOCITY_PX_S = 300;
const LOOP_MS = 2000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
let pageSeed = 430041;
function paintPage(el: HTMLElement) {
const rnd = seedRand(pageSeed);
pageSeed += 7;
let html = '<div class="' + styles.title + '" style="width:' + (44 + rnd() * 16) + '%"></div>';
for (let i = 0; i < 6; i += 1) html += '<div class="' + styles.line + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.3) html += '<div class="' + styles.fig + '"></div>';
el.innerHTML = html;
}
paintPage(card);
paintPage(cardBack);
let dragging = false;
let samples: { x: number; t: number }[] = [];
let dragStartX = 0, cardStartX = 0, curX = 0, curRot = 0;
let flyAnim: { fromX: number; fromRot: number; toX: number; toRot: number; start: number; dur: number } | null = null;
let springAnim: { fromX: number; toX: number; fromRot: number; toRot: number; start: number; dur: number } | null = null;
function pushSample(x: number, t: number) { samples.push({ x, t }); if (samples.length > 10) samples.shift(); }
function render() {
card!.style.transform = "translateX(" + curX.toFixed(1) + "px) rotate(" + curRot.toFixed(2) + "deg)";
card!.style.opacity = String(Math.max(0, 1 - Math.abs(curX) / 460));
}
function velocityNow(nowT: number) {
if (samples.length < 2) return 0;
const windowStart = nowT - 120;
let first = samples[0];
for (let i = 0; i < samples.length; i += 1) { if (samples[i].t >= windowStart) { first = samples[i]; break; } }
const last = samples[samples.length - 1];
const dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return (last.x - first.x) / dt;
}
function startDrag(x: number, t: number) { dragging = true; flyAnim = null; springAnim = null; samples = []; dragStartX = x; cardStartX = curX; pushSample(x, t); }
function moveDrag(x: number, t: number) {
if (!dragging) return;
const dx = x - dragStartX;
curX = cardStartX + dx;
curRot = dx / 22;
pushSample(x, t);
render();
}
function release(t: number) {
if (!dragging) return;
dragging = false;
const v = velocityNow(t);
if (Math.abs(v) >= SWIPE_FLIP_VELOCITY_PX_S) flyOut(v < 0 ? -1 : 1, t);
else springBack(t);
}
function flyOut(dir: number, nowT: number) { flyAnim = { fromX: curX, fromRot: curRot, toX: dir * 640, toRot: dir * 26, start: nowT, dur: 260 }; }
function springBack(nowT: number) { springAnim = { fromX: curX, toX: 0, fromRot: curRot, toRot: 0, start: nowT, dur: 520 }; }
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function springEase(t: number) { const d = 7.2, w = 5.6; return 1 - Math.exp(-d * t) * Math.cos(w * t); }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); hint!.style.opacity = "0"; } }
function onPointerDown(e: PointerEvt) { markActive(); (card as unknown as { setPointerCapture(id: number): void }).setPointerCapture(e.pointerId); startDrag(e.clientX, e.timeStamp); }
function onPointerMove(e: PointerEvt) { if (dragging) moveDrag(e.clientX, e.timeStamp); }
function onPointerUp(e: PointerEvt) { release(e.timeStamp); }
card.addEventListener("pointerdown", onPointerDown as unknown as EventListener);
card.addEventListener("pointermove", onPointerMove as unknown as EventListener);
card.addEventListener("pointerup", onPointerUp as unknown as EventListener);
card.addEventListener("pointercancel", onPointerUp as unknown as EventListener);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
markActive();
flyOut(e.key === "ArrowLeft" ? -1 : 1, lastTs || 0);
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; hint.style.opacity = "0"; }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 20, run: (nowT: number) => { paintPage(cardBack!); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 420, run: (nowT: number) => { moveDrag(-360, nowT); } },
{ t: 640, run: (nowT: number) => { release(nowT); } },
{ t: 950, run: (nowT: number) => { paintPage(card!); curX = 0; curRot = 0; render(); startDrag(0, nowT); } },
{ t: 1350, run: (nowT: number) => { moveDrag(-70, nowT); } },
{ t: 1650, run: (nowT: number) => { release(nowT); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (flyAnim) {
const p = Math.max(0, Math.min(1, (ts - flyAnim.start) / flyAnim.dur));
const e = easeOutCubic(p);
curX = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
curRot = flyAnim.fromRot + (flyAnim.toRot - flyAnim.fromRot) * e;
render();
if (p >= 1) flyAnim = null;
} else if (springAnim) {
const sp = Math.max(0, Math.min(1, (ts - springAnim.start) / springAnim.dur));
const se = springEase(sp);
curX = springAnim.fromX + (springAnim.toX - springAnim.fromX) * se;
curRot = springAnim.fromRot + (springAnim.toRot - springAnim.fromRot) * se;
render();
if (sp >= 1) { curX = 0; curRot = 0; render(); springAnim = null; }
}
raf = requestAnimationFrame(tick);
}
render();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
card.removeEventListener("pointerdown", onPointerDown as unknown as EventListener);
card.removeEventListener("pointermove", onPointerMove as unknown as EventListener);
card.removeEventListener("pointerup", onPointerUp as unknown as EventListener);
card.removeEventListener("pointercancel", onPointerUp as unknown as EventListener);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvf} is-demo`} ref={rootRef} tabIndex={0} aria-label="페이지 스와이프 카드">
<div className={styles.stack}>
<div className={styles.cardBack} ref={cardBackRef} />
<div className={styles.card} ref={cardRef} />
</div>
<p className={styles.hint} ref={hintRef}>좌우로 밀기</p>
</div>
</main>
);
}04 손을 뗄 때의 속도로 다음 장 여부를 계산하는 스와이프 플립 — 측정 누적면적 42.158% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
이 데모가 속한 글
pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. p 이 글의 데모 전부 보기


