From 864c9b9e088db6fe2bbb655ba155b0f72652fcde Mon Sep 17 00:00:00 2001 From: chunjaemin Date: Mon, 23 Feb 2026 18:55:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT/#22]=20=EA=B3=B5=EC=9A=A9=20=ED=83=AD?= =?UTF-8?q?=EB=B0=94=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/TabBar/TabBar.tsx | 47 +++++++++++++++++++ src/shared/ui/TabBar/index.ts | 2 + src/shared/ui/TabBar/types.ts | 11 +++++ .../ui/TabBar/useTabBarUnderlineAnimation.ts | 36 ++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 src/shared/ui/TabBar/TabBar.tsx create mode 100644 src/shared/ui/TabBar/index.ts create mode 100644 src/shared/ui/TabBar/types.ts create mode 100644 src/shared/ui/TabBar/useTabBarUnderlineAnimation.ts diff --git a/src/shared/ui/TabBar/TabBar.tsx b/src/shared/ui/TabBar/TabBar.tsx new file mode 100644 index 0000000..7dbbcca --- /dev/null +++ b/src/shared/ui/TabBar/TabBar.tsx @@ -0,0 +1,47 @@ +import { Animated, Pressable, Text, View } from "react-native"; +import type { TabBarProps } from "./types"; +import { useTabBarUnderlineAnimation } from "./useTabBarUnderlineAnimation"; + +export function TabBar({ tabs, activeTab, onTabChange, testID }: TabBarProps) { + const { tabWidth, translateX, handleLayout } = useTabBarUnderlineAnimation( + tabs, + activeTab, + ); + + return ( + + + {tabs.map((tab) => { + const active = activeTab === tab.id; + return ( + onTabChange(tab.id)} + > + + {tab.label} + + + ); + })} + + + {tabWidth > 0 && ( + + )} + + + ); +} diff --git a/src/shared/ui/TabBar/index.ts b/src/shared/ui/TabBar/index.ts new file mode 100644 index 0000000..e16d99e --- /dev/null +++ b/src/shared/ui/TabBar/index.ts @@ -0,0 +1,2 @@ +export { TabBar } from "./TabBar"; +export type { TabBarProps, TabBarTab } from "./types"; diff --git a/src/shared/ui/TabBar/types.ts b/src/shared/ui/TabBar/types.ts new file mode 100644 index 0000000..28ac800 --- /dev/null +++ b/src/shared/ui/TabBar/types.ts @@ -0,0 +1,11 @@ +export type TabBarTab = { + id: string; + label: string; +}; + +export type TabBarProps = { + tabs: TabBarTab[]; + activeTab: string; + onTabChange: (id: string) => void; + testID?: string; +}; diff --git a/src/shared/ui/TabBar/useTabBarUnderlineAnimation.ts b/src/shared/ui/TabBar/useTabBarUnderlineAnimation.ts new file mode 100644 index 0000000..cec4524 --- /dev/null +++ b/src/shared/ui/TabBar/useTabBarUnderlineAnimation.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from "react"; +import { Animated, Easing, type LayoutChangeEvent } from "react-native"; +import type { TabBarTab } from "./types"; + +const ANIMATION_DURATION_MS = 200; + +export function useTabBarUnderlineAnimation(tabs: TabBarTab[], activeTab: string) { + const [containerWidth, setContainerWidth] = useState(0); + const translateX = useRef(new Animated.Value(0)).current; + const isInitialMount = useRef(true); + + const activeIndex = tabs.findIndex((tab) => tab.id === activeTab); + const tabWidth = containerWidth > 0 ? containerWidth / tabs.length : 0; + const targetTranslateX = activeIndex >= 0 ? activeIndex * tabWidth : 0; + + useEffect(() => { + if (tabWidth <= 0) return; + if (isInitialMount.current) { + isInitialMount.current = false; + translateX.setValue(targetTranslateX); + } else { + Animated.timing(translateX, { + toValue: targetTranslateX, + duration: ANIMATION_DURATION_MS, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + } + }, [translateX, targetTranslateX, tabWidth]); + + const handleLayout = (e: LayoutChangeEvent) => { + setContainerWidth(e.nativeEvent.layout.width); + }; + + return { tabWidth, translateX, handleLayout }; +} From a70c8432c32024f205636b077bfdd73fcef661b8 Mon Sep 17 00:00:00 2001 From: chunjaemin Date: Mon, 23 Feb 2026 18:57:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[DOCS/#22]=20README.md=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/TabBar/README.md | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/shared/ui/TabBar/README.md diff --git a/src/shared/ui/TabBar/README.md b/src/shared/ui/TabBar/README.md new file mode 100644 index 0000000..15c4160 --- /dev/null +++ b/src/shared/ui/TabBar/README.md @@ -0,0 +1,75 @@ +# TabBar + +stateless 탭바 컴포넌트입니다. 선택된 탭에 따라 underline이 슬라이드 애니메이션으로 이동합니다. 배경은 투명하여 부모 배경에 맞춰 사용할 수 있습니다. + +## 사용법 + +### Import + +```tsx +import { TabBar } from "@/shared/ui/TabBar"; +``` + +### 기본 사용 + +```tsx +const [activeTab, setActiveTab] = useState("inquiry"); + + +``` + +### 탭 3개 이상 + +```tsx + +``` + +### 탭에 따른 콘텐츠 전환 + +```tsx +const [activeTab, setActiveTab] = useState("inquiry"); + + + + {activeTab === "inquiry" && } + {activeTab === "history" && } + +``` + +## Props + +| Prop | 타입 | 필수 | 설명 | +|------|------|------|------| +| `tabs` | `TabBarTab[]` | ✅ | 탭 목록 (`{ id, label }`) | +| `activeTab` | `string` | ✅ | 현재 선택된 탭 id | +| `onTabChange` | `(id: string) => void` | ✅ | 탭 클릭 시 호출 | +| `testID` | `string` | | 테스트용 id | + +## 참고 + +- **stateless**: `activeTab`과 `onTabChange`로 부모가 상태를 제어합니다. +- **배경**: 투명. 부모 `View`의 `className`으로 배경색을 지정합니다. +- **underline**: 선택된 탭으로 200ms 슬라이드 애니메이션. +- **탭 개수**: 2개 이상 모두 지원. `flex-1`로 균등 배치됩니다.