mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
global search gesture
This commit is contained in:
@@ -10,10 +10,16 @@ import { SpectrumCurve } from '@/components/SpectrumCurve';
|
||||
import { TrackRow } from '@/components/library/TrackRow';
|
||||
import { PlaylistRow } from '@/components/library/PlaylistRow';
|
||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||
import {
|
||||
PullSearchGesture,
|
||||
PullSearchScrollView,
|
||||
useScrollTopGate,
|
||||
} from '@/components/search/PullSearchGesture';
|
||||
import { colors, fonts, radius, spacing } from '@/theme';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useSearchStore } from '@/stores/searchStore';
|
||||
import {
|
||||
playTracks,
|
||||
shuffleTracks,
|
||||
@@ -352,9 +358,11 @@ export default function HomeScreen() {
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
|
||||
|
||||
const [randomAlbumKey, setRandomAlbumKey] = useState<string | null>(null);
|
||||
const [randomSeed] = useState(() => Math.random());
|
||||
const scrollTop = useScrollTopGate();
|
||||
|
||||
const tracksByAlbum = useMemo(() => {
|
||||
const map = new Map<string, DbTrack[]>();
|
||||
@@ -418,9 +426,18 @@ export default function HomeScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const openSearch = () => openQuickSearch();
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||
<PullSearchScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={styles.content}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<AstraLogo size={36} />
|
||||
<Text style={styles.wordmark}>ASTRA</Text>
|
||||
@@ -548,7 +565,8 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</PullSearchScrollView>
|
||||
</PullSearchGesture>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import { ScanProgress } from '@/components/library/ScanProgress';
|
||||
import { EmptyLibrary } from '@/components/library/EmptyLibrary';
|
||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import { ActionSheet } from '@/components/sheets/ActionSheet';
|
||||
import {
|
||||
PullSearchGesture,
|
||||
PullSearchScrollView,
|
||||
useScrollTopGate,
|
||||
} from '@/components/search/PullSearchGesture';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
@@ -43,6 +48,7 @@ export default function LibraryScreen() {
|
||||
|
||||
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
|
||||
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
||||
const scrollTop = useScrollTopGate();
|
||||
|
||||
const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning;
|
||||
|
||||
@@ -52,111 +58,137 @@ export default function LibraryScreen() {
|
||||
const playAllFrom = (index: number) => {
|
||||
void playTracks(sortedTracks.map(dbTrackToTrack), index);
|
||||
};
|
||||
const openSearch = () => openQuickSearch();
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<View style={styles.headingRow}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Library
|
||||
</Text>
|
||||
{!isEmpty ? (
|
||||
<Pressable
|
||||
hitSlop={8}
|
||||
onPress={() => openQuickSearch()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Search library"
|
||||
>
|
||||
<Ionicons name="search" size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyLibrary />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.switcher}>
|
||||
<ViewModeSwitcher value={viewMode} onChange={setViewMode} />
|
||||
</View>
|
||||
<ScanProgress />
|
||||
{scanError ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
|
||||
Scan problem: {scanError}
|
||||
</Text>
|
||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||
<View style={styles.headingRow}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Library
|
||||
</Text>
|
||||
{!isEmpty ? (
|
||||
<Pressable
|
||||
hitSlop={8}
|
||||
onPress={() => openQuickSearch()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Search library"
|
||||
>
|
||||
<Ionicons name="search" size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{viewMode === 'albums' ? (
|
||||
<FlashList
|
||||
data={albums}
|
||||
numColumns={2}
|
||||
keyExtractor={(album) => album.identity_key}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.gridCell}>
|
||||
<AlbumGridItem
|
||||
album={item}
|
||||
{isEmpty ? (
|
||||
<EmptyLibrary />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.switcher}>
|
||||
<ViewModeSwitcher
|
||||
value={viewMode}
|
||||
onChange={(mode) => {
|
||||
scrollTop.setScrollAtTop(true);
|
||||
setViewMode(mode);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ScanProgress />
|
||||
{scanError ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.error} numberOfLines={2}>
|
||||
Scan problem: {scanError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'albums' ? (
|
||||
<FlashList
|
||||
data={albums}
|
||||
numColumns={2}
|
||||
keyExtractor={(album) => album.identity_key}
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
renderScrollComponent={PullSearchScrollView}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.gridCell}>
|
||||
<AlbumGridItem
|
||||
album={item}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/library/album/[key]',
|
||||
params: { key: item.identity_key },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'artists' ? (
|
||||
<FlashList
|
||||
data={artists}
|
||||
keyExtractor={(artist) => artist.artist}
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
renderScrollComponent={PullSearchScrollView}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
renderItem={({ item }) => (
|
||||
<ArtistRow
|
||||
artist={item}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/library/album/[key]',
|
||||
params: { key: item.identity_key },
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: item.artist },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'artists' ? (
|
||||
<FlashList
|
||||
data={artists}
|
||||
keyExtractor={(artist) => artist.artist}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<ArtistRow
|
||||
artist={item}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: item.artist },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'tracks' ? (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.sortTrigger}
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
|
||||
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
|
||||
</Pressable>
|
||||
<FlashList
|
||||
data={sortedTracks}
|
||||
keyExtractor={(track) => String(track.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackRow
|
||||
track={item}
|
||||
active={item.path === currentPath}
|
||||
onPress={() => playAllFrom(index)}
|
||||
onLongPress={() => setActionTrack(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{viewMode === 'playlists' ? <PlaylistsView /> : null}
|
||||
{viewMode === 'tracks' ? (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.sortTrigger}
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="swap-vertical" size={14} color={colors.textSecondary} />
|
||||
<Text variant="label">{TRACK_SORT_LABELS[trackSort]}</Text>
|
||||
</Pressable>
|
||||
<FlashList
|
||||
data={sortedTracks}
|
||||
keyExtractor={(track) => String(track.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
renderScrollComponent={PullSearchScrollView}
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackRow
|
||||
track={item}
|
||||
active={item.path === currentPath}
|
||||
onPress={() => playAllFrom(index)}
|
||||
onLongPress={() => setActionTrack(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'folders' ? <FoldersView /> : null}
|
||||
</>
|
||||
)}
|
||||
{viewMode === 'playlists' ? (
|
||||
<PlaylistsView
|
||||
onScroll={scrollTop.onScroll}
|
||||
scrollEventThrottle={scrollTop.scrollEventThrottle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'folders' ? <FoldersView /> : null}
|
||||
</>
|
||||
)}
|
||||
</PullSearchGesture>
|
||||
|
||||
<TrackActionsSheet track={actionTrack} onClose={() => setActionTrack(null)} />
|
||||
<ActionSheet
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Pressable, StyleSheet, Alert } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useRouter } from 'expo-router';
|
||||
@@ -7,6 +14,7 @@ import { Text } from '@/components/Text';
|
||||
import { ActionSheet } from '@/components/sheets/ActionSheet';
|
||||
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
|
||||
import { PlaylistRow } from '@/components/library/PlaylistRow';
|
||||
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import type { Playlist } from '@/types/playlist';
|
||||
@@ -23,7 +31,13 @@ function fileDisplayName(fileUri: string): string {
|
||||
|
||||
type Prompt = { kind: 'create' } | { kind: 'rename'; playlist: Playlist } | null;
|
||||
|
||||
export function PlaylistsView() {
|
||||
export function PlaylistsView({
|
||||
onScroll,
|
||||
scrollEventThrottle,
|
||||
}: {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
scrollEventThrottle?: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const playlists = usePlaylistStore((s) => s.playlists);
|
||||
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
|
||||
@@ -125,6 +139,10 @@ export function PlaylistsView() {
|
||||
data={playlists}
|
||||
keyExtractor={(playlist) => String(playlist.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
renderScrollComponent={PullSearchScrollView}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={scrollEventThrottle}
|
||||
ListHeaderComponent={
|
||||
<PlaylistRow
|
||||
name="Favorites"
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable gesture state. */
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
ScrollView as RNScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ScrollViewProps,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
ScrollView as GestureScrollView,
|
||||
type GestureType,
|
||||
type NativeViewGestureHandlerProps,
|
||||
} from 'react-native-gesture-handler';
|
||||
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { commitHaptic, tickHaptic } from '@/lib/haptics';
|
||||
|
||||
const OPEN_THRESHOLD = 76;
|
||||
const RESET_THRESHOLD = 58;
|
||||
const MAX_PULL = 112;
|
||||
|
||||
type PullSearchGestureRef = MutableRefObject<GestureType | undefined>;
|
||||
type SimultaneousHandlers = NativeViewGestureHandlerProps['simultaneousHandlers'];
|
||||
type PullSearchScrollViewProps = ScrollViewProps & Pick<NativeViewGestureHandlerProps, 'simultaneousHandlers'>;
|
||||
type PullSearchContextValue = {
|
||||
gestureRef: PullSearchGestureRef;
|
||||
cancelIfScrolledAway: (offsetY: number) => void;
|
||||
};
|
||||
|
||||
const PullSearchGestureContext = createContext<PullSearchContextValue | null>(null);
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function mergeSimultaneousHandlers(
|
||||
existing: SimultaneousHandlers,
|
||||
contextValue: PullSearchContextValue | null
|
||||
): SimultaneousHandlers {
|
||||
if (!contextValue) return existing;
|
||||
if (!existing) return contextValue.gestureRef;
|
||||
return Array.isArray(existing)
|
||||
? [...existing, contextValue.gestureRef]
|
||||
: [existing, contextValue.gestureRef];
|
||||
}
|
||||
|
||||
export const PullSearchScrollView = forwardRef<RNScrollView, PullSearchScrollViewProps>(
|
||||
function PullSearchScrollView({ simultaneousHandlers, onScroll, ...props }, ref) {
|
||||
const pullSearchContext = useContext(PullSearchGestureContext);
|
||||
const mergedHandlers = useMemo(
|
||||
() => mergeSimultaneousHandlers(simultaneousHandlers, pullSearchContext),
|
||||
[pullSearchContext, simultaneousHandlers]
|
||||
);
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
onScroll?.(event);
|
||||
pullSearchContext?.cancelIfScrolledAway(event.nativeEvent.contentOffset.y);
|
||||
},
|
||||
[onScroll, pullSearchContext]
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureScrollView
|
||||
ref={ref}
|
||||
{...props}
|
||||
onScroll={handleScroll}
|
||||
simultaneousHandlers={mergedHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function useScrollTopGate(initialAtTop = true) {
|
||||
const atTopRef = useRef(initialAtTop);
|
||||
const [atTop, setAtTop] = useState(initialAtTop);
|
||||
|
||||
const setScrollAtTop = useCallback((next: boolean) => {
|
||||
if (next === atTopRef.current) return;
|
||||
atTopRef.current = next;
|
||||
setAtTop(next);
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setScrollAtTop(event.nativeEvent.contentOffset.y <= 2);
|
||||
},
|
||||
[setScrollAtTop]
|
||||
);
|
||||
|
||||
return { atTop, onScroll, scrollEventThrottle: 16 as const, setScrollAtTop };
|
||||
}
|
||||
|
||||
export function PullSearchGesture({
|
||||
children,
|
||||
enabled = true,
|
||||
atTop,
|
||||
onOpen,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
atTop: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const [pull, setPull] = useState(0);
|
||||
const [armed, setArmed] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const pullGestureRef = useMemo<PullSearchGestureRef>(() => ({ current: undefined }), []);
|
||||
const pullValue = useSharedValue(0);
|
||||
const armedValue = useSharedValue(false);
|
||||
const draggingValue = useSharedValue(false);
|
||||
|
||||
const resetUi = useCallback(() => {
|
||||
setArmed(false);
|
||||
setPull(0);
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => {
|
||||
commitHaptic();
|
||||
onOpen();
|
||||
resetUi();
|
||||
}, [onOpen, resetUi]);
|
||||
|
||||
const resetShared = useCallback(() => {
|
||||
runOnUI(() => {
|
||||
'worklet';
|
||||
pullValue.value = 0;
|
||||
armedValue.value = false;
|
||||
draggingValue.value = false;
|
||||
})();
|
||||
}, [armedValue, draggingValue, pullValue]);
|
||||
|
||||
const cancelIfScrolledAway = useCallback(
|
||||
(offsetY: number) => {
|
||||
if (offsetY <= 2 || !dragging) return;
|
||||
resetShared();
|
||||
resetUi();
|
||||
},
|
||||
[dragging, resetShared, resetUi]
|
||||
);
|
||||
|
||||
const contextValue = useMemo<PullSearchContextValue>(
|
||||
() => ({ gestureRef: pullGestureRef, cancelIfScrolledAway }),
|
||||
[cancelIfScrolledAway, pullGestureRef]
|
||||
);
|
||||
|
||||
const pullGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(pullGestureRef)
|
||||
.enabled(enabled && atTop)
|
||||
.activeOffsetY(10)
|
||||
.failOffsetY(-8)
|
||||
.failOffsetX([-28, 28])
|
||||
.onStart(() => {
|
||||
'worklet';
|
||||
pullValue.value = 0;
|
||||
armedValue.value = false;
|
||||
draggingValue.value = true;
|
||||
runOnJS(setPull)(0);
|
||||
runOnJS(setArmed)(false);
|
||||
runOnJS(setDragging)(true);
|
||||
})
|
||||
.onChange((event) => {
|
||||
'worklet';
|
||||
const nextPull = Math.max(0, Math.min(MAX_PULL, pullValue.value + event.changeY));
|
||||
pullValue.value = nextPull;
|
||||
runOnJS(setPull)(nextPull);
|
||||
|
||||
if (!armedValue.value && nextPull >= OPEN_THRESHOLD) {
|
||||
armedValue.value = true;
|
||||
runOnJS(setArmed)(true);
|
||||
runOnJS(tickHaptic)();
|
||||
} else if (armedValue.value && nextPull < RESET_THRESHOLD) {
|
||||
armedValue.value = false;
|
||||
runOnJS(setArmed)(false);
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
'worklet';
|
||||
const finalPull = pullValue.value;
|
||||
pullValue.value = 0;
|
||||
armedValue.value = false;
|
||||
draggingValue.value = false;
|
||||
|
||||
if (finalPull >= OPEN_THRESHOLD || (finalPull > 44 && event.velocityY > 1250)) {
|
||||
runOnJS(open)();
|
||||
return;
|
||||
}
|
||||
runOnJS(resetUi)();
|
||||
})
|
||||
.onFinalize((_event, success) => {
|
||||
'worklet';
|
||||
if (success) return;
|
||||
pullValue.value = 0;
|
||||
armedValue.value = false;
|
||||
draggingValue.value = false;
|
||||
runOnJS(resetUi)();
|
||||
}),
|
||||
[
|
||||
armedValue,
|
||||
atTop,
|
||||
draggingValue,
|
||||
enabled,
|
||||
open,
|
||||
pullGestureRef,
|
||||
pullValue,
|
||||
resetUi,
|
||||
]
|
||||
);
|
||||
|
||||
const progress = clamp(pull / OPEN_THRESHOLD, 0, 1);
|
||||
const indicatorStyle = {
|
||||
opacity: pull <= 0 ? 0 : Math.max(0.72, progress),
|
||||
transform: [
|
||||
{ translateY: -18 + (clamp(pull, 0, MAX_PULL) / MAX_PULL) * 38 },
|
||||
{ scale: armed ? 1.03 : 0.94 + progress * 0.06 },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<PullSearchGestureContext.Provider value={contextValue}>
|
||||
<GestureDetector gesture={pullGesture}>
|
||||
<View collapsable={false} style={styles.root}>
|
||||
{children}
|
||||
{dragging ? (
|
||||
<View pointerEvents="none" style={[styles.indicator, indicatorStyle]}>
|
||||
<Ionicons name="search" size={16} color={armed ? colors.accentTextStrong : colors.textSecondary} />
|
||||
<Text variant="label" color={armed ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{armed ? 'Release' : 'Search'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
</PullSearchGestureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: spacing.xs,
|
||||
alignSelf: 'center',
|
||||
zIndex: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 10,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user