Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/shared/ui/TabBar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# TabBar

stateless 탭바 컴포넌트입니다. 선택된 탭에 따라 underline이 슬라이드 애니메이션으로 이동합니다. 배경은 투명하여 부모 배경에 맞춰 사용할 수 있습니다.

## 사용법

### Import

```tsx
import { TabBar } from "@/shared/ui/TabBar";
```

### 기본 사용

```tsx
const [activeTab, setActiveTab] = useState("inquiry");

<TabBar
tabs={[
{ id: "inquiry", label: "문의하기" },
{ id: "history", label: "문의내역확인" },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
```

### 탭 3개 이상

```tsx
<TabBar
tabs={[
{ id: "all", label: "전체" },
{ id: "pending", label: "대기중" },
{ id: "done", label: "완료" },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
```

### 탭에 따른 콘텐츠 전환

```tsx
const [activeTab, setActiveTab] = useState("inquiry");

<View>
<TabBar
tabs={[
{ id: "inquiry", label: "문의하기" },
{ id: "history", label: "문의내역확인" },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{activeTab === "inquiry" && <InquiryForm />}
{activeTab === "history" && <InquiryHistory />}
</View>
```

## 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`로 균등 배치됩니다.
47 changes: 47 additions & 0 deletions src/shared/ui/TabBar/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View testID={testID} onLayout={handleLayout}>
<View className="flex-row">
{tabs.map((tab) => {
const active = activeTab === tab.id;
return (
<Pressable
key={tab.id}
className="flex-1 items-center py-3"
onPress={() => onTabChange(tab.id)}
>
<Text
className={`font-regular ${
active ? "text-content-primary" : "text-content-secondary"
}`}
numberOfLines={1}
>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
<View className="relative h-[2px] w-full">
{tabWidth > 0 && (
<Animated.View
className="absolute left-0 top-0 h-[2px] bg-content-primary"
style={{
width: tabWidth,
transform: [{ translateX }],
}}
/>
)}
</View>
</View>
);
}
2 changes: 2 additions & 0 deletions src/shared/ui/TabBar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { TabBar } from "./TabBar";
export type { TabBarProps, TabBarTab } from "./types";
11 changes: 11 additions & 0 deletions src/shared/ui/TabBar/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type TabBarTab = {
id: string;
label: string;
};

export type TabBarProps = {
tabs: TabBarTab[];
activeTab: string;
onTabChange: (id: string) => void;
testID?: string;
};
36 changes: 36 additions & 0 deletions src/shared/ui/TabBar/useTabBarUnderlineAnimation.ts
Original file line number Diff line number Diff line change
@@ -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 };
}