Spaces:
Sleeping
Sleeping
File size: 7,629 Bytes
bea55e2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | 'use client';
/**
* PageTransition
* --------------
* Wraps every Next.js App Router page and:
*
* 1. **Slide-in on navigation** β when the route changes, the new page slides
* in from right-to-left (x: 100% -> 0). This is the "opening a new page"
* animation the user asked for.
*
* 2. **iOS-style swipe-back gesture** β when the user starts a touch within
* the leftmost ~28px of the screen and drags rightward, the page follows
* their finger (sliding left-to-right, as if being pushed off-screen).
* Releasing past ~120px commits the back navigation; otherwise the page
* snaps back. This is the "exiting a page" animation the user asked for
* and matches the iOS native feel.
*
* 3. Sizing safety β the shell is pinned to `width:100%` and overflow is
* hidden on <main>, so the slide animation can never introduce a
* horizontal scrollbar.
*
* The component is intentionally self-contained: it owns its own motion
* values, its own touch listeners, and its own route-change subscription
* (via usePathname). It does NOT use AnimatePresence because the App Router
* swaps `children` in place β keyed remounting via `key={pathname}` is enough
* to trigger the entrance animation on every navigation.
*/
import { motion, useMotionValue, useTransform, animate, type PanInfo } from 'framer-motion';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react';
// ---- Tunable constants ------------------------------------------------------
const EDGE_ZONE_PX = 28; // Touch must start within this many px of the left edge.
const COMMIT_THRESHOLD_PX = 120; // Drag this far right to actually go back.
const DRAG_MAX_PX = 280; // Visual cap on how far the page will follow the finger.
const SLIDE_DURATION = 0.32; // seconds β entrance animation length
const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; // iOS-like ease
// Module-level flag β set to true right before a swipe-back commit so the
// NEXT mount of <PageTransition> knows to skip its slide-in animation.
// (The user already saw the previous page revealed as they dragged; replaying
// a slide-in would feel like a glitch.)
let suppressNextEntrance = false;
export function PageTransition({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
// Single source of truth for the shell's horizontal position.
const x = useMotionValue(0);
// Dim the page slightly as it slides off β sells the "card being pushed
// over the previous page" effect.
const opacity = useTransform(x, [0, DRAG_MAX_PX], [1, 0.7]);
// Subtle scale on the underlying card so the depth reads correctly.
const scale = useTransform(x, [0, DRAG_MAX_PX], [1, 0.96]);
const [isDragging, setIsDragging] = useState(false);
const touchState = useRef<{
startX: number;
startY: number;
active: boolean;
horizontal: boolean | null;
} | null>(null);
// ----- Entrance animation: slide in from the right (x = innerWidth -> 0) --
// Runs on every pathname change because key={pathname} remounts the shell.
useEffect(() => {
if (typeof window === 'undefined') return;
// If this mount immediately follows a swipe-back commit, skip the
// slide-in animation β the user already saw the previous page revealed.
if (suppressNextEntrance) {
suppressNextEntrance = false;
x.set(0);
return;
}
// Default forward navigation: slide in from the right.
x.set(window.innerWidth);
const controls = animate(x, 0, {
duration: SLIDE_DURATION,
ease: SLIDE_EASE,
});
return () => controls.stop();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// ----- Swipe-back gesture handlers -----------------------------------------
function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
if (typeof window === 'undefined') return;
const t = e.touches[0];
if (!t) return;
// Only begin tracking if the touch began inside the left edge zone.
if (t.clientX > EDGE_ZONE_PX) {
touchState.current = null;
return;
}
touchState.current = {
startX: t.clientX,
startY: t.clientY,
active: true,
horizontal: null, // undecided yet β we decide after the first move
};
}
function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
const state = touchState.current;
if (!state || !state.active) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - state.startX;
const dy = t.clientY - state.startY;
// Decide gesture direction once we have enough movement.
if (state.horizontal === null) {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < 8 && absDy < 8) return; // ignore micro-jitter
// Vertical-dominant movement = the user is scrolling the page, not
// swiping back. Release control to the browser.
if (absDy > absDx) {
state.active = false;
return;
}
state.horizontal = true;
setIsDragging(true);
}
if (!state.horizontal) return;
// Block the page from scrolling while we drag horizontally.
if (e.cancelable) e.preventDefault();
// Only allow rightward movement, and cap it so the page doesn't fly off.
const clamped = Math.max(0, Math.min(dx, DRAG_MAX_PX));
x.set(clamped);
}
function onTouchEnd() {
const state = touchState.current;
touchState.current = null;
if (!state || !state.active || !state.horizontal) {
// No active drag β nothing to commit / cancel.
setIsDragging(false);
return;
}
const current = x.get();
if (current >= COMMIT_THRESHOLD_PX) {
// Commit: animate the rest of the way off-screen, then go back.
const target = typeof window !== 'undefined' ? window.innerWidth : DRAG_MAX_PX;
animate(x, target, {
duration: 0.22,
ease: [0.4, 0, 1, 1],
onComplete: () => {
// Tell the next <PageTransition> mount (the page we're going back
// to) to skip its slide-in animation β the gesture already revealed it.
suppressNextEntrance = true;
router.back();
// Safety net in case the route change doesn't remount the shell
// (e.g. navigating to the same pathname). Reset on next frame.
requestAnimationFrame(() => x.set(0));
setIsDragging(false);
},
});
} else {
// Cancel: snap back to 0.
animate(x, 0, {
duration: 0.24,
ease: SLIDE_EASE,
onComplete: () => setIsDragging(false),
});
}
}
// ----- Render --------------------------------------------------------------
return (
<motion.div
key={pathname}
className={`page-shell${isDragging ? ' is-dragging' : ''}`}
style={{ x, opacity, scale }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
// Prevent the browser from intercepting the gesture as a native
// overscroll/refresh once we're committed to a horizontal drag.
// (Per-gesture preventDefault in onTouchMove handles the actual blocking;
// this attribute is the belt-and-braces version.)
data-swipe-back="true"
>
{children}
</motion.div>
);
}
// Re-export PanInfo so downstream consumers can type their own handlers if needed.
export type { PanInfo };
|