Spaces:
Sleeping
Sleeping
File size: 5,318 Bytes
c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 b58fb59 2490428 b58fb59 c92b696 2490428 c92b696 2490428 b58fb59 2490428 b58fb59 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 2490428 c92b696 | 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 | 'use client';
/**
* Screen — the interactive view layer for the IOSStack.
*
* Each Screen is an absolutely-positioned layer. Its horizontal position
* is controlled by a MotionValue passed from IOSStack:
* - TOP page: x = dragX (draggable via touch)
* - BEHIND page: x = parallaxX (derived from dragX)
* - EXITING page: x = dragX (animating to W)
*
* Drag gesture:
* - Touch must start within EDGE_ZONE_PX of the left edge
* - Only horizontal-dominant gestures are intercepted (vertical = page scroll)
* - On release: if past COMMIT_THRESHOLD → animate dragX to W, then onBack()
* otherwise → animate dragX back to 0 (snap back)
*/
import { motion, animate, type MotionValue } from 'framer-motion';
import {
type ReactNode,
useRef,
useState,
type TouchEvent as ReactTouchEvent,
useEffect,
} from 'react';
const EDGE_ZONE_PX = 28;
const COMMIT_THRESHOLD_PX = 120;
const DRAG_MAX_PX = 320;
// Match the IOSStack push/back durations for visual consistency.
// Drag-commit is slightly faster (0.35s) because the user has already
// visually "committed" by dragging past the threshold — a long animation
// here would feel laggy.
const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const SNAP_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const DRAG_COMMIT_DURATION = 0.35;
const DRAG_CANCEL_DURATION = 0.3;
interface ScreenProps {
children: ReactNode;
isTop: boolean;
isExiting: boolean;
xValue: MotionValue<number>;
overlayOpacity?: MotionValue<number>;
onBack: () => void;
}
export function Screen({ children, isTop, isExiting, xValue, overlayOpacity, onBack }: ScreenProps) {
const [isDragging, setIsDragging] = useState(false);
const touchState = useRef<{
startX: number;
startY: number;
active: boolean;
horizontal: boolean | null;
} | null>(null);
// Get window width (cached)
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 375;
// ---- Touch handlers (only for the TOP, non-exiting page) ----
function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
if (!isTop || isExiting) return;
const t = e.touches[0];
if (!t) return;
// Only start tracking if touch is in 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,
};
}
function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
if (!isTop || isExiting) return;
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 on first significant move
if (state.horizontal === null) {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < 8 && absDy < 8) return;
// Vertical-dominant → let the browser handle it (page scroll)
if (absDy > absDx) {
state.active = false;
return;
}
state.horizontal = true;
setIsDragging(true);
}
if (!state.horizontal) return;
// Prevent vertical scroll while dragging horizontally
if (e.cancelable) e.preventDefault();
// Follow the finger, capped at DRAG_MAX_PX
const clamped = Math.max(0, Math.min(dx, DRAG_MAX_PX));
xValue.set(clamped);
}
function onTouchEnd() {
if (!isTop || isExiting) return;
const state = touchState.current;
touchState.current = null;
if (!state || !state.active || !state.horizontal) {
setIsDragging(false);
return;
}
const current = xValue.get();
if (current >= COMMIT_THRESHOLD_PX) {
// COMMIT: animate the page all the way off-screen, THEN navigate back
animate(xValue, windowWidth, {
duration: DRAG_COMMIT_DURATION,
ease: SLIDE_EASE,
onComplete: () => {
onBack();
},
});
} else {
// CANCEL: snap back to 0
animate(xValue, 0, {
duration: DRAG_CANCEL_DURATION,
ease: SNAP_EASE,
onComplete: () => setIsDragging(false),
});
}
}
// ---- Render ----
return (
<motion.div
style={{
position: 'absolute',
inset: 0,
x: xValue,
background: '#fff',
boxShadow: isTop && !isExiting ? '-10px 0 25px rgba(0,0,0,0.2)' : 'none',
zIndex: isExiting ? 2 : isTop ? 2 : 1,
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
{/* Page content — each screen has its own scroll container */}
<div
style={{
height: '100%',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
background: '#fff',
}}
>
{children}
</div>
{/* Dimming overlay for the BEHIND page */}
{overlayOpacity && (
<motion.div
style={{
position: 'absolute',
inset: 0,
background: '#000',
opacity: overlayOpacity,
pointerEvents: 'none',
}}
/>
)}
</motion.div>
);
}
|