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 };