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 && ( { + 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,21 +58,86 @@ export function LinkSaveModal({ onCancel, onSave, }: LinkSaveModalProps) { + 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) { + 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 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, + lockMs: 250, + }); + const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); + const modalBottomInset = keyboardInset > 0 + ? keyboardInset + KEYBOARD_TOP_GAP + : restingBottomInset; return ( - + - - - - 링크 저장 - 저장할 URL 제목을 입력해 주세요. - - - - URL 제목 - - - - - 검사 대상 URL - - - {url} - + + + + + 링크 저장 + 저장할 URL 제목을 입력해 주세요. + + + + URL 제목 + + + {title.length > 0 && !loading && ( + + + + )} + + + + + 검사 대상 URL + + + {url} + + - - - - - 취소 - - - - - {loading ? '저장 중...' : '저장'} - - - + + + + 취소 + + + + + {loading ? '저장 중...' : '저장'} + + + + - + ); } @@ -133,12 +238,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 +255,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,26 +270,46 @@ 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, + 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: 56, - borderRadius: 20, + height: 52, + borderRadius: 18, backgroundColor: Colors.brand.background, justifyContent: 'center', paddingHorizontal: 18, @@ -193,12 +322,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 +340,8 @@ const styles = StyleSheet.create({ }, saveButton: { flex: 1, - height: 56, - borderRadius: 28, + height: 52, + borderRadius: 26, alignItems: 'center', justifyContent: 'center', backgroundColor: Colors.brand.primary,