Skip to content

Commit

Permalink
Merge pull request #34 from Nexters/feat/#33
Browse files Browse the repository at this point in the history
[ Feat/#33 ] Chat page 카드 애니메이션 구현
  • Loading branch information
ljh0608 authored Jan 31, 2025
2 parents 90f7d8e + 2735eb4 commit 60f4c0e
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as motion from "motion/react-client";
import Image from "next/image";
import catHand from "@/shared/assets/images/catHand.png";
import cardBack from "@/shared/assets/images/cardBack.jpg";
import cardBack from "@/shared/assets/images/cardBack.webp";
import cardFront from "@/shared/assets/images/Card1.jpg";
import styled from "styled-components";
import { easeInOut } from "motion";
Expand Down
128 changes: 128 additions & 0 deletions src/chat/components/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import Image from "next/image";
import styled from "styled-components";
import CardBack from "@/shared/assets/images/cardBack.webp";
import { cubicBezier, easeOut } from "motion";
import { div } from "motion/react-client";
import { useState } from "react";
import { Dispatch, SetStateAction, useRef, useEffect } from "react";
import { CardPickState } from "../models/CardPickState";
import { DeckState } from "../models/DeckState";
interface PropTypes {
idx: number;
deckState: DeckState;
setDeckState: Dispatch<SetStateAction<DeckState>>;
onClick: (index: number) => void;
cardPickState: CardPickState[];
}

const Card = ({
idx,
deckState,
setDeckState,
onClick,
cardPickState,
}: PropTypes) => {
const [isCardShadow, setIsCardShadow] = useState(false);
const [moveDistance, setMoveDistance] = useState(0);
const cardRef = useRef<HTMLDivElement>(null);

const cardVariants = {
initial: { x: 0, y: 0, rotate: 0 },
spread: {
x: [0, idx * 50, idx * 50 + moveDistance],
transition: {
duration: 1.2,
delay: 0.7,
time: [0, 0.7, 1.2],
ease: [cubicBezier(0.44, 0, 0.56, 1), "easeInOut"],
},
},

infiniteScroll: {
x: [0, idx * 50, idx * 50 + moveDistance],
transition: {
duration: 0.01,
ease: easeOut,
},
},

clickAnimation: {
y: -50,
rotate: 16,
transition: {
duration: 0.3,
},
},

cardDownAnimation: {
y: 0,
rotate: 0,
transition: {
duration: 0.3,
},
},
};

const onAnimationEnd = () => {
setIsCardShadow(true);
setDeckState("Spread");
};

const handleClickCard = () => {
onClick(idx);
};

const getCardAnimation = () => {
if (deckState === "Stack") return "spread";
if (deckState === "Spread") {
if (cardPickState[idx] === "Pick") return "clickAnimation";
if (cardPickState[idx] === "Down") return "cardDownAnimation";
}
return "infiniteScroll";
};

useEffect(() => {
if (cardRef.current) {
const cardPos = cardRef.current.offsetLeft;
setMoveDistance(-cardPos); // 부모 기준 왼쪽으로 붙이기
}
}, []);

return (
<CardAnimationWrapper
ref={cardRef}
variants={cardVariants}
animate={getCardAnimation()}
onClick={handleClickCard}
onAnimationComplete={onAnimationEnd}
>
<CardWrapper
src={CardBack}
alt="카드 뒷면 이미지"
isCardShadow={isCardShadow}
/>
</CardAnimationWrapper>
);
};

export default Card;

const CardAnimationWrapper = styled(div)`
width: 100px;
height: 160px;
position: absolute;
cursor: pointer;
`;

const CardWrapper = styled(Image)<{ isCardShadow: boolean }>`
border-radius: 8px;
box-shadow: ${({ isCardShadow }) =>
isCardShadow ? "-8px 0px 12px 0px rgba(0, 0, 0, 0.15)" : ""};
width: 100px;
height: 160px;
`;
120 changes: 120 additions & 0 deletions src/chat/components/ChatCardSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import styled from "styled-components";
import * as motion from "motion/react-client";
import { easeInOut } from "motion";
import Card from "./Card";
import { useState, useEffect, useRef } from "react";
import { DeckState } from "../models/DeckState";
import { CardPickState } from "../models/CardPickState";
interface PropTypes {
/** 타입 변경 필요 */
onClick: () => void;
}

const riseUpCardDeck = {
duration: 0.6,
ease: easeInOut,
};

const ChatCardSelect = ({ onClick }: PropTypes) => {
const [deckState, setDeckState] = useState<DeckState>("Stack");
const ITEMS_PER_LOAD = 15;
const [items, setItems] = useState<CardPickState[]>(
Array.from({ length: ITEMS_PER_LOAD }, () => "Default")
);

const observerRef = useRef<HTMLDivElement | null>(null);

const handleClickCard = (index: number) => {
/** 첫번째 선택시 Card Pick animation을 위한 상태변경 */
if (deckState === "Spread") {
setItems((prevItems) =>
prevItems.map((_, i) => (i === index ? "Pick" : "Down"))
);
}
/** Pick된 카드 최종 선택시 타로 선택 API 호출 */
if (items[index] === "Pick") {
alert("Card Select!");
onClick();
}
};

/** Trigger observe 무한스크롤 */
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setItems((prev) => [
...prev,
...Array.from(
{ length: ITEMS_PER_LOAD },
() => "Default" as CardPickState
),
]);
}
});
},
{ root: null, threshold: 0.0 }
);

