From c392c18fc8e8adcca24b7ac322b4759dd0014e57 Mon Sep 17 00:00:00 2001 From: chunjaemin Date: Mon, 23 Feb 2026 16:47:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[FEAT/#20]=20=EC=B1=84=ED=8C=85=20=EB=B0=94?= =?UTF-8?q?=ED=85=80=20=EC=8A=A4=EB=82=B5=EB=B0=94=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=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/app/(tabs)/index.tsx | 2 +- src/widgets/chat/bottom-snackbar/index.ts | 3 + .../chat/bottom-snackbar/model/types.ts | 31 +++++++ .../bottom-snackbar/ui/BottomSnackbar.tsx | 82 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/widgets/chat/bottom-snackbar/index.ts create mode 100644 src/widgets/chat/bottom-snackbar/model/types.ts create mode 100644 src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx 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/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..cd857d5 --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/model/types.ts @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +export type BottomSnackbarProps = { + /** 노출 여부 (외부 상태로 제어) */ + visible: boolean; + + /** 제목 */ + title: string; + + /** 부제목 */ + subtitle?: string; + + /** + * 하단 액션 영역. + * 버튼 1개/2개 등 원하는 UI를 그대로 넣습니다. + */ + actions?: ReactNode; + + /** + * 부모(채팅 입력창) 위에 붙이기 위한 bottom offset. + * - 입력창 컨테이너 높이(+gap)를 측정해 전달하면 "항상 입력창 바로 위"를 보장할 수 있습니다. + */ + bottomOffset?: number; + + /** 좌우 인셋(기본 24) */ + horizontalInset?: number; + + /** 테스트용 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..49c2c6f --- /dev/null +++ b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Animated, Easing, Text, View } from "react-native"; +import { shadows } from "@/shared/styles/shadows"; +import type { BottomSnackbarProps } from "../model/types"; + +const DEFAULT_HORIZONTAL_INSET = 24; +const DEFAULT_BOTTOM_OFFSET = 0; +const ANIMATION_DURATION_MS = 180; + +export function BottomSnackbar({ + visible, + title, + subtitle, + actions, + bottomOffset = DEFAULT_BOTTOM_OFFSET, + horizontalInset = DEFAULT_HORIZONTAL_INSET, + testID, +}: BottomSnackbarProps) { + 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: ANIMATION_DURATION_MS, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(({ finished }) => { + if (!finished) return; + if (!visible) setShouldRender(false); + }); + }, [progress, visible]); + + const animatedStyle = useMemo(() => { + const translateY = progress.interpolate({ + inputRange: [0, 1], + outputRange: [10, 0], + }); + + return { + opacity: progress, + transform: [{ translateY }], + } as const; + }, [progress]); + + if (!shouldRender) return null; + + return ( + + + {title} + {!!subtitle && ( + + {subtitle} + + )} + + {!!actions && {actions}} + + + ); +} + From 10f86f7dadb42071be3fbe5a57813d8e864e0269 Mon Sep 17 00:00:00 2001 From: chunjaemin Date: Mon, 23 Feb 2026 17:00:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[ADD/#20]=20shared/lib/hooks=EC=97=90=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20fade=20slide=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/hooks/useFadeSlideVisibility.ts | 58 +++++++++++++++++++ .../bottom-snackbar/ui/BottomSnackbar.tsx | 36 +----------- 2 files changed, 61 insertions(+), 33 deletions(-) create mode 100644 src/shared/lib/hooks/useFadeSlideVisibility.ts 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/ui/BottomSnackbar.tsx b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx index 49c2c6f..b5bf5d7 100644 --- a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx +++ b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx @@ -1,11 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Animated, Easing, Text, View } from "react-native"; +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"; const DEFAULT_HORIZONTAL_INSET = 24; const DEFAULT_BOTTOM_OFFSET = 0; -const ANIMATION_DURATION_MS = 180; export function BottomSnackbar({ visible, @@ -16,36 +15,7 @@ export function BottomSnackbar({ horizontalInset = DEFAULT_HORIZONTAL_INSET, testID, }: BottomSnackbarProps) { - 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: ANIMATION_DURATION_MS, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(({ finished }) => { - if (!finished) return; - if (!visible) setShouldRender(false); - }); - }, [progress, visible]); - - const animatedStyle = useMemo(() => { - const translateY = progress.interpolate({ - inputRange: [0, 1], - outputRange: [10, 0], - }); - - return { - opacity: progress, - transform: [{ translateY }], - } as const; - }, [progress]); + const { shouldRender, animatedStyle } = useFadeSlideVisibility(visible); if (!shouldRender) return null; From ab495cdaf464cba916da59e32fcff4b4e1049d69 Mon Sep 17 00:00:00 2001 From: chunjaemin Date: Mon, 23 Feb 2026 17:04:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[DOCS/#20]=20shouldRender=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx index b5bf5d7..b7c4f36 100644 --- a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx +++ b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx @@ -16,8 +16,8 @@ export function BottomSnackbar({ testID, }: BottomSnackbarProps) { const { shouldRender, animatedStyle } = useFadeSlideVisibility(visible); - - if (!shouldRender) return null; + + if (!shouldRender) return null; //visible false가 실행 되더라도 애니메이션은 끝까지 실행시킨 후 사라지게 함 return ( Date: Mon, 23 Feb 2026 17:14:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[REFACTOR/#20]=20absolute=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=97=90=EC=84=9C=20stack=EA=B5=AC=EC=A1=B0=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/widgets/chat/bottom-snackbar/README.md | 70 +++++++++++++++++++ .../chat/bottom-snackbar/model/types.ts | 9 --- .../bottom-snackbar/ui/BottomSnackbar.tsx | 19 +---- 3 files changed, 73 insertions(+), 25 deletions(-) create mode 100644 src/widgets/chat/bottom-snackbar/README.md 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/model/types.ts b/src/widgets/chat/bottom-snackbar/model/types.ts index cd857d5..5af46b1 100644 --- a/src/widgets/chat/bottom-snackbar/model/types.ts +++ b/src/widgets/chat/bottom-snackbar/model/types.ts @@ -16,15 +16,6 @@ export type BottomSnackbarProps = { */ actions?: ReactNode; - /** - * 부모(채팅 입력창) 위에 붙이기 위한 bottom offset. - * - 입력창 컨테이너 높이(+gap)를 측정해 전달하면 "항상 입력창 바로 위"를 보장할 수 있습니다. - */ - bottomOffset?: number; - - /** 좌우 인셋(기본 24) */ - horizontalInset?: number; - /** 테스트용 id */ testID?: string; }; diff --git a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx index b7c4f36..81baf45 100644 --- a/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx +++ b/src/widgets/chat/bottom-snackbar/ui/BottomSnackbar.tsx @@ -3,35 +3,22 @@ import { useFadeSlideVisibility } from "@/shared/lib/hooks/useFadeSlideVisibilit import { shadows } from "@/shared/styles/shadows"; import type { BottomSnackbarProps } from "../model/types"; -const DEFAULT_HORIZONTAL_INSET = 24; -const DEFAULT_BOTTOM_OFFSET = 0; - export function BottomSnackbar({ visible, title, subtitle, actions, - bottomOffset = DEFAULT_BOTTOM_OFFSET, - horizontalInset = DEFAULT_HORIZONTAL_INSET, testID, }: BottomSnackbarProps) { const { shouldRender, animatedStyle } = useFadeSlideVisibility(visible); - - if (!shouldRender) return null; //visible false가 실행 되더라도 애니메이션은 끝까지 실행시킨 후 사라지게 함 + + if (!shouldRender) return null; //visible false가 실행 되더라도 애니메이션은 끝까지 실행시킨 후 사라지게 함 return (