Skip to content

Commit 60f4c0e

Browse files
authored
Merge pull request #34 from Nexters/feat/#33
[ Feat/#33 ] Chat page 카드 애니메이션 구현
2 parents 90f7d8e + 2735eb4 commit 60f4c0e

File tree

8 files changed

+276
-6
lines changed

8 files changed

+276
-6
lines changed

src/app/chats/[chatId]/tarot-reading/[resultId]/components/TarotInteraction.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as motion from "motion/react-client";
44
import Image from "next/image";
55
import catHand from "@/shared/assets/images/catHand.png";
6-
import cardBack from "@/shared/assets/images/cardBack.jpg";
6+
import cardBack from "@/shared/assets/images/cardBack.webp";
77
import cardFront from "@/shared/assets/images/Card1.jpg";
88
import styled from "styled-components";
99
import { easeInOut } from "motion";

src/chat/components/Card.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import Image from "next/image";
4+
import styled from "styled-components";
5+
import CardBack from "@/shared/assets/images/cardBack.webp";
6+
import { cubicBezier, easeOut } from "motion";
7+
import { div } from "motion/react-client";
8+
import { useState } from "react";
9+
import { Dispatch, SetStateAction, useRef, useEffect } from "react";
10+
import { CardPickState } from "../models/CardPickState";
11+
import { DeckState } from "../models/DeckState";
12+
interface PropTypes {
13+
idx: number;
14+
deckState: DeckState;
15+
setDeckState: Dispatch<SetStateAction<DeckState>>;
16+
onClick: (index: number) => void;
17+
cardPickState: CardPickState[];
18+
}
19+
20+
const Card = ({
21+
idx,
22+
deckState,
23+
setDeckState,
24+
onClick,
25+
cardPickState,
26+
}: PropTypes) => {
27+
const [isCardShadow, setIsCardShadow] = useState(false);
28+
const [moveDistance, setMoveDistance] = useState(0);
29+
const cardRef = useRef<HTMLDivElement>(null);
30+
31+
const cardVariants = {
32+
initial: { x: 0, y: 0, rotate: 0 },
33+
spread: {
34+
x: [0, idx * 50, idx * 50 + moveDistance],
35+
transition: {
36+
duration: 1.2,
37+
delay: 0.7,
38+
time: [0, 0.7, 1.2],
39+
ease: [cubicBezier(0.44, 0, 0.56, 1), "easeInOut"],
40+
},
41+
},
42+
43+
infiniteScroll: {
44+
x: [0, idx * 50, idx * 50 + moveDistance],
45+
transition: {
46+
duration: 0.01,
47+
ease: easeOut,
48+
},
49+
},
50+
51+
clickAnimation: {
52+
y: -50,
53+
rotate: 16,
54+
transition: {
55+
duration: 0.3,
56+
},
57+
},
58+
59+
cardDownAnimation: {
60+
y: 0,
61+
rotate: 0,
62+
transition: {
63+
duration: 0.3,
64+
},
65+
},
66+
};
67+
68+
const onAnimationEnd = () => {
69+
setIsCardShadow(true);
70+
setDeckState("Spread");
71+
};
72+
73+
const handleClickCard = () => {
74+
onClick(idx);
75+
};
76+
77+
const getCardAnimation = () => {
78+
if (deckState === "Stack") return "spread";
79+
if (deckState === "Spread") {
80+
if (cardPickState[idx] === "Pick") return "clickAnimation";
81+
if (cardPickState[idx] === "Down") return "cardDownAnimation";
82+
}
83+
return "infiniteScroll";
84+
};
85+
86+
useEffect(() => {
87+
if (cardRef.current) {
88+
const cardPos = cardRef.current.offsetLeft;
89+
setMoveDistance(-cardPos); // 부모 기준 왼쪽으로 붙이기
90+
}
91+
}, []);
92+
93+
return (
94+
<CardAnimationWrapper
95+
ref={cardRef}
96+
variants={cardVariants}
97+
animate={getCardAnimation()}
98+
onClick={handleClickCard}
99+
onAnimationComplete={onAnimationEnd}
100+
>
101+
<CardWrapper
102+
src={CardBack}
103+
alt="카드 뒷면 이미지"
104+
isCardShadow={isCardShadow}
105+
/>
106+
</CardAnimationWrapper>
107+
);
108+
};
109+
110+
export default Card;
111+
112+
const CardAnimationWrapper = styled(div)`
113+
width: 100px;
114+
height: 160px;
115+
position: absolute;
116+
117+
cursor: pointer;
118+
`;
119+
120+
const CardWrapper = styled(Image)<{ isCardShadow: boolean }>`
121+
border-radius: 8px;
122+
123+
box-shadow: ${({ isCardShadow }) =>
124+
isCardShadow ? "-8px 0px 12px 0px rgba(0, 0, 0, 0.15)" : ""};
125+
126+
width: 100px;
127+
height: 160px;
128+
`;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import styled from "styled-components";
2+
import * as motion from "motion/react-client";
3+
import { easeInOut } from "motion";
4+
import Card from "./Card";
5+
import { useState, useEffect, useRef } from "react";
6+
import { DeckState } from "../models/DeckState";
7+
import { CardPickState } from "../models/CardPickState";
8+
interface PropTypes {
9+
/** 타입 변경 필요 */
10+
onClick: () => void;
11+
}
12+
13+
const riseUpCardDeck = {
14+
duration: 0.6,
15+
ease: easeInOut,
16+
};
17+
18+
const ChatCardSelect = ({ onClick }: PropTypes) => {
19+
const [deckState, setDeckState] = useState<DeckState>("Stack");
20+
const ITEMS_PER_LOAD = 15;
21+
const [items, setItems] = useState<CardPickState[]>(
22+
Array.from({ length: ITEMS_PER_LOAD }, () => "Default")
23+
);
24+
25+
const observerRef = useRef<HTMLDivElement | null>(null);
26+
27+
const handleClickCard = (index: number) => {
28+
/** 첫번째 선택시 Card Pick animation을 위한 상태변경 */
29+
if (deckState === "Spread") {
30+
setItems((prevItems) =>
31+
prevItems.map((_, i) => (i === index ? "Pick" : "Down"))
32+
);
33+
}
34+
/** Pick된 카드 최종 선택시 타로 선택 API 호출 */
35+
if (items[index] === "Pick") {
36+
alert("Card Select!");
37+
onClick();
38+
}
39+
};
40+
41+
/** Trigger observe 무한스크롤 */
42+
useEffect(() => {
43+
const observer = new IntersectionObserver(
44+
(entries) => {
45+
entries.forEach((entry) => {
46+
if (entry.isIntersecting) {
47+
setItems((prev) => [
48+
...prev,
49+
...Array.from(
50+
{ length: ITEMS_PER_LOAD },
51+
() => "Default" as CardPickState
52+
),
53+
]);
54+
}
55+
});
56+
},
57+
{ root: null, threshold: 0.0 }
58+
);
59+
60+
if (observerRef.current) {
61+
observer.observe(observerRef.current);
62+
}
63+
64+
return () => {
65+
if (observerRef.current) observer.unobserve(observerRef.current);
66+
};
67+
}, []);
68+
69+
return (
70+
<CardDeckWrapper
71+
initial={{ opacity: 0, y: 200 }}
72+
animate={{ opacity: 1, y: 0 }}
73+
transition={riseUpCardDeck}
74+
>
75+
{items.map((_, idx) => (
76+
<Card
77+
key={idx}
78+
idx={idx}
79+
deckState={deckState}
80+
setDeckState={setDeckState}
81+
onClick={handleClickCard}
82+
cardPickState={items}
83+
/>
84+
))}
85+
86+
<InfinteScrollTrigger ref={observerRef} pos={items.length} />
87+
</CardDeckWrapper>
88+
);
89+
};
90+
91+
export default ChatCardSelect;
92+
93+
const InfinteScrollTrigger = styled.div<{ pos: number }>`
94+
width: 100px;
95+
height: 160px;
96+
97+
background-color: transparent;
98+
99+
position: absolute;
100+
left: ${({ pos }) => pos * 50}px;
101+
`;
102+
const CardDeckWrapper = styled(motion.div)`
103+
display: flex;
104+
justify-content: center;
105+
align-items: center;
106+
107+
width: 100%;
108+
height: 400px;
109+
position: relative;
110+
overflow-x: scroll;
111+
112+
-ms-overflow-style: none; /* IE, Edge */
113+
scrollbar-width: none; /* Firefox */
114+
::-webkit-scrollbar {
115+
display: none;
116+
}
117+
& > .no-scroll::-webkit-scrollbar {
118+
display: none; /* Chrome, Safari, Opera */
119+
}
120+
`;

src/chat/components/ChatRoom.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { useChatMessagesContext } from "@/chat/hooks/useChatMessagesStore";
99
import FullscreenOverflowDivider from "@/shared/components/FullscreenOverflowDivider";
1010
import MainContent from "@/shared/components/MainContent";
1111
import { delay } from "@/shared/utils/delay";
12-
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
12+
import {
13+
useParams,
14+
usePathname,
15+
useRouter,
16+
useSearchParams,
17+
} from "next/navigation";
1318
import { useEffect } from "react";
1419
import { css } from "styled-components";
1520
import { useStickToBottom } from "use-stick-to-bottom";
@@ -24,7 +29,13 @@ export default function ChatRoom() {
2429
const initialMessage = searchParams.get("message");
2530
const { data } = useChatMessages(Number(chatId));
2631
const { scrollRef, contentRef } = useStickToBottom();
27-
const { copyServerState, state: messages, addMessage, editMessage, deleteMessage } = useChatMessagesContext();
32+
const {
33+
copyServerState,
34+
state: messages,
35+
addMessage,
36+
editMessage,
37+
deleteMessage,
38+
} = useChatMessagesContext();
2839
const {
2940
isVisible: isTextFieldVisible,
3041
enable: enableTextField,
@@ -94,7 +105,8 @@ export default function ChatRoom() {
94105
});
95106
}, [data]);
96107

97-
if (!chatId) throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");
108+
if (!chatId)
109+
throw new Error("chatId가 Dynamic Route에서 전달 되어야 합니다.");
98110
if (!data) return null;
99111

100112
return (
@@ -130,9 +142,17 @@ export default function ChatRoom() {
130142
>
131143
{messages.map((message) => {
132144
if (message.sender === "SYSTEM") {
133-
return <ChatBubbleGroup key={message.messageId} message={message} />;
145+
return (
146+
<ChatBubbleGroup key={message.messageId} message={message} />
147+
);
134148
}
135-
return <ChatBubble key={message.messageId} sender={message.sender} message={message.answers[0]} />;
149+
return (
150+
<ChatBubble
151+
key={message.messageId}
152+
sender={message.sender}
153+
message={message.answers[0]}
154+
/>
155+
);
136156
})}
137157
</div>
138158

src/chat/models/CardPickState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type CardPickState = "Default" | "Pick" | "Down";

src/chat/models/DeckState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type DeckState = "Stack" | "Spread";

src/shared/assets/images/cardBack.jpg

-25.7 KB
Binary file not shown.
47.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)