diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index f1fbb3b..d7a6562 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,7 +1,7 @@ import { shadows } from "@/shared/styles/shadows"; +import { SmallButton } from "@/shared/ui/buttons/ActionButton"; import { BenefitSelectionGroup } from "@/shared/ui/buttons/BenefitSelectionGroup"; import { MediumButton } from "@/shared/ui/buttons/SubmitButton"; -import { SmallButton } from "@/shared/ui/buttons/ActionButton"; import { ScrollView, Text, View } from "react-native"; export default function HomeScreen() { diff --git a/src/shared/lib/hooks/useFadeSlideVisibility.ts b/src/shared/lib/hooks/useFadeSlideVisibility.ts new file mode 100644 index 0000000..553f62c --- /dev/null +++ b/src/shared/lib/hooks/useFadeSlideVisibility.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Animated, Easing } from "react-native"; + +export type UseFadeSlideVisibilityOptions = { + /** 애니메이션 지속 시간 (ms) */ + duration?: number; + /** 숨김 시 translateY (0 = 보임, 이 값 = 숨김) */ + translateYFrom?: number; +}; + +const DEFAULT_DURATION_MS = 180; +const DEFAULT_TRANSLATE_Y_FROM = 10; + +/** + * fade + slide up visibility 애니메이션 훅. + * visible이 true면 opacity 0→1, translateY from→0으로 나타나고, + * false면 반대로 사라진 뒤 shouldRender가 false가 되어 언마운트됩니다. + */ +export function useFadeSlideVisibility( + visible: boolean, + options?: UseFadeSlideVisibilityOptions, +) { + const duration = options?.duration ?? DEFAULT_DURATION_MS; + const translateYFrom = options?.translateYFrom ?? DEFAULT_TRANSLATE_Y_FROM; + + const [shouldRender, setShouldRender] = useState(visible); + const progress = useRef(new Animated.Value(visible ? 1 : 0)).current; + + useEffect(() => { + if (visible) { + setShouldRender(true); + } + + Animated.timing(progress, { + toValue: visible ? 1 : 0, + duration, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(({ finished }) => { + if (!finished) return; + if (!visible) setShouldRender(false); + }); + }, [progress, visible, duration]); + + const animatedStyle = useMemo(() => { + const translateY = progress.interpolate({ + inputRange: [0, 1], + outputRange: [translateYFrom, 0], + }); + + return { + opacity: progress, + transform: [{ translateY }], + } as const; + }, [progress, translateYFrom]); + + return { shouldRender, animatedStyle }; +} diff --git a/src/widgets/chat/bottom-snackbar/README.md b/src/widgets/chat/bottom-snackbar/README.md new file mode 100644 index 0000000..559d63c --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/README.md @@ -0,0 +1,70 @@ +# BottomSnackbar + +스택(flex) 레이아웃으로 쌓이는 스낵바 위젯입니다. fade + slide 애니메이션으로 나타나고 사라집니다. + +**위치는 absolute가 아닌 flex 스택으로, 부모에서 자식 순서와 `className`(padding, gap 등)으로 제어합니다.** + +## 사용법 + +### Import + +```tsx +import { BottomSnackbar } from "@/widgets/chat/bottom-snackbar"; +``` + +### 기본 구조 (채팅 입력창 위에 배치) + +부모 `View`에서 flex 순서로 위치를 제어합니다. 스낵바를 입력창 **위**에 두려면 DOM 순서상 입력창 **앞**에 배치합니다. + +```tsx +const [visible, setVisible] = useState(false); + + + ... + + {/* 하단 영역: 스낵바 → 입력창 순으로 쌓임 */} + + setVisible(false)}>확인 + } + /> + + + +``` + +### 버튼 2개 + +```tsx + + setVisible(false)}>취소 + 저장 + + } +/> +``` + +## Props + +| Prop | 타입 | 필수 | 설명 | +|------|------|------|------| +| `visible` | `boolean` | ✅ | 노출 여부 (외부 상태로 제어) | +| `title` | `string` | ✅ | 제목 | +| `subtitle` | `string` | | 부제목 | +| `actions` | `ReactNode` | | 하단 액션 영역 (버튼 1개/2개 등) | +| `testID` | `string` | | 테스트용 id | + +## 참고 + +- **위치 제어**: absolute 사용 안 함. 부모 `View`의 자식 순서와 `className`(padding, gap 등)으로 배치합니다. +- **애니메이션**: `visible`이 `false`가 되어도 사라지는 애니메이션이 끝난 뒤에만 언마운트됩니다. +- **actions**: `SmallButton`, `MediumButton` 등 공통 버튼 컴포넌트를 사용할 수 있습니다. diff --git a/src/widgets/chat/bottom-snackbar/index.ts b/src/widgets/chat/bottom-snackbar/index.ts new file mode 100644 index 0000000..a197188 --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/index.ts @@ -0,0 +1,3 @@ +export { BottomSnackbar } from "./ui/BottomSnackbar"; +export type { BottomSnackbarProps } from "./model/types"; + diff --git a/src/widgets/chat/bottom-snackbar/model/types.ts b/src/widgets/chat/bottom-snackbar/model/types.ts new file mode 100644 index 0000000..5af46b1 --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/model/types.ts @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +export type BottomSnackbarProps = { + /** 노출 여부 (외부 상태로 제어) */ + visible: boolean; + + /** 제목 */ + title: string; + + /** 부제목 */ + subtitle?: string; + + /** + * 하단 액션 영역. + * 버튼 1개/2개 등 원하는 UI를 그대로 넣습니다. + */ + actions?: ReactNode; + + /** 테스트용 id */ + testID?: string; +}; + diff --git a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx new file mode 100644 index 0000000..81baf45 --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx @@ -0,0 +1,39 @@ +import { Animated, Text, View } from "react-native"; +import { useFadeSlideVisibility } from "@/shared/lib/hooks/useFadeSlideVisibility"; +import { shadows } from "@/shared/styles/shadows"; +import type { BottomSnackbarProps } from "../model/types"; + +export function BottomSnackbar({ + visible, + title, + subtitle, + actions, + testID, +}: BottomSnackbarProps) { + const { shouldRender, animatedStyle } = useFadeSlideVisibility(visible); + + if (!shouldRender) return null; //visible false가 실행 되더라도 애니메이션은 끝까지 실행시킨 후 사라지게 함 + + return ( + + + {title} + {!!subtitle && ( + + {subtitle} + + )} + + {!!actions && {actions}} + + + ); +} +