if (observerRef.current) {
observer.observe(observerRef.current);
}

return () => {
if (observerRef.current) observer.unobserve(observerRef.current);
};
}, []);

return (
<CardDeckWrapper
initial={{ opacity: 0, y: 200 }}
animate={{ opacity: 1, y: 0 }}
transition={riseUpCardDeck}
>
{items.map((_, idx) => (
<Card
key={idx}
idx={idx}
deckState={deckState}
setDeckState={setDeckState}
onClick={handleClickCard}
cardPickState={items}
/>
))}

<InfinteScrollTrigger ref={observerRef} pos={items.length} />
</CardDeckWrapper>
);
};

export default ChatCardSelect;

const InfinteScrollTrigger = styled.div<{ pos: number }>`
width: 100px;
height: 160px;
background-color: transparent;
position: absolute;
left: ${({ pos }) => pos * 50}px;
`;
const CardDeckWrapper = styled(motion.div)`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 400px;
position: relative;
overflow-x: scroll;
-ms-overflow-style: none; /* IE, Edge */
scrollbar-width: none; /* Firefox */
::-webkit-scrollbar {
display: none;
}
& > .no-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
`;
30 changes: 25 additions & 5 deletions src/chat/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { useChatMessagesContext } from "@/chat/hooks/useChatMessagesStore";
import FullscreenOverflowDivider from "@/shared/components/FullscreenOverflowDivider";
import MainContent from "@/shared/components/MainContent";
import { delay } from "@/shared/utils/delay";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useParams,
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";
import { useEffect } from "react";
import { css } from "styled-components";
import { useStickToBottom } from "use-stick-to-bottom";
Expand All @@ -24,7 +29,13 @@ export default function ChatRoom() {
const initialMessage = searchParams.get("message");
const { data } = useChatMessages(Number(chatId));
const { scrollRef, contentRef } = useStickToBottom();
const { copyServerState, state: messages, addMessage, editMessage, deleteMessage } = useChatMessagesContext();
const {
copyServerState,
state: messages,
addMessage,
editMessage,
deleteMessage,
} = useChatMessagesContext();
const {
isVisible: isTextFieldVisible,
enable: enableTextField,
Expand Down Expand Up @@ -94,7 +105,8 @@ export default function ChatRoom() {
});
}, [data]);

if (!chatId) throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");
if (!chatId)
throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");
if (!data) return null;

return (
Expand Down Expand Up @@ -130,9 +142,17 @@ export default function ChatRoom() {
>
{messages.map((message) => {
if (message.sender === "SYSTEM") {
return <ChatBubbleGroup key={message.messageId} message={message} />;
return (
<ChatBubbleGroup key={message.messageId} message={message} />
);
}
return <ChatBubble key={message.messageId} sender={message.sender} message={message.answers[0]} />;
return (
<ChatBubble
key={message.messageId}
sender={message.sender}
message={message.answers[0]}
/>
);
})}
</div>

Expand Down
1 change: 1 addition & 0 deletions src/chat/models/CardPickState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type CardPickState = "Default" | "Pick" | "Down";
1 change: 1 addition & 0 deletions src/chat/models/DeckState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type DeckState = "Stack" | "Spread";
Binary file removed src/shared/assets/images/cardBack.jpg
Binary file not shown.
Binary file added src/shared/assets/images/cardBack.webp
Binary file not shown.

0 comments on commit 60f4c0e

Please sign in to comment.