From be785e8b34bf952fc83b3c699f640a1b95cc5d80 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 30 May 2026 14:07:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/link-save-modal.tsx | 207 +++++++++++++++++++----------- 1 file changed, 133 insertions(+), 74 deletions(-) diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index bf4d680..cc51512 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -1,20 +1,45 @@ import { useEffect, useState } from 'react'; import { Keyboard, - KeyboardAvoidingView, + LayoutAnimation, Modal, Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; +const MODAL_BOTTOM_GAP = 16; +const KEYBOARD_TOP_GAP = 16; + +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 LinkSaveModalProps { visible: boolean; url: string; @@ -33,7 +58,9 @@ export function LinkSaveModal({ onCancel, onSave, }: LinkSaveModalProps) { + const insets = useSafeAreaInsets(); const [title, setTitle] = useState(initialTitle); + const [keyboardInset, setKeyboardInset] = useState(0); const trimmedTitle = title.trim(); const saveDisabled = loading || trimmedTitle.length === 0; @@ -42,12 +69,37 @@ export function LinkSaveModal({ setTitle(initialTitle); }, [initialTitle, visible]); + useEffect(() => { + if (!visible) { + setKeyboardInset(0); + return; + } + + 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(); + }; + }, [visible]); + const handleSave = () => { if (saveDisabled) return; onSave(trimmedTitle); }; const guardedCancel = useGuardedPress(onCancel, { disabled: loading, lockMs: 250 }); const guardedSave = useGuardedPress(handleSave, { disabled: saveDisabled }); + const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); + const modalBottomInset = keyboardInset > 0 + ? keyboardInset + KEYBOARD_TOP_GAP + : restingBottomInset; return ( - + - - - - 링크 저장 - 저장할 URL 제목을 입력해 주세요. - - - - URL 제목 - - - - - 검사 대상 URL - - - {url} - + + + + + 링크 저장 + 저장할 URL 제목을 입력해 주세요. - - - - - 취소 - - - - - {loading ? '저장 중...' : '저장'} - - - + + + URL 제목 + + + + + 검사 대상 URL + + + {url} + + + + + + + 취소 + + + + + {loading ? '저장 중...' : '저장'} + + + + - + ); } @@ -133,12 +188,16 @@ const styles = StyleSheet.create({ }, sheet: { width: '100%', + maxHeight: '80%', backgroundColor: Colors.light.background, borderTopLeftRadius: 28, borderTopRightRadius: 28, - paddingTop: 14, + overflow: 'hidden', + }, + sheetContent: { + paddingTop: 12, paddingHorizontal: 24, - paddingBottom: 32, + paddingBottom: 24, }, handle: { alignSelf: 'center', @@ -146,11 +205,11 @@ const styles = StyleSheet.create({ height: 6, borderRadius: 3, backgroundColor: Colors.brand.line, - marginBottom: 30, + marginBottom: 22, }, header: { - gap: 14, - marginBottom: 34, + gap: 8, + marginBottom: 24, }, title: { ...Typography.title, @@ -161,16 +220,16 @@ const styles = StyleSheet.create({ color: Colors.brand.textSecondary, }, fieldGroup: { - gap: 12, - marginBottom: 28, + gap: 10, + marginBottom: 20, }, label: { ...Typography.caption, color: Colors.brand.text, }, input: { - height: 56, - borderRadius: 20, + height: 52, + borderRadius: 18, borderWidth: 1, borderColor: Colors.brand.line, paddingHorizontal: 18, @@ -179,8 +238,8 @@ const styles = StyleSheet.create({ backgroundColor: Colors.light.background, }, urlBox: { - height: 56, - borderRadius: 20, + height: 52, + borderRadius: 18, backgroundColor: Colors.brand.background, justifyContent: 'center', paddingHorizontal: 18, @@ -193,12 +252,12 @@ const styles = StyleSheet.create({ actions: { flexDirection: 'row', gap: 14, - marginTop: 4, + marginTop: 0, }, cancelButton: { flex: 1, - height: 56, - borderRadius: 28, + height: 52, + borderRadius: 26, borderWidth: 1.5, borderColor: Colors.brand.primary, alignItems: 'center', @@ -211,8 +270,8 @@ const styles = StyleSheet.create({ }, saveButton: { flex: 1, - height: 56, - borderRadius: 28, + height: 52, + borderRadius: 26, alignItems: 'center', justifyContent: 'center', backgroundColor: Colors.brand.primary, From f7e2081a70c44e8cb2751865067546d6a7fd1924 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 30 May 2026 14:10:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=20=EC=A0=9C=EB=AA=A9=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B2=84=ED=8A=BC=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 --- components/ui/link-save-modal.tsx | 63 ++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index cc51512..fe3ed2e 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -96,6 +96,10 @@ export function LinkSaveModal({ }; const guardedCancel = useGuardedPress(onCancel, { disabled: loading, lockMs: 250 }); const guardedSave = useGuardedPress(handleSave, { disabled: saveDisabled }); + const guardedClearTitle = useGuardedPress(() => setTitle(''), { + disabled: loading, + lockMs: 250, + }); const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); const modalBottomInset = keyboardInset > 0 ? keyboardInset + KEYBOARD_TOP_GAP @@ -126,18 +130,29 @@ export function LinkSaveModal({ URL 제목 - + + + {title.length > 0 && !loading && ( + + + + )} + @@ -227,15 +242,35 @@ const styles = StyleSheet.create({ ...Typography.caption, color: Colors.brand.text, }, - input: { + inputRow: { + flexDirection: 'row', + alignItems: 'center', height: 52, borderRadius: 18, borderWidth: 1, borderColor: Colors.brand.line, paddingHorizontal: 18, + backgroundColor: Colors.light.background, + }, + input: { + flex: 1, ...Typography.body, color: Colors.brand.text, - backgroundColor: Colors.light.background, + padding: 0, + }, + clearButton: { + marginLeft: 8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: Colors.brand.softMint, + alignItems: 'center', + justifyContent: 'center', + }, + clearButtonText: { + ...Typography.caption, + color: Colors.brand.primary, + lineHeight: 16, }, urlBox: { height: 52, From 5e8b3a6587fa49021dbc1f2c31f40576d81ab10d Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 30 May 2026 14:21:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=20=EC=9E=85=EB=A0=A5=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=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 --- components/ui/link-save-modal.tsx | 43 ++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index fe3ed2e..381a7de 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, LayoutAnimation, @@ -61,13 +61,28 @@ export function LinkSaveModal({ const insets = useSafeAreaInsets(); const [title, setTitle] = useState(initialTitle); const [keyboardInset, setKeyboardInset] = useState(0); + const titleInputRef = useRef(null); + const titleFocusTimerRef = useRef | null>(null); const trimmedTitle = title.trim(); const saveDisabled = loading || trimmedTitle.length === 0; + const clearTitleFocusTimer = useCallback(() => { + if (titleFocusTimerRef.current) { + clearTimeout(titleFocusTimerRef.current); + titleFocusTimerRef.current = null; + } + }, []); + useEffect(() => { setTitle(initialTitle); - }, [initialTitle, visible]); + + if (!visible) { + clearTitleFocusTimer(); + } + }, [clearTitleFocusTimer, initialTitle, visible]); + + useEffect(() => clearTitleFocusTimer, [clearTitleFocusTimer]); useEffect(() => { if (!visible) { @@ -90,11 +105,30 @@ export function LinkSaveModal({ }; }, [visible]); + const handleCancel = useCallback(() => { + if (loading) { + return; + } + + clearTitleFocusTimer(); + onCancel(); + }, [clearTitleFocusTimer, loading, onCancel]); + + const handleModalShow = useCallback(() => { + clearTitleFocusTimer(); + + titleFocusTimerRef.current = setTimeout(() => { + titleInputRef.current?.focus(); + titleFocusTimerRef.current = null; + }, 100); + }, [clearTitleFocusTimer]); + const handleSave = () => { if (saveDisabled) return; + clearTitleFocusTimer(); onSave(trimmedTitle); }; - const guardedCancel = useGuardedPress(onCancel, { disabled: loading, lockMs: 250 }); + const guardedCancel = useGuardedPress(handleCancel, { disabled: loading, lockMs: 250 }); const guardedSave = useGuardedPress(handleSave, { disabled: saveDisabled }); const guardedClearTitle = useGuardedPress(() => setTitle(''), { disabled: loading, @@ -111,6 +145,7 @@ export function LinkSaveModal({ transparent animationType="slide" onRequestClose={guardedCancel} + onShow={handleModalShow} > @@ -132,6 +167,7 @@ export function LinkSaveModal({ URL 제목 {title.length > 0 && !loading && ( Date: Sat, 30 May 2026 14:28:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20URL=20=EC=9E=85=EB=A0=A5=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=8F=99=EC=9E=91=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/add-link.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index b8250f1..1e3afc3 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useFocusEffect } from '@react-navigation/native'; import { + Keyboard, KeyboardAvoidingView, Platform, StyleSheet, @@ -37,14 +38,33 @@ export default function AddLinkScreen() { const isCompact = windowWidth < COMPACT_WIDTH || windowHeight <= SHORT_SCREEN_HEIGHT; const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); + const urlInputRef = useRef(null); + const urlFocusTimerRef = useRef | null>(null); + + const clearUrlFocusTimer = useCallback(() => { + if (urlFocusTimerRef.current) { + clearTimeout(urlFocusTimerRef.current); + urlFocusTimerRef.current = null; + } + }, []); useFocusEffect( useCallback(() => { isNavigatingRef.current = false; setIsNavigating(false); - }, []), + + clearUrlFocusTimer(); + urlFocusTimerRef.current = setTimeout(() => { + urlInputRef.current?.focus(); + urlFocusTimerRef.current = null; + }, 100); + + return clearUrlFocusTimer; + }, [clearUrlFocusTimer]), ); + useEffect(() => clearUrlFocusTimer, [clearUrlFocusTimer]); + useEffect(() => { const nextSharedUrl = getSharedUrlParam(sharedUrl); @@ -132,6 +152,7 @@ export default function AddLinkScreen() { {url.length > 0 && (