File size: 6,800 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
205
206
207
208
209
210
211
212
213
214
'use client';

/**
 * IOSStack — iOS-style navigation with "Move with me" drag + frozen previous page.
 *
 * FIX: hasPrevious uses a ref (not state) to avoid race conditions between
 * the drag-back callback's setHasPrevious(false) and the render-time
 * setHasPrevious(true). Refs update synchronously — no batching issues.
 */

import { motion, AnimatePresence, useMotionValue, useTransform, animate } from 'framer-motion';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react';

const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const PUSH_DURATION = 0.45;
const BACK_DURATION = 0.4;
const EDGE_ZONE_PX = 28;
const COMMIT_THRESHOLD_PX = 120;

export function IOSStack({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  const router = useRouter();

  const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375;

  // --- Frozen previous page ---
  const previousChildren = useRef<ReactNode | null>(null);
  const lastChildren = useRef<ReactNode>(children);
  const lastPath = useRef<string>(pathname);
  // FIX: Use a ref for hasPrevious to avoid race conditions.
  // State batching can cause setHasPrevious(false) from drag-back to
  // override setHasPrevious(true) from the render-time check.
  const hasPreviousRef = useRef(false);
  const [hasPreviousState, setHasPreviousState] = useState(false);
  const [direction, setDirection] = useState<'forward' | 'back'>('forward');

  // Helper to update both ref and state
  const setHasPrevious = (val: boolean) => {
    hasPreviousRef.current = val;
    setHasPreviousState(val);
  };

  // --- Render-time capture of previous children ---
  if (lastPath.current !== pathname) {
    previousChildren.current = lastChildren.current;
    setHasPrevious(true);
    setDirection(lastPath.current.length > pathname.length ? 'back' : 'forward');
    lastPath.current = pathname;
  }
  lastChildren.current = children;

  // --- Motion values ---
  const x = useMotionValue(0);
  const prevPageX = useTransform(x, [0, windowWidth], [0, windowWidth * 0.2]);
  const shadowOpacity = useTransform(x, [0, windowWidth], [0.35, 0]);
  const prevPageScale = useTransform(x, [0, windowWidth], [0.96, 1]);

  // --- Manual touch handlers ---
  const touchState = useRef<{
    startX: number;
    startY: number;
    active: boolean;
    horizontal: boolean | null;
  }>({ startX: 0, startY: 0, active: false, horizontal: null });
  const [isDragging, setIsDragging] = useState(false);

  function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
    // FIX: Use the ref (synchronous) instead of state (may be stale due to batching)
    if (!hasPreviousRef.current) return;
    const t = e.touches[0];
    if (!t || t.clientX > EDGE_ZONE_PX) {
      touchState.current = { startX: 0, startY: 0, active: false, horizontal: null };
      return;
    }
    touchState.current = {
      startX: t.clientX,
      startY: t.clientY,
      active: true,
      horizontal: null,
    };
  }

  function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
    const state = touchState.current;
    if (!state.active) return;
    const t = e.touches[0];
    if (!t) return;
    const dx = t.clientX - state.startX;
    const dy = t.clientY - state.startY;

    if (state.horizontal === null) {
      const absDx = Math.abs(dx);
      const absDy = Math.abs(dy);
      if (absDx < 8 && absDy < 8) return;
      if (absDy > absDx) {
        state.active = false;
        return;
      }
      state.horizontal = true;
      setIsDragging(true);
    }

    if (!state.horizontal) return;
    if (e.cancelable) e.preventDefault();

    const clamped = Math.max(0, Math.min(dx, windowWidth));
    x.set(clamped);
  }

  function onTouchEnd() {
    const state = touchState.current;
    touchState.current = { startX: 0, startY: 0, active: false, horizontal: null };
    if (!state.active || !state.horizontal) {
      setIsDragging(false);
      return;
    }

    const current = x.get();
    if (current >= COMMIT_THRESHOLD_PX) {
      // Commit: animate off-screen, then navigate back
      animate(x, windowWidth, {
        duration: 0.3,
        ease: SLIDE_EASE,
        onComplete: () => {
          // FIX: Don't clear hasPrevious here — let the render-time check
          // handle it when the pathname changes after router.back().
          // This prevents the race condition where setHasPrevious(false)
          // overrides the render-time setHasPrevious(true).
          router.back();
          requestAnimationFrame(() => {
            x.set(0);
            setIsDragging(false);
          });
        },
      });
    } else {
      animate(x, 0, {
        duration: 0.3,
        ease: SLIDE_EASE,
        onComplete: () => setIsDragging(false),
      });
    }
  }

  // --- Animation variants ---
  const variants = {
    enter: (dir: string) => ({
      x: dir === 'forward' ? '100%' : '0%',
      zIndex: 10,
    }),
    center: {
      x: 0,
      zIndex: 10,
      transition: { duration: PUSH_DURATION, ease: SLIDE_EASE },
    },
    exit: (dir: string) => ({
      x: dir === 'forward' ? '-20%' : '100%',
      zIndex: dir === 'forward' ? 1 : 10,
      transition: { duration: BACK_DURATION, ease: SLIDE_EASE },
    }),
  };

  return (
    <div style={{ position: 'fixed', inset: 0, overflow: 'hidden', background: '#000' }}>
      {/* Layer 0: Frozen Previous Page */}
      {hasPreviousState && previousChildren.current && (
        <motion.div
          className="absolute inset-0 bg-white"
          style={{
            x: prevPageX,
            scale: prevPageScale,
            zIndex: 0,
            pointerEvents: 'none',
          }}
          aria-hidden="true"
        >
          {previousChildren.current}
          <motion.div
            className="absolute inset-0 bg-black pointer-events-none"
            style={{ opacity: shadowOpacity }}
          />
        </motion.div>
      )}

      {/* Layer 1: Active Page */}
      <AnimatePresence mode="popLayout" custom={direction} initial={false}>
        <motion.div
          key={pathname}
          custom={direction}
          variants={variants}
          initial="enter"
          animate="center"
          exit="exit"
          style={{
            x,
            zIndex: 10,
            position: 'absolute',
            inset: 0,
            background: '#fff',
            boxShadow: isDragging ? '-8px 0 25px rgba(0,0,0,0.2)' : 'none',
          }}
          onTouchStart={onTouchStart}
          onTouchMove={onTouchMove}
          onTouchEnd={onTouchEnd}
          onTouchCancel={onTouchEnd}
        >
          {children}
        </motion.div>
      </AnimatePresence>
    </div>
  );
}