diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index c2093bc..ede91f0 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, - KeyboardAvoidingView, + Keyboard, + LayoutAnimation, Modal, Platform, Pressable, @@ -12,8 +13,9 @@ import { TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; import { FolderCard } from '@/components/ui/folder-card'; @@ -31,7 +33,30 @@ type MenuState = { folderId?: number; }; +const RENAME_MODAL_BOTTOM_GAP = 16; +const RENAME_KEYBOARD_TOP_GAP = 8; + +const showKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; +const hideKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + +const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { + if (Platform.OS !== 'ios') { + return; + } + + const duration = event.duration > 10 ? event.duration : 10; + + LayoutAnimation.configureNext({ + duration, + update: { + duration, + type: LayoutAnimation.Types[event.easing] || LayoutAnimation.Types.keyboard, + }, + }); +}; + export default function FolderScreen() { + const insets = useSafeAreaInsets(); const router = useRouter(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; @@ -56,11 +81,13 @@ export default function FolderScreen() { currentName: '', }); const [isMutating, setIsMutating] = useState(false); + const [renameKeyboardInset, setRenameKeyboardInset] = useState(0); const [createToastVisible, setCreateToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [renameToastVisible, setRenameToastVisible] = useState(false); const lastCreatedToastRef = useRef(undefined); const renameInputRef = useRef(null); + const renameFocusTimerRef = useRef | null>(null); const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; const folders = useMemo( @@ -81,11 +108,39 @@ export default function FolderScreen() { setCreateToastVisible(true); }, [folderCreated]); + useEffect(() => { + return () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!renameState.visible) { + setRenameKeyboardInset(0); + return; + } + + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setRenameKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setRenameKeyboardInset(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [renameState.visible]); + const handleEditName = () => { if (menuState.folderId == null) return; const current = folders.find((f) => f.id === menuState.folderId)?.name ?? ''; setRenameState({ visible: true, folderId: menuState.folderId, value: current, currentName: current }); - setTimeout(() => renameInputRef.current?.focus(), 100); }; const handleRenameConfirm = async () => { @@ -95,6 +150,10 @@ export default function FolderScreen() { setIsMutating(true); try { await renameFolder(renameState.folderId, trimmed); + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + renameFocusTimerRef.current = null; + } setRenameState({ visible: false, value: '', currentName: '' }); setRenameToastVisible(true); } catch (error) { @@ -108,9 +167,24 @@ export default function FolderScreen() { }; const handleRenameCancel = () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + renameFocusTimerRef.current = null; + } setRenameState({ visible: false, value: '', currentName: '' }); }; + const handleRenameModalShow = () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + } + + renameFocusTimerRef.current = setTimeout(() => { + renameInputRef.current?.focus(); + renameFocusTimerRef.current = null; + }, 100); + }; + const handleDelete = () => { if (menuState.folderId == null) return; const folderId = menuState.folderId; @@ -149,6 +223,10 @@ export default function FolderScreen() { trimmedRenameValue.length === 0 || trimmedRenameValue === renameState.currentName.trim() || isMutating; + const renameRestingBottomInset = Math.max(insets.bottom, RENAME_MODAL_BOTTOM_GAP); + const renameModalBottomInset = renameKeyboardInset > 0 + ? renameKeyboardInset + RENAME_KEYBOARD_TOP_GAP + : renameRestingBottomInset; return ( @@ -239,53 +317,64 @@ export default function FolderScreen() { transparent animationType="fade" onRequestClose={handleRenameCancel} + onShow={handleRenameModalShow} > - - 폴더명 수정 - - setRenameState((s) => ({ ...s, value: v }))} - placeholder="폴더 이름 입력" - placeholderTextColor={Colors.brand.textHint} - returnKeyType="done" - onSubmitEditing={handleRenameConfirm} - maxLength={50} - autoFocus - /> - {renameState.value.length > 0 && ( + + 폴더명 수정 + + setRenameState((s) => ({ ...s, value: v }))} + placeholder="폴더 이름 입력" + placeholderTextColor={Colors.brand.textHint} + returnKeyType="done" + onSubmitEditing={Keyboard.dismiss} + maxLength={50} + /> + {renameState.value.length > 0 && ( + setRenameState((s) => ({ ...s, value: '' }))} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={renameStyles.clearButton} + > + + + )} + + setRenameState((s) => ({ ...s, value: '' }))} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={renameStyles.clearButton} + style={renameStyles.cancelBtn} + onPress={handleRenameCancel} > - + 취소 - )} - - - - 취소 - - - - 저장 - - - + + + 저장 + + + + - + ); @@ -373,9 +462,14 @@ const renameStyles = StyleSheet.create({ backgroundColor: Colors.brand.overlayBackdrop, }, sheet: { + width: '100%', + maxHeight: '80%', backgroundColor: Colors.brand.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, + overflow: 'hidden', + }, + sheetContent: { padding: 24, paddingBottom: 40, gap: 16, diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index cc15e55..6e886f3 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Keyboard, KeyboardAvoidingView, Modal, Platform, @@ -75,7 +76,7 @@ export function LinkSaveModal({ placeholder="예: 네이버 블로그" placeholderTextColor={Colors.brand.textHint} returnKeyType="done" - onSubmitEditing={handleSave} + onSubmitEditing={Keyboard.dismiss} maxLength={500} editable={!loading} autoFocus diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 4593c06..99a888d 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -1,19 +1,45 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, - KeyboardAvoidingView, + Keyboard, + LayoutAnimation, Modal, Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, type SavedLink } from '@/context/saved-links-context'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const MODAL_BOTTOM_GAP = 16; +const KEYBOARD_TOP_GAP = 8; + +const showKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; +const hideKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + +const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { + if (Platform.OS !== 'ios') { + return; + } + + const duration = event.duration > 10 ? event.duration : 10; + + LayoutAnimation.configureNext({ + duration, + update: { + duration, + type: LayoutAnimation.Types[event.easing] || LayoutAnimation.Types.keyboard, + }, + }); +}; interface TitleEditModalProps { editingLink: SavedLink | null; @@ -22,20 +48,51 @@ interface TitleEditModalProps { } export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditModalProps) { + const insets = useSafeAreaInsets(); const [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const [keyboardInset, setKeyboardInset] = useState(0); const titleInputRef = useRef(null); + const titleFocusTimerRef = useRef | null>(null); + + const clearTitleFocusTimer = useCallback(() => { + if (titleFocusTimerRef.current) { + clearTimeout(titleFocusTimerRef.current); + titleFocusTimerRef.current = null; + } + }, []); useEffect(() => { if (!editingLink) { setTitleValue(''); + setKeyboardInset(0); + clearTitleFocusTimer(); return; } setTitleValue(editingLink.title); - const focusTimer = setTimeout(() => titleInputRef.current?.focus(), 100); + }, [clearTitleFocusTimer, editingLink]); + + useEffect(() => clearTitleFocusTimer, [clearTitleFocusTimer]); + + useEffect(() => { + if (!editingLink) { + return; + } - return () => clearTimeout(focusTimer); + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setKeyboardInset(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; }, [editingLink]); const handleClose = useCallback(() => { @@ -43,8 +100,18 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod return; } + clearTitleFocusTimer(); onClose(); - }, [isUpdatingTitle, onClose]); + }, [clearTitleFocusTimer, isUpdatingTitle, onClose]); + + const handleModalShow = useCallback(() => { + clearTitleFocusTimer(); + + titleFocusTimerRef.current = setTimeout(() => { + titleInputRef.current?.focus(); + titleFocusTimerRef.current = null; + }, 100); + }, [clearTitleFocusTimer]); const handleConfirm = useCallback(async () => { const trimmedTitle = titleValue.trim(); @@ -57,6 +124,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod try { await onConfirm(editingLink.id, trimmedTitle); + clearTitleFocusTimer(); onClose(); } catch (error) { Alert.alert( @@ -66,7 +134,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod } finally { setIsUpdatingTitle(false); } - }, [editingLink, isUpdatingTitle, onClose, onConfirm, titleValue]); + }, [clearTitleFocusTimer, editingLink, isUpdatingTitle, onClose, onConfirm, titleValue]); const trimmedTitleValue = titleValue.trim(); const titleSubmitDisabled = @@ -74,6 +142,10 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod trimmedTitleValue.length > 500 || trimmedTitleValue === editingLink?.title.trim() || isUpdatingTitle; + const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); + const modalBottomInset = keyboardInset > 0 + ? keyboardInset + KEYBOARD_TOP_GAP + : restingBottomInset; return ( - - 제목 수정 - - - {titleValue.length > 0 && !isUpdatingTitle && ( + + 제목 수정 + + + {titleValue.length > 0 && !isUpdatingTitle && ( + setTitleValue('')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + style={styles.clearButton} + > + + + )} + + setTitleValue('')} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - style={styles.clearButton} + style={styles.cancelBtn} + onPress={handleClose} + disabled={isUpdatingTitle} > - + 취소 - )} - - - - 취소 - - - - {isUpdatingTitle ? '저장 중...' : '저장'} - - - + + + {isUpdatingTitle ? '저장 중...' : '저장'} + + + + - + ); } @@ -147,9 +227,14 @@ const styles = StyleSheet.create({ backgroundColor: Colors.brand.overlayBackdrop, }, sheet: { + width: '100%', + maxHeight: '80%', backgroundColor: Colors.brand.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, + overflow: 'hidden', + }, + sheetContent: { padding: 24, paddingBottom: 40, gap: 16,