From 85683d59edab22b42f240fcf14cfd2b58254c014 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 01:01:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 117 +++++++++++++++++---------- components/ui/link-save-modal.tsx | 3 +- components/ui/title-edit-modal.tsx | 125 ++++++++++++++++++----------- 3 files changed, 157 insertions(+), 88 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index c2093bc..4be63b0 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -2,9 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, - KeyboardAvoidingView, + Keyboard, Modal, - Platform, Pressable, ScrollView, StyleSheet, @@ -56,6 +55,7 @@ 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); @@ -81,6 +81,25 @@ export default function FolderScreen() { setCreateToastVisible(true); }, [folderCreated]); + useEffect(() => { + if (!renameState.visible) { + setRenameKeyboardInset(0); + return; + } + + const showSubscription = Keyboard.addListener('keyboardDidShow', (event) => { + setRenameKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + 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 ?? ''; @@ -240,52 +259,63 @@ export default function FolderScreen() { animationType="fade" onRequestClose={handleRenameCancel} > - 0 && { paddingBottom: renameKeyboardInset }, + ]} > - 폴더명 수정 - - 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} + autoFocus + /> + {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 +403,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..4ab7ef2 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, - KeyboardAvoidingView, + Keyboard, Modal, - Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, @@ -24,11 +24,13 @@ interface TitleEditModalProps { export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditModalProps) { const [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const [keyboardInset, setKeyboardInset] = useState(0); const titleInputRef = useRef(null); useEffect(() => { if (!editingLink) { setTitleValue(''); + setKeyboardInset(0); return; } @@ -38,6 +40,24 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod return () => clearTimeout(focusTimer); }, [editingLink]); + useEffect(() => { + if (!editingLink) { + return; + } + + const showSubscription = Keyboard.addListener('keyboardDidShow', (event) => { + setKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardInset(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [editingLink]); + const handleClose = useCallback(() => { if (isUpdatingTitle) { return; @@ -82,57 +102,65 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod animationType="fade" onRequestClose={handleClose} > - 0 && { paddingBottom: keyboardInset }, + ]} > - 제목 수정 - - - {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.confirmBtn, titleSubmitDisabled && styles.confirmBtnDisabled]} + onPress={handleConfirm} + disabled={titleSubmitDisabled} > - + + {isUpdatingTitle ? '저장 중...' : '저장'} + - )} - - - - 취소 - - - - {isUpdatingTitle ? '저장 중...' : '저장'} - - - + + - + ); } @@ -147,9 +175,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, From 4b11d0e65d0ce7be7bb280a4d3ff92c46ea0668e Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 01:08:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=95=98=EB=8B=A8=20=EC=9C=84=EC=B9=98=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 12 ++++++++++-- components/ui/title-edit-modal.tsx | 11 ++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 4be63b0..2b7d929 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -12,7 +12,7 @@ import { TouchableOpacity, View, } 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'; @@ -30,7 +30,11 @@ type MenuState = { folderId?: number; }; +const RENAME_MODAL_BOTTOM_GAP = 16; +const RENAME_KEYBOARD_TOP_GAP = 8; + export default function FolderScreen() { + const insets = useSafeAreaInsets(); const router = useRouter(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; @@ -168,6 +172,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 ( @@ -262,7 +270,7 @@ export default function FolderScreen() { 0 && { paddingBottom: renameKeyboardInset }, + { paddingBottom: renameModalBottomInset }, ]} > diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 4ab7ef2..7e8031c 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -14,6 +14,10 @@ import { 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; interface TitleEditModalProps { editingLink: SavedLink | null; @@ -22,6 +26,7 @@ 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); @@ -94,6 +99,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 ( 0 && { paddingBottom: keyboardInset }, + { paddingBottom: modalBottomInset }, ]} > From 0875fce28fae8e833f7997241f46b6c9ee8d1480 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 14:52:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20iOS=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=9C=84=EC=B9=98=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 28 ++++++++++++++++++++++++++-- components/ui/title-edit-modal.tsx | 28 ++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 2b7d929..6f7ee0e 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -3,7 +3,9 @@ import { ActivityIndicator, Alert, Keyboard, + LayoutAnimation, Modal, + Platform, Pressable, ScrollView, StyleSheet, @@ -11,6 +13,7 @@ import { TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -33,6 +36,25 @@ type MenuState = { 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(); @@ -91,10 +113,12 @@ export default function FolderScreen() { return; } - const showSubscription = Keyboard.addListener('keyboardDidShow', (event) => { + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); setRenameKeyboardInset(event.endCoordinates.height); }); - const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); setRenameKeyboardInset(0); }); diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 7e8031c..b8014fc 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, Keyboard, + LayoutAnimation, Modal, + Platform, Pressable, ScrollView, StyleSheet, @@ -10,6 +12,7 @@ import { TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; @@ -19,6 +22,25 @@ 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; onConfirm: (id: number, title: string) => Promise; @@ -50,10 +72,12 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod return; } - const showSubscription = Keyboard.addListener('keyboardDidShow', (event) => { + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); setKeyboardInset(event.endCoordinates.height); }); - const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); setKeyboardInset(0); }); From 5938e469d70ce15b3147ced7f291e381e29257db Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 14:59:12 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=ED=8F=AC=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=20=EC=B2=98=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 6f7ee0e..6ac3003 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -132,7 +132,6 @@ export default function FolderScreen() { 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 () => { From 66f30267819b05e08e7b976ef6e083aa44f278ce Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 15:16:49 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=ED=8F=AC=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 30 ++++++++++++++++++++++++++++- components/ui/title-edit-modal.tsx | 31 ++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 6ac3003..ede91f0 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -87,6 +87,7 @@ export default function FolderScreen() { 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( @@ -107,6 +108,14 @@ export default function FolderScreen() { setCreateToastVisible(true); }, [folderCreated]); + useEffect(() => { + return () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + } + }; + }, []); + useEffect(() => { if (!renameState.visible) { setRenameKeyboardInset(0); @@ -141,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) { @@ -154,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; @@ -289,6 +317,7 @@ export default function FolderScreen() { transparent animationType="fade" onRequestClose={handleRenameCancel} + onShow={handleRenameModalShow} > {renameState.value.length > 0 && ( (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]); - return () => clearTimeout(focusTimer); - }, [editingLink]); + useEffect(() => clearTitleFocusTimer, [clearTitleFocusTimer]); useEffect(() => { if (!editingLink) { @@ -92,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(); @@ -106,6 +124,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod try { await onConfirm(editingLink.id, trimmedTitle); + clearTitleFocusTimer(); onClose(); } catch (error) { Alert.alert( @@ -115,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 = @@ -134,6 +153,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod transparent animationType="fade" onRequestClose={handleClose} + onShow={handleModalShow} > {titleValue.length > 0 && !isUpdatingTitle && (