tinder upd

This commit is contained in:
2026-05-13 12:47:12 +05:00
parent d77fdd0e2c
commit b101bb928e
7 changed files with 207 additions and 67 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+85 -58
View File
@@ -1,5 +1,10 @@
import Section from "../../ui/Section";
import { ANIMATION_DURATION, SwipeState } from "./helpers";
import {
AFTER_TINDER_SCROLL_THRESHOLD,
ANIMATION_DURATION,
IS_TINDER_SCROLL_THRESHOLD,
SwipeState,
} from "./helpers";
import CursorButtonWrapper from "../../ui/CursorButtonWrapper";
import {
useScroll,
@@ -14,6 +19,7 @@ import { useCollectionScrollCardMotion } from "./useCollectionScrollCardMotion";
import AppartamentDescription from "./AppartamentDescription";
import PlanDescription from "./PlanDescription";
import { COLLECTION_DATA, getInitialCollectionSlotIndices } from "./data";
import useDiscreteScroll from "@/hooks/useDiscreteScroll";
const DECK_LENGTH = COLLECTION_DATA.length;
@@ -34,17 +40,33 @@ export default function Collection() {
// Состояния
const isAnimating = useRef(false);
const [isTinder, setIsTinder] = useState(false);
const [afterTinder, setAfterTinder] = useState(false);
// Отслеживание скролла
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
useMotionValueEvent(scrollYProgress, "change", (latest) => {
setIsTinder(latest >= 0.8);
const discreteScrollProgress = useDiscreteScroll(scrollYProgress, [
0,
IS_TINDER_SCROLL_THRESHOLD,
AFTER_TINDER_SCROLL_THRESHOLD,
1,
]);
useMotionValueEvent(discreteScrollProgress, "change", (latest) => {
setIsTinder(
latest >= IS_TINDER_SCROLL_THRESHOLD &&
latest < AFTER_TINDER_SCROLL_THRESHOLD
);
setAfterTinder(latest >= AFTER_TINDER_SCROLL_THRESHOLD);
});
// Motion-значения в `vw` для трёх карточек коллекции при скролле.
const scrollCards = useCollectionScrollCardMotion(discreteScrollProgress);
// по сути, порядок изображений, которые сдивгается влево или вправо при листании,
// в результате чего обновялются отображаемые фото.
// Нужен для сохранения порядка изображения после выхода из состояния тиндера.
@@ -90,9 +112,6 @@ export default function Collection() {
return () => clearTimeout(timer);
}, [swipe]);
// Motion-значения в `vw` для трёх карточек коллекции при скролле.
const scrollCards = useCollectionScrollCardMotion(scrollYProgress);
const handleSliderClick = (direction: "right" | "left") => {
if (!isTinder || isAnimating.current || DECK_LENGTH === 0) return;
const nextOrder =
@@ -137,16 +156,17 @@ export default function Collection() {
}
return (
<Section id="collection" headerColorScheme="Dark">
<Section id="collection" headerColorScheme="Dark" className="relative">
<h2 className="text-[5.556vw] lg:px-[calc(15.042vw-1.667vw)] lg:mb-[2.778vw] leading-[1] w-full font-light flex flex-col gap-[0.556vw] select-none">
<span className="block">Коллекция авторских</span>
<span className="block ml-auto">резиденций</span>
</h2>
<div ref={ref} className="relative h-[200vh]">
<div className="sticky top-0 w-full h-[100vh] ">
<div ref={ref} className="relative h-[300vh]">
<div className="sticky top-0 w-full h-[100vh] z-[5]">
{/* Описание квартиры */}
<AnimatePresence mode="wait">
{isTinder && activeItem && (
{isTinder && activeItem && !afterTinder && (
<motion.div
key="description"
initial={{ opacity: 0, x: -100 }}
@@ -166,8 +186,9 @@ export default function Collection() {
)}
</AnimatePresence>
{/* План квартиры */}
<AnimatePresence mode="wait">
{isTinder && activeItem && (
{isTinder && activeItem && !afterTinder && (
<motion.div
key="plan"
initial={{ opacity: 0, x: 100 }}
@@ -185,57 +206,63 @@ export default function Collection() {
)}
</AnimatePresence>
{isTinder ? (
<>
{[0, 1, 2, 3].map((index) => (
<TinderSlotCard
key={index}
indexInDeck={index}
swipe={swipe}
src={getTinderImgSrc(index)}
{/* Карточки */}
<>
{isTinder || afterTinder ? (
<>
{[0, 1, 2, 3].map((index) => (
<TinderSlotCard
key={index}
indexInDeck={index}
swipe={swipe}
afterTinder={afterTinder}
src={getTinderImgSrc(index)}
className="-translate-y-1/2"
/>
))}
</>
) : (
<>
<CollectionCard
zIndex={2}
w={scrollCards[0].widthVw}
h={scrollCards[0].heightVw}
l={scrollCards[0].leftVw}
src={getImgSrc(0)}
className="-translate-y-1/2"
/>
))}
</>
) : (
<>
<CollectionCard
zIndex={2}
w={scrollCards[0].widthVw}
h={scrollCards[0].heightVw}
l={scrollCards[0].leftVw}
src={getImgSrc(0)}
className="-translate-y-1/2"
/>
<CollectionCard
zIndex={3}
w={scrollCards[1].widthVw}
h={scrollCards[1].heightVw}
l={scrollCards[1].leftVw}
src={getImgSrc(1)}
className="-translate-y-1/2"
/>
<CollectionCard
zIndex={2}
w={scrollCards[2].widthVw}
h={scrollCards[2].heightVw}
l={scrollCards[2].leftVw}
src={getImgSrc(2)}
className="-translate-y-1/2"
/>
</>
)}
<CollectionCard
zIndex={3}
w={scrollCards[1].widthVw}
h={scrollCards[1].heightVw}
l={scrollCards[1].leftVw}
src={getImgSrc(1)}
className="-translate-y-1/2"
/>
<CollectionCard
zIndex={2}
w={scrollCards[2].widthVw}
h={scrollCards[2].heightVw}
l={scrollCards[2].leftVw}
src={getImgSrc(2)}
className="-translate-y-1/2"
/>
</>
)}
{isTinder && (
<CursorButtonWrapper
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[31.111vw] h-[41.667vw] z-[4]"
onRightClick={() => handleSliderClick("left")}
onLeftClick={() => handleSliderClick("right")}
>
<div className="size-full" />
</CursorButtonWrapper>
)}
{isTinder && !afterTinder && (
<CursorButtonWrapper
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[48.111vw] h-[41.667vw] z-[4]"
onRightClick={() => handleSliderClick("left")}
onLeftClick={() => handleSliderClick("right")}
>
<div className="size-full" />
</CursorButtonWrapper>
)}
</>
</div>
<div className="w-[calc(100vw+3.333vw)] h-[100vh] bg-[url('/img/bg/room.png')] absolute bottom-0 left-1/2 -translate-x-1/2 -mx-[1.667vw] bg-cover bg-center z-[1]"></div>
</div>
</Section>
);
@@ -30,23 +30,34 @@ const CARDS_STATE = {
zIndex: 2,
},
under: {
width: "14.931vw",
height: "19.583vw",
width: "16.806vw",
height: "21.667vw",
left: "40.708vw",
zIndex: 1,
},
} as const;
const AFTER_TINDER_CARDS_STATE = {
middle: {
width: "16.806vw",
height: "21.667vw",
left: "40.708vw",
zIndex: 3,
},
} as const;
const Z_INDEX_DELAY_MS = (ANIMATION_DURATION * 1000) / 2;
export default function TinderSlotCard({
indexInDeck,
swipe,
afterTinder,
src,
className,
}: {
indexInDeck: number;
swipe: SwipeState;
afterTinder: boolean;
src: string;
className?: string;
}) {
@@ -59,15 +70,30 @@ export default function TinderSlotCard({
// Переход из состояния
const baseGeo = cardPosition(indexInDeck, { phase: "idle" });
// в состояние
const geo = cardPosition(indexInDeck, swipe);
const isTopCard = indexInDeck === 1;
const geo =
afterTinder && isTopCard
? AFTER_TINDER_CARDS_STATE.middle
: cardPosition(indexInDeck, swipe);
const opacity = afterTinder && !isTopCard ? 0 : 1;
const swipeDir = swipe.phase === "animating" ? swipe.dir : undefined;
const [isAfterTinderTransition, setIsAfterTinderTransition] =
useState(afterTinder);
/** В idle z-index из геометрии слота — без кадра с «чужим» delayedZ */
const [delayedZ, setDelayedZ] = useState(() => baseGeo.zIndex);
const zForMotion = swipe.phase === "idle" ? geo.zIndex : delayedZ;
const zHalfTimerRef = useRef<number | undefined>(undefined);
useLayoutEffect(() => {
const timer = window.setTimeout(() => {
setIsAfterTinderTransition(afterTinder);
}, afterTinder ? 0 : ANIMATION_DURATION * 1000);
return () => clearTimeout(timer);
}, [afterTinder]);
useLayoutEffect(() => {
if (swipe.phase !== "animating") return;
@@ -95,8 +121,10 @@ export default function TinderSlotCard({
};
}, [swipe.phase, swipeDir, indexInDeck, geo.zIndex, baseGeo.zIndex]);
const shouldAnimateCard =
swipe.phase === "animating" || afterTinder || isAfterTinderTransition;
const transition =
swipe.phase === "animating"
shouldAnimateCard
? {
duration: ANIMATION_DURATION,
ease: "easeInOut" as const,
@@ -111,13 +139,16 @@ export default function TinderSlotCard({
width: geo.width,
height: geo.height,
left: geo.left,
opacity,
zIndex: zForMotion,
}}
transition={transition}
style={{ pointerEvents: afterTinder && !isTopCard ? "none" : undefined }}
className={clsx(
"absolute top-1/2",
swipe.phase === "animating" &&
"will-change-[transform,width,height,left]",
indexInDeck === 1 && "shadow-md",
className
)}
>
@@ -1,5 +1,11 @@
export const ANIMATION_DURATION = 0.5;
// Расстояние до перехода от трех отдельных карточек до тиндера
export const IS_TINDER_SCROLL_THRESHOLD = 0.15;
// Расстояние до перехода от тиндера до одной карточки
export const AFTER_TINDER_SCROLL_THRESHOLD = 0.4;
export type SwipeState =
| { phase: "idle" }
| { phase: "animating"; dir: "left" | "right" };
@@ -1,7 +1,8 @@
import { type MotionValue, useTransform } from "framer-motion";
import { IS_TINDER_SCROLL_THRESHOLD } from "./helpers";
/** Диапазон прогресса скролла секции (как в useScroll offset) */
const SCROLL_INPUT: [number, number] = [0, 0.8];
const SCROLL_INPUT: [number, number] = [0, IS_TINDER_SCROLL_THRESHOLD];
/** Тройки [начало, конец] для left / width / height по каждой из трёх карточек вне тиндера */
export const COLLECTION_SCROLL_CARD_SPECS: Array<{
+8 -4
View File
@@ -40,10 +40,13 @@ export default function CursorButtonWrapper({
const targetRef = wrapperRef ?? localWrapperRef;
const setHovered = useCallback((value: boolean) => {
setIsHovered(value);
onHoverChange?.(value);
}, [onHoverChange]);
const setHovered = useCallback(
(value: boolean) => {
setIsHovered(value);
onHoverChange?.(value);
},
[onHoverChange]
);
const handleMouseEnter = () => {
setHovered(true);
@@ -121,6 +124,7 @@ export default function CursorButtonWrapper({
className={clsx("relative hover:cursor-none", className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={() => !isHovered && handleMouseEnter()}
onClick={handleClick}
>
<AnimatePresence mode="wait">
+71
View File
@@ -0,0 +1,71 @@
import {
MotionValue,
useMotionValue,
useMotionValueEvent,
useSpring,
} from "framer-motion";
/**
* Хук, который преобразует непрерывный прогресс скролла в дискретное
* значение индекса сегмента, а затем плавно сглаживает его с помощью useSpring.
* * @param scrollProgress MotionValue от 0 до 1 (например, scrollYProgress).
* @param breakpoints Массив чисел от 0 до 1, определяющих границы сегментов.
* @returns MotionValue сглаженное с помощью useSpring, представляющее текущее значение, соответствующие сегментам breakpoints.
* @example
* // Здесь segmentProgress будет принимать одно из значений [0, 0.33, 0.66, 1]
* const segmentProgress = useDiscreteScroll(
scrollYProgress,
[0, 0.33, 0.66, 1]
);
return (
<motion.p style={{
opacity: useTransform(
segmentProgress,
[0, 0.33, 0.66],
[0, 1, 0]
)}}
>
<Content/>
</motion.p>
)
*
*/
function useDiscreteScroll(
scrollProgress: MotionValue<number>,
breakpoints: number[]
): MotionValue<number> {
const discreteIndex = useMotionValue(0);
const smoothedIndex = useSpring(discreteIndex, {
stiffness: 160,
damping: 25,
restDelta: 0.001,
});
useMotionValueEvent(scrollProgress, "change", (latestScroll) => {
let newIndex = 0;
for (let i = 1; i < breakpoints.length; i++) {
const start = breakpoints[i - 1];
const end = breakpoints[i];
if (latestScroll >= start && latestScroll < end) {
newIndex = i - 1;
break;
}
if (i === breakpoints.length - 1 && latestScroll >= end) {
newIndex = breakpoints.length - 1;
break;
}
}
if (newIndex !== discreteIndex.get()) {
discreteIndex.set(breakpoints[newIndex]);
}
});
return smoothedIndex;
}
export default useDiscreteScroll;