tinder upd
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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<{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user