From 357e21f59b056826da8db1c701fa0d57cf4fde9f Mon Sep 17 00:00:00 2001 From: Arda Erturk Date: Fri, 6 Jun 2025 18:32:33 -0400 Subject: [PATCH] Improve home screen interactions --- src/components/apps/DraggableAppIcon.tsx | 209 ++++++++---------- src/components/apps/IOSDragDropAppsScreen.tsx | 151 ++++++++----- src/components/apps/IOSFolderModal.tsx | 54 +++-- src/components/apps/IOSSearchWidget.tsx | 2 +- src/components/apps/IOSSpotlightModal.tsx | 76 +++++++ src/components/apps/SafariBrowser.tsx | 28 +-- 6 files changed, 307 insertions(+), 213 deletions(-) create mode 100644 src/components/apps/IOSSpotlightModal.tsx diff --git a/src/components/apps/DraggableAppIcon.tsx b/src/components/apps/DraggableAppIcon.tsx index 9fcde3f..8366f91 100644 --- a/src/components/apps/DraggableAppIcon.tsx +++ b/src/components/apps/DraggableAppIcon.tsx @@ -1,23 +1,14 @@ -import React, { useRef } from 'react'; -import { - StyleSheet, - View, - Pressable, -} from 'react-native'; +import React from 'react'; +import { StyleSheet, View, Pressable } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, runOnJS, - useAnimatedGestureHandler, withSequence, withTiming, } from 'react-native-reanimated'; -import { - PanGestureHandler, - LongPressGestureHandler, - State, -} from 'react-native-gesture-handler'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { IOSAppIcon } from './IOSAppIcon'; import { IOSFolderIcon } from './IOSFolderIcon'; import { hapticTrigger } from '@/src/utils/hapticFeedback'; @@ -36,6 +27,7 @@ interface DraggableAppIconProps { onDragEnd: (x: number, y: number) => void; isDragging: boolean; isDropTarget: boolean; + isOpening?: boolean; } export function DraggableAppIcon({ @@ -52,14 +44,13 @@ export function DraggableAppIcon({ onDragEnd, isDragging, isDropTarget, + isOpening, }: DraggableAppIconProps) { const translateX = useSharedValue(0); const translateY = useSharedValue(0); const scale = useSharedValue(1); const opacity = useSharedValue(1); const zIndex = useSharedValue(0); - const longPressRef = useRef(null); - const panRef = useRef(null); // Handle drop target animation React.useEffect(() => { @@ -71,65 +62,56 @@ export function DraggableAppIcon({ } }, [isDropTarget]); - const handleLongPress = () => { - if (!isEditMode) { - onLongPress(); + React.useEffect(() => { + if (isOpening) { + scale.value = withTiming(1.2, { duration: 200 }); + opacity.value = withTiming(0, { duration: 180 }); + } else { + scale.value = withTiming(1, { duration: 150 }); + opacity.value = withTiming(1, { duration: 150 }); } - }; + }, [isOpening]); - const panHandler = useAnimatedGestureHandler({ - onStart: () => { - 'worklet'; - if (isEditMode) { - scale.value = withSpring(1.1, { - damping: 15, - stiffness: 400, - }); - zIndex.value = 1000; - opacity.value = withTiming(0.8, { duration: 150 }); - runOnJS(onDragStart)(item, index); - runOnJS(hapticTrigger)('impactMedium'); - } - }, - onActive: (event) => { - 'worklet'; - if (isEditMode) { - translateX.value = event.translationX; - translateY.value = event.translationY; - runOnJS(onDragMove)( - position.x + event.translationX, - position.y + event.translationY - ); - } - }, - onEnd: (event) => { - 'worklet'; - if (isEditMode) { - const finalX = position.x + event.translationX; - const finalY = position.y + event.translationY; - - runOnJS(onDragEnd)(finalX, finalY); - - // Animate back to original position - translateX.value = withSpring(0, { - damping: 20, - stiffness: 400, - }); - translateY.value = withSpring(0, { - damping: 20, - stiffness: 400, - }); - scale.value = withSpring(1, { - damping: 15, - stiffness: 400, - }); - opacity.value = withTiming(1, { duration: 150 }); - zIndex.value = 0; - - runOnJS(hapticTrigger)('impactLight'); + + const panGesture = Gesture.Pan() + .enabled(isEditMode) + .onBegin(() => { + scale.value = withSpring(1.1, { damping: 15, stiffness: 400 }); + zIndex.value = 1000; + opacity.value = withTiming(0.8, { duration: 150 }); + runOnJS(onDragStart)(item, index); + runOnJS(hapticTrigger)('impactMedium'); + }) + .onUpdate((event) => { + translateX.value = event.translationX; + translateY.value = event.translationY; + runOnJS(onDragMove)( + position.x + event.translationX, + position.y + event.translationY + ); + }) + .onEnd((event) => { + const finalX = position.x + event.translationX; + const finalY = position.y + event.translationY; + runOnJS(onDragEnd)(finalX, finalY); + + translateX.value = withSpring(0, { damping: 20, stiffness: 400 }); + translateY.value = withSpring(0, { damping: 20, stiffness: 400 }); + scale.value = withSpring(1, { damping: 15, stiffness: 400 }); + opacity.value = withTiming(1, { duration: 150 }); + zIndex.value = 0; + runOnJS(hapticTrigger)('impactLight'); + }); + + const longPressGesture = Gesture.LongPress() + .minDuration(400) + .onStart(() => { + if (!isEditMode) { + runOnJS(onLongPress)(); } - }, - }); + }); + + const combinedGesture = Gesture.Simultaneous(panGesture, longPressGesture); const animatedStyle = useAnimatedStyle(() => { return { @@ -144,58 +126,41 @@ export function DraggableAppIcon({ }); return ( - { - if (event.nativeEvent.state === State.ACTIVE) { - handleLongPress(); - } - }} - minDurationMs={400} - simultaneousHandlers={panRef} - > - - - - {isDragging ? ( - - ) : ( - <> - {isFolder ? ( - {}} - onLongPress={() => {}} - onDelete={onDelete} - /> - ) : ( - {}} - onLongPress={() => {}} - onDelete={onDelete} - /> - )} - - )} - - - - + + + + {isDragging ? ( + + ) : ( + <> + {isFolder ? ( + {}} + onLongPress={() => {}} + onDelete={onDelete} + /> + ) : ( + {}} + onLongPress={() => {}} + onDelete={onDelete} + /> + )} + + )} + + + ); } diff --git a/src/components/apps/IOSDragDropAppsScreen.tsx b/src/components/apps/IOSDragDropAppsScreen.tsx index 5818a01..f139c08 100644 --- a/src/components/apps/IOSDragDropAppsScreen.tsx +++ b/src/components/apps/IOSDragDropAppsScreen.tsx @@ -18,6 +18,7 @@ import { DraggableAppIcon } from '@/src/components/apps/DraggableAppIcon'; // Icon components are used within DraggableAppIcon import { IOSSearchWidget } from '@/src/components/apps/IOSSearchWidget'; import { IOSFolderModal } from '@/src/components/apps/IOSFolderModal'; +import { IOSSpotlightModal } from '@/src/components/apps/IOSSpotlightModal'; import { SafariBrowser } from '@/src/components/apps/SafariBrowser'; import { hapticTrigger } from '@/src/utils/hapticFeedback'; import Animated, { @@ -27,15 +28,9 @@ import Animated, { withTiming, interpolate, runOnJS, - useAnimatedGestureHandler, withSequence, withDelay, } from 'react-native-reanimated'; -import { - PanGestureHandler, - State, - LongPressGestureHandler, -} from 'react-native-gesture-handler'; const { width, height } = Dimensions.get('window'); const ICONS_PER_ROW = 4; @@ -86,6 +81,8 @@ export default function IOSDragDropAppsScreen() { const [isEditMode, setIsEditMode] = useState(false); const [browserVisible, setBrowserVisible] = useState(false); const [browserUrl, setBrowserUrl] = useState(''); + const [openingAppId, setOpeningAppId] = useState(null); + const [spotlightVisible, setSpotlightVisible] = useState(false); const [openFolder, setOpenFolder] = useState(null); const [folderPosition, setFolderPosition] = useState({ x: 0, y: 0 }); const [dragState, setDragState] = useState({ @@ -261,21 +258,28 @@ export default function IOSDragDropAppsScreen() { } }, [isEditMode, dragState.isActive, handleDonePress]); - const handleAppPress = useCallback((app: App) => { - if (isEditMode || dragState.isActive) return; - - hapticTrigger('impactLight'); - setBrowserUrl(app.url); - - browserScale.value = 0.9; - browserOpacity.value = 0; + const handleAppPress = useCallback( + (app: App, position: { x: number; y: number }) => { + if (isEditMode || dragState.isActive) return; - browserScale.value = withTiming(1, { duration: 250 }); - browserOpacity.value = withTiming(1, { duration: 250 }); - backgroundBlur.value = withTiming(10, { duration: 250 }); - - setBrowserVisible(true); - }, [isEditMode, dragState.isActive]); + hapticTrigger('impactLight'); + setOpeningAppId(app.id); + setBrowserUrl(app.url); + + browserScale.value = 0.85; + browserOpacity.value = 0; + + browserScale.value = withTiming(1, { duration: 250 }); + browserOpacity.value = withTiming(1, { duration: 250 }); + backgroundBlur.value = withTiming(10, { duration: 250 }); + + setTimeout(() => { + setBrowserVisible(true); + setOpeningAppId(null); + }, 200); + }, + [isEditMode, dragState.isActive], + ); const handleFolderPress = useCallback((folder: Folder, position: { x: number; y: number }) => { if (isEditMode || dragState.isActive) return; @@ -333,17 +337,31 @@ export default function IOSDragDropAppsScreen() { const handleSearchPress = useCallback(() => { hapticTrigger('impactLight'); - - browserScale.value = 0.9; - browserOpacity.value = 0; - - browserScale.value = withTiming(1, { duration: 250 }); - browserOpacity.value = withTiming(1, { duration: 250 }); - backgroundBlur.value = withTiming(10, { duration: 250 }); - - setBrowserVisible(true); + setSpotlightVisible(true); }, []); + const handleSearchSubmit = useCallback( + (text: string) => { + setSpotlightVisible(false); + if (!text) return; + let finalUrl = text; + if (!finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) { + finalUrl = 'https://' + finalUrl; + } + + browserScale.value = 0.85; + browserOpacity.value = 0; + + browserScale.value = withTiming(1, { duration: 250 }); + browserOpacity.value = withTiming(1, { duration: 250 }); + backgroundBlur.value = withTiming(10, { duration: 250 }); + + setBrowserUrl(finalUrl); + setBrowserVisible(true); + }, + [], + ); + const handleBrowserClose = useCallback(() => { // Animate browser closing browserScale.value = withTiming(0, { duration: 200 }); @@ -410,29 +428,36 @@ export default function IOSDragDropAppsScreen() { const handleItemDrop = (item: App | Folder, newPosition: number) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - - // Update item positions - if ('apps' in item) { - // It's a folder - setFolders(prev => { - const updated = [...prev]; - const itemIndex = updated.findIndex(f => f.id === item.id); - if (itemIndex >= 0) { - updated[itemIndex] = { ...updated[itemIndex], position: newPosition }; - } - return updated.sort((a, b) => a.position - b.position); - }); - } else { - // It's an app - setApps(prev => { - const updated = [...prev]; - const itemIndex = updated.findIndex(a => a.id === item.id); - if (itemIndex >= 0) { - updated[itemIndex] = { ...updated[itemIndex], position: newPosition }; - } - return updated.sort((a, b) => a.position - b.position); - }); - } + + // Collect visible items sorted by position + const standaloneApps = apps.filter((a) => !a.folderId); + const allItems: (App | Folder)[] = [...standaloneApps, ...folders].sort( + (a, b) => a.position - b.position, + ); + + const currentIndex = allItems.findIndex((i) => i.id === item.id); + if (currentIndex === -1) return; + + const clampedPosition = Math.max(0, Math.min(newPosition, allItems.length - 1)); + + allItems.splice(currentIndex, 1); + allItems.splice(clampedPosition, 0, item); + + const updatedApps = [...apps]; + const updatedFolders = [...folders]; + + allItems.forEach((it, idx) => { + if ('apps' in it) { + const fIdx = updatedFolders.findIndex((f) => f.id === it.id); + if (fIdx >= 0) updatedFolders[fIdx] = { ...updatedFolders[fIdx], position: idx }; + } else { + const aIdx = updatedApps.findIndex((a) => a.id === it.id); + if (aIdx >= 0) updatedApps[aIdx] = { ...updatedApps[aIdx], position: idx }; + } + }); + + setApps(updatedApps.sort((a, b) => a.position - b.position)); + setFolders(updatedFolders.sort((a, b) => a.position - b.position)); }; const backgroundAnimatedStyle = useAnimatedStyle(() => { @@ -492,7 +517,17 @@ export default function IOSDragDropAppsScreen() { isEditMode={isEditMode} position={{ x: position.x, y: position.y }} index={itemIndex} - onPress={() => (isFolder ? handleFolderPress(item as Folder, { x: position.x + ICON_SIZE / 2, y: position.y + ICON_SIZE / 2 }) : handleAppPress(item as App))} + onPress={() => + isFolder + ? handleFolderPress(item as Folder, { + x: position.x + ICON_SIZE / 2, + y: position.y + ICON_SIZE / 2, + }) + : handleAppPress(item as App, { + x: position.x + ICON_SIZE / 2, + y: position.y + ICON_SIZE / 2, + }) + } onLongPress={handleLongPress} onDelete={() => (isFolder ? handleDeleteFolder(item.id) : handleDeleteApp(item.id))} onDragStart={handleDragStart} @@ -500,6 +535,7 @@ export default function IOSDragDropAppsScreen() { onDragEnd={handleDragEnd} isDragging={isDragging} isDropTarget={dropTargetIndex === itemIndex} + isOpening={openingAppId === item.id} /> ); })} @@ -528,7 +564,7 @@ export default function IOSDragDropAppsScreen() { {/* iOS 17 style gradient background */} + + {/* Spotlight Search Modal */} + setSpotlightVisible(false)} + onSubmit={handleSearchSubmit} + /> {/* Folder Modal */} {openFolder && ( diff --git a/src/components/apps/IOSFolderModal.tsx b/src/components/apps/IOSFolderModal.tsx index 7e97d69..e30dc7d 100644 --- a/src/components/apps/IOSFolderModal.tsx +++ b/src/components/apps/IOSFolderModal.tsx @@ -24,9 +24,12 @@ import { IOSAppIcon } from './IOSAppIcon'; import { hapticTrigger } from '@/src/utils/hapticFeedback'; const { width, height } = Dimensions.get('window'); -const FOLDER_WIDTH = width - 30; -const FOLDER_HEIGHT = 380; +// Folder size tuned to better match iOS folder popover +const FOLDER_WIDTH = width - 60; +const FOLDER_HEIGHT = Math.min(360, height * 0.65); const ICONS_PER_ROW = 3; +const ROWS_PER_PAGE = 3; +const ICONS_PER_PAGE = ICONS_PER_ROW * ROWS_PER_PAGE; const ICON_SIZE = 74; const ICON_SPACING = 25; @@ -65,6 +68,8 @@ export function IOSFolderModal({ const translateY = useSharedValue(0); const [isEditing, setIsEditing] = React.useState(false); const [editedName, setEditedName] = React.useState(folderName); + const [currentPage, setCurrentPage] = React.useState(0); + const totalPages = Math.max(1, Math.ceil(apps.length / ICONS_PER_PAGE)); useEffect(() => { if (visible) { @@ -130,10 +135,12 @@ export function IOSFolderModal({ setIsEditing(false); }; - const renderApps = () => { + const renderApps = (page: number) => { + const startIndex = page * ICONS_PER_PAGE; + const pageApps = apps.slice(startIndex, startIndex + ICONS_PER_PAGE); const rows = []; - for (let i = 0; i < apps.length; i += ICONS_PER_ROW) { - const rowApps = apps.slice(i, i + ICONS_PER_ROW); + for (let i = 0; i < ROWS_PER_PAGE; i++) { + const rowApps = pageApps.slice(i * ICONS_PER_ROW, (i + 1) * ICONS_PER_ROW); rows.push( {rowApps.map((app) => ( @@ -207,18 +214,34 @@ export function IOSFolderModal({ {/* Apps grid */} + setCurrentPage( + Math.round(e.nativeEvent.contentOffset.x / FOLDER_WIDTH), + ) + } > - {renderApps()} + {Array.from({ length: totalPages }).map((_, pageIndex) => ( + + {renderApps(pageIndex)} + + ))} - {/* Page dots if needed */} - {apps.length > 9 && ( + {/* Page dots */} + {totalPages > 1 && ( - - + {Array.from({ length: totalPages }).map((_, idx) => ( + + ))} )} @@ -286,10 +309,7 @@ const styles = StyleSheet.create({ textAlign: 'center', }, appsContainer: { - flex: 1, - }, - appsContent: { - paddingBottom: 20, + flexGrow: 0, }, appRow: { flexDirection: 'row', diff --git a/src/components/apps/IOSSearchWidget.tsx b/src/components/apps/IOSSearchWidget.tsx index 4f489a8..1771007 100644 --- a/src/components/apps/IOSSearchWidget.tsx +++ b/src/components/apps/IOSSearchWidget.tsx @@ -97,7 +97,7 @@ export function IOSSearchWidget({ onPress, recentApps = DEFAULT_RECENT_APPS }: I void; + onSubmit: (query: string) => void; +} + +export function IOSSpotlightModal({ visible, onClose, onSubmit }: IOSSpotlightModalProps) { + const opacity = useSharedValue(0); + const scale = useSharedValue(0.95); + const [query, setQuery] = useState(''); + + useEffect(() => { + if (visible) { + opacity.value = withTiming(1, { duration: 250 }); + scale.value = withTiming(1, { duration: 250 }); + } else { + opacity.value = withTiming(0, { duration: 200 }); + scale.value = withTiming(0.95, { duration: 200 }); + } + }, [visible]); + + const containerStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); + + return ( + + + + + + + onSubmit(query)} + /> + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + searchContainer: { + width: '90%', + backgroundColor: Apple.Colors.secondarySystemBackground, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + }, + input: { + fontSize: 17, + color: Apple.Colors.label, + }, +}); diff --git a/src/components/apps/SafariBrowser.tsx b/src/components/apps/SafariBrowser.tsx index ed00a62..c74adab 100644 --- a/src/components/apps/SafariBrowser.tsx +++ b/src/components/apps/SafariBrowser.tsx @@ -22,13 +22,9 @@ import Animated, { withTiming, interpolate, runOnJS, - useAnimatedGestureHandler, - withSequence, } from 'react-native-reanimated'; -import { - PanGestureHandler, - State, -} from 'react-native-gesture-handler'; +} from 'react-native-reanimated'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { BlurView } from 'expo-blur'; import { LinearGradient } from 'expo-linear-gradient'; import { Apple } from '@/constants/AppleDesign'; @@ -97,12 +93,8 @@ export function SafariBrowser({ visible, initialUrl, onClose }: SafariBrowserPro }; }); - const gestureHandler = useAnimatedGestureHandler({ - onStart: () => { - 'worklet'; - }, - onActive: (event) => { - 'worklet'; + const panGesture = Gesture.Pan() + .onUpdate((event) => { if (event.translationY > 0) { gestureTranslateY.value = event.translationY; scale.value = interpolate( @@ -116,9 +108,8 @@ export function SafariBrowser({ visible, initialUrl, onClose }: SafariBrowserPro [0, 20] ); } - }, - onEnd: (event) => { - 'worklet'; + }) + .onEnd((event) => { if (event.translationY > SWIPE_THRESHOLD) { translateY.value = withTiming(height, { duration: 300 }); runOnJS(onClose)(); @@ -137,8 +128,7 @@ export function SafariBrowser({ visible, initialUrl, onClose }: SafariBrowserPro stiffness: 400, }); } - }, - }); + }); const handleNavigate = () => { let finalUrl = inputUrl; @@ -179,7 +169,7 @@ export function SafariBrowser({ visible, initialUrl, onClose }: SafariBrowserPro - + {/* Drag indicator */} @@ -304,7 +294,7 @@ export function SafariBrowser({ visible, initialUrl, onClose }: SafariBrowserPro - + );