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`로 균등 배치됩니다. 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 }; +}