diff --git a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx
deleted file mode 100644
index 01b52cf62..000000000
--- a/apps/native/src/features/student/problem/components/ProblemDrawingToolbar.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react';
-import { View } from 'react-native';
-import { Undo2, Redo2 } from 'lucide-react-native';
-
-import { PencilFilledIcon, EraserFilledIcon } from '@components/system/icons';
-import { colors } from '@theme/tokens';
-import { AnimatedPressable } from '@components/common';
-
-interface ProblemDrawingToolbarProps {
- canUndo: boolean;
- canRedo: boolean;
- onUndo: () => void;
- onRedo: () => void;
- isEraserMode: boolean;
- onPenModePress: () => void;
- onEraserModePress: () => void;
-}
-
-export const ProblemDrawingToolbar = ({
- canUndo,
- canRedo,
- onUndo,
- onRedo,
- isEraserMode,
- onPenModePress,
- onEraserModePress,
-}: ProblemDrawingToolbarProps) => {
- return (
-
- {/* Undo/Redo 그룹 */}
-
- {/* Undo 버튼 */}
-
-
-
-
- {/* Redo 버튼 */}
-
-
-
-
-
- {/* 구분선 */}
-
-
- {/* Pencil/Eraser 그룹 */}
-
- {/* Eraser 버튼 */}
-
-
-
-
- {/* Pencil 버튼 */}
-
-
-
-
-
- );
-};
diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx
new file mode 100644
index 000000000..fd890f4e9
--- /dev/null
+++ b/apps/native/src/features/student/problem/components/floating-toolbar/ProblemDrawingToolbar.tsx
@@ -0,0 +1,218 @@
+import { View } from 'react-native';
+import { GestureDetector } from 'react-native-gesture-handler';
+import Animated from 'react-native-reanimated';
+import { Undo2, Redo2 } from 'lucide-react-native';
+
+import { AnimatedPressable } from '@components/common';
+import { EraserFilledIcon, PencilFilledIcon } from '@components/system/icons';
+import { colors } from '@theme/tokens';
+
+import {
+ BUTTON_RADIUS,
+ BUTTON_SIZE,
+ COLLAPSED_RADIUS,
+ COLLAPSED_W,
+ type Corner,
+ DIVIDER_WIDTH,
+ EXPANDED_RADIUS,
+ GAP,
+ ICON_SIZE,
+ PADDING,
+ SHADOW,
+ TOOLBAR_H,
+ ToolbarButton,
+ ToolbarDivider,
+} from './shared';
+import { useFloatingToolbarSnap } from './useFloatingToolbarSnap';
+
+const EXPANDED_W =
+ PADDING +
+ BUTTON_SIZE +
+ GAP +
+ BUTTON_SIZE +
+ GAP +
+ DIVIDER_WIDTH +
+ GAP +
+ BUTTON_SIZE +
+ GAP +
+ BUTTON_SIZE +
+ PADDING;
+
+interface ProblemDrawingToolbarProps {
+ canUndo: boolean;
+ canRedo: boolean;
+ onUndo: () => void;
+ onRedo: () => void;
+ isEraserMode: boolean;
+ onPenModePress: () => void;
+ onEraserModePress: () => void;
+ collapsed: boolean;
+ onCollapsedChange: (collapsed: boolean) => void;
+ containerWidth: number;
+ containerHeight: number;
+ initialCorner?: Corner;
+}
+
+export const ProblemDrawingToolbar = ({
+ canUndo,
+ canRedo,
+ onUndo,
+ onRedo,
+ isEraserMode,
+ onPenModePress,
+ onEraserModePress,
+ collapsed,
+ onCollapsedChange,
+ containerWidth,
+ containerHeight,
+ initialCorner = 'bottom-left',
+}: ProblemDrawingToolbarProps) => {
+ const toolbarWidth = collapsed ? COLLAPSED_W : EXPANDED_W;
+ const { composedGesture, animatedStyle, ready } = useFloatingToolbarSnap({
+ containerWidth,
+ containerHeight,
+ toolbarWidth,
+ initialCorner,
+ });
+
+ return (
+
+
+ {collapsed ? (
+ onCollapsedChange(false)} />
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const ExpandedToolbar = ({
+ canUndo,
+ canRedo,
+ onUndo,
+ onRedo,
+ isEraserMode,
+ onPenModePress,
+ onEraserModePress,
+}: Pick<
+ ProblemDrawingToolbarProps,
+ | 'canUndo'
+ | 'canRedo'
+ | 'onUndo'
+ | 'onRedo'
+ | 'isEraserMode'
+ | 'onPenModePress'
+ | 'onEraserModePress'
+>) => (
+
+
+ }
+ isActive={canUndo}
+ />
+
+ }
+ isActive={canRedo}
+ />
+
+
+ }
+ isActive={!isEraserMode}
+ />
+
+ }
+ isActive={isEraserMode}
+ />
+
+);
+
+const CollapsedToolbar = ({
+ isEraserMode,
+ onPress,
+}: {
+ isEraserMode: boolean;
+ onPress: () => void;
+}) => (
+
+
+ {isEraserMode ? (
+
+ ) : (
+
+ )}
+
+
+);
diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/index.ts b/apps/native/src/features/student/problem/components/floating-toolbar/index.ts
new file mode 100644
index 000000000..fc4e8576b
--- /dev/null
+++ b/apps/native/src/features/student/problem/components/floating-toolbar/index.ts
@@ -0,0 +1 @@
+export { ProblemDrawingToolbar } from './ProblemDrawingToolbar';
diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx b/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx
new file mode 100644
index 000000000..aee68124a
--- /dev/null
+++ b/apps/native/src/features/student/problem/components/floating-toolbar/shared.tsx
@@ -0,0 +1,107 @@
+import { View } from 'react-native';
+
+import { AnimatedPressable } from '@components/common';
+import { colors } from '@theme/tokens';
+
+export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+export const BUTTON_SIZE = 42;
+export const BUTTON_RADIUS = 12;
+export const ICON_SIZE = 20;
+export const PADDING = 10;
+export const GAP = 10;
+export const DIVIDER_WIDTH = 1;
+export const SCREEN_MARGIN = 24;
+export const EXPANDED_RADIUS = 24;
+export const COLLAPSED_RADIUS = 20;
+export const COLOR_BTN_SIZE = 28;
+export const COLOR_CIRCLE_SIZE = 24;
+export const COLOR_GRID_GAP = 4;
+export const COLOR_GRID_W = COLOR_BTN_SIZE * 2 + COLOR_GRID_GAP;
+export const TOOLBAR_H = PADDING + BUTTON_SIZE + PADDING;
+export const COLLAPSED_W = TOOLBAR_H;
+export const SPRING = { damping: 20, stiffness: 220, mass: 0.8 };
+export const LONG_PRESS_MS = 1000;
+
+export const SHADOW = {
+ shadowColor: '#1E1E21',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4.5,
+ elevation: 3,
+} as const;
+
+export const cornerXY = (corner: Corner, width: number, cw: number, ch: number) => {
+ const x = corner.endsWith('left') ? SCREEN_MARGIN : cw - width - SCREEN_MARGIN;
+ const y = corner.startsWith('top') ? SCREEN_MARGIN : ch - TOOLBAR_H - SCREEN_MARGIN;
+ return { x, y };
+};
+
+interface ToolbarButtonProps {
+ onPress: () => void;
+ disabled?: boolean;
+ isActive?: boolean;
+ activeBg: string;
+ icon: React.ReactNode;
+}
+
+export const ToolbarButton = ({
+ onPress,
+ disabled,
+ isActive,
+ activeBg,
+ icon,
+}: ToolbarButtonProps) => (
+
+ {icon}
+
+);
+
+export const ToolbarDivider = () => (
+
+);
+
+interface ColorSwatchProps {
+ color: string;
+ selected: boolean;
+ onPress: () => void;
+}
+
+export const ColorSwatch = ({ color, selected, onPress }: ColorSwatchProps) => (
+
+
+
+);
diff --git a/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts b/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts
new file mode 100644
index 000000000..406cbdc75
--- /dev/null
+++ b/apps/native/src/features/student/problem/components/floating-toolbar/useFloatingToolbarSnap.ts
@@ -0,0 +1,99 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Gesture } from 'react-native-gesture-handler';
+import { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
+
+import { type Corner, LONG_PRESS_MS, SCREEN_MARGIN, SPRING, TOOLBAR_H, cornerXY } from './shared';
+
+interface UseFloatingToolbarSnapOptions {
+ containerWidth: number;
+ containerHeight: number;
+ toolbarWidth: number;
+ initialCorner?: Corner;
+}
+
+export function useFloatingToolbarSnap({
+ containerWidth,
+ containerHeight,
+ toolbarWidth,
+ initialCorner = 'bottom-left',
+}: UseFloatingToolbarSnapOptions) {
+ const [corner, setCorner] = useState(initialCorner);
+ const isFirstMount = useRef(true);
+
+ const translateX = useSharedValue(SCREEN_MARGIN);
+ const translateY = useSharedValue(SCREEN_MARGIN);
+ const dragStartX = useSharedValue(0);
+ const dragStartY = useSharedValue(0);
+ const dragged = useSharedValue(false);
+
+ // 첫 measure 시 즉시 위치 (좌상단 → corner spring 비행 방지). 이후 corner / size 변경 시에만 spring.
+ useEffect(() => {
+ if (containerWidth <= 0 || containerHeight <= 0) return;
+ const { x, y } = cornerXY(corner, toolbarWidth, containerWidth, containerHeight);
+ if (isFirstMount.current) {
+ translateX.value = x;
+ translateY.value = y;
+ isFirstMount.current = false;
+ } else {
+ translateX.value = withSpring(x, SPRING);
+ translateY.value = withSpring(y, SPRING);
+ }
+ }, [corner, toolbarWidth, containerWidth, containerHeight, translateX, translateY]);
+
+ const snapToCorner = useCallback(
+ (x: number, y: number) => {
+ if (containerWidth <= 0 || containerHeight <= 0) return;
+ const cx = x + toolbarWidth / 2;
+ const cy = y + TOOLBAR_H / 2;
+
+ const inLeft = cx < containerWidth / 3;
+ const inRight = cx > (containerWidth * 2) / 3;
+ const inTop = cy < containerHeight / 3;
+ const inBottom = cy > (containerHeight * 2) / 3;
+
+ let next: Corner = corner;
+ if ((inLeft || inRight) && (inTop || inBottom)) {
+ const vertical = inTop ? 'top' : 'bottom';
+ const horizontal = inLeft ? 'left' : 'right';
+ next = `${vertical}-${horizontal}` as Corner;
+ }
+
+ if (next === corner) {
+ // 같은 corner zone 으로 release: setCorner 가 effect 트리거 못 하므로 직접 spring 으로 복귀.
+ const { x: tx, y: ty } = cornerXY(next, toolbarWidth, containerWidth, containerHeight);
+ translateX.value = withSpring(tx, SPRING);
+ translateY.value = withSpring(ty, SPRING);
+ } else {
+ // 다른 corner: setCorner 만 호출 → effect 가 spring 담당 (중복 spring 방지).
+ setCorner(next);
+ }
+ },
+ [containerWidth, containerHeight, toolbarWidth, corner, translateX, translateY]
+ );
+
+ const panGesture = Gesture.Pan()
+ .activateAfterLongPress(LONG_PRESS_MS)
+ .onBegin(() => {
+ dragStartX.value = translateX.value;
+ dragStartY.value = translateY.value;
+ dragged.value = false;
+ })
+ .onUpdate((e) => {
+ dragged.value = true;
+ translateX.value = dragStartX.value + e.translationX;
+ translateY.value = dragStartY.value + e.translationY;
+ })
+ .onEnd(() => {
+ if (!dragged.value) return;
+ runOnJS(snapToCorner)(translateX.value, translateY.value);
+ });
+
+ const composedGesture = Gesture.Simultaneous(panGesture, Gesture.Native());
+
+ const ready = containerWidth > 0 && containerHeight > 0;
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
+ }));
+
+ return { composedGesture, animatedStyle, ready };
+}
diff --git a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx
index ea3ff47ca..863bdab83 100644
--- a/apps/native/src/features/student/problem/screens/ProblemScreen.tsx
+++ b/apps/native/src/features/student/problem/screens/ProblemScreen.tsx
@@ -40,7 +40,7 @@ import BottomActionBar from '../components/BottomActionBar';
import { buildDocumentInit } from '../transforms/contentRendererTransforms';
import { DrawingCanvas, type DrawingCanvasRef } from '../../scrap/utils/skia';
import { useDrawingState } from '../../scrap/hooks/useDrawingState';
-import { ProblemDrawingToolbar } from '../components/ProblemDrawingToolbar';
+import { ProblemDrawingToolbar } from '../components/floating-toolbar';
import { ConfirmationModal } from '../../scrap/components/Dialog';
type ProblemScreenProps = Partial>;
@@ -473,6 +473,27 @@ const ProblemScreen = ({ navigation }: ProblemScreenProps) => {
const canvasRef = useRef(null);
const drawingState = useDrawingState();
+ const [toolbarCollapsed, setToolbarCollapsed] = useState(false);
+ const [toolbarArea, setToolbarArea] = useState({ width: 0, height: 0 });
+
+ const handleToolbarAreaLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
+ const { width, height } = nativeEvent.layout;
+ setToolbarArea((prev) =>
+ prev.width === width && prev.height === height ? prev : { width, height }
+ );
+ }, []);
+
+ const handlePenModePress = useCallback(() => {
+ drawingState.setPenMode();
+ }, [drawingState]);
+
+ const handleEraserModePress = useCallback(() => {
+ if (drawingState.isEraserMode) {
+ drawingState.setPenMode();
+ } else {
+ drawingState.setEraserMode();
+ }
+ }, [drawingState]);
const screenHeight = Dimensions.get('window').height;
@@ -486,65 +507,56 @@ const ProblemScreen = ({ navigation }: ProblemScreenProps) => {
right={ setIsCloseVisible(true)} />}
/>
-
-
+
+
+
+ {/* Problem */}
+
+
+
+ {/* 위층: DrawingCanvas - ProblemViewer 위에 겹쳐짐 */}
+
+
+ setToolbarCollapsed(true)}
+ />
+
+
+
+
+
+
+
canvasRef.current?.undo()}
onRedo={() => canvasRef.current?.redo()}
isEraserMode={drawingState.isEraserMode}
- onPenModePress={drawingState.setPenMode}
- onEraserModePress={() => {
- if (drawingState.isEraserMode) {
- drawingState.setPenMode();
- } else {
- drawingState.setEraserMode();
- }
- }}
+ onPenModePress={handlePenModePress}
+ onEraserModePress={handleEraserModePress}
+ collapsed={toolbarCollapsed}
+ onCollapsedChange={setToolbarCollapsed}
+ containerWidth={toolbarArea.width}
+ containerHeight={toolbarArea.height}
/>
-
-
- {/* Problem */}
-
-
-
- {/* 위층: DrawingCanvas - ProblemViewer 위에 겹쳐짐 */}
-
-
-
-
-
-
-
-
void;
onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void;
+ onStrokeStart?: () => void;
eraserMode?: boolean;
eraserSize?: number;
textMode?: boolean;
@@ -85,6 +86,7 @@ const DrawingCanvas = forwardRef(
strokeWidth = 3,
onChange,
onHistoryChange,
+ onStrokeStart,
eraserMode = false,
eraserSize = 20,
textMode = false,
@@ -966,6 +968,9 @@ const DrawingCanvas = forwardRef(
isActiveGesture.value = true;
showHover.value = false; // 그리기 시작 시 호버 숨김
if (textMode) return; // 텍스트 모드에서는 그리기 비활성화
+ if (onStrokeStart) {
+ runOnJS(onStrokeStart)();
+ }
if (eraserMode) {
runOnJS(startEraser)(e.x, e.y);
} else {
@@ -1001,6 +1006,7 @@ const DrawingCanvas = forwardRef(
[
textMode,
eraserMode,
+ onStrokeStart,
startStroke,
addPoint,
finalizeStroke,