fix: search container height when keyboard is open

This commit is contained in:
Lei Nelissen
2025-03-05 22:48:35 +01:00
parent c2d7d81de8
commit 0b90f6f7f0

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import Input from '@/components/Input'; import Input from '@/components/Input';
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native'; import { ActivityIndicator, Animated, KeyboardAvoidingView, SafeAreaView, View } from 'react-native';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from '@/store'; import { useAppDispatch, useTypedSelector } from '@/store';
import Fuse, { IFuseOptions } from 'fuse.js'; import Fuse, { IFuseOptions } from 'fuse.js';
@@ -18,7 +18,6 @@ import DownloadIcon from '@/components/DownloadIcon';
import ChevronRight from '@/assets/icons/chevron-right.svg'; import ChevronRight from '@/assets/icons/chevron-right.svg';
import SearchIcon from '@/assets/icons/magnifying-glass.svg'; import SearchIcon from '@/assets/icons/magnifying-glass.svg';
import { ShadowWrapper } from '@/components/Shadow'; import { ShadowWrapper } from '@/components/Shadow';
import { useKeyboardHeight } from '@/utility/useKeyboardHeight';
import { NavigationProp } from '@/screens/types'; import { NavigationProp } from '@/screens/types';
import { useNavigationOffsets } from '@/components/SafeNavigatorView'; import { useNavigationOffsets } from '@/components/SafeNavigatorView';
import BaseAlbumImage from '@/screens/Music/stacks/components/AlbumImage'; import BaseAlbumImage from '@/screens/Music/stacks/components/AlbumImage';
@@ -101,17 +100,14 @@ export default function Search() {
const offsets = useNavigationOffsets({ includeOverlay: false }); const offsets = useNavigationOffsets({ includeOverlay: false });
// Prepare state for fuse and albums // Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
const [fuseResults, setFuseResults] = useState<CombinedResults>([]); const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]); const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const albums = useTypedSelector(state => state.music.albums.entities); const albums = useTypedSelector(state => state.music.albums.entities);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers // Prepare helpers
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage(); const getImage = useGetImage();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -122,10 +118,9 @@ export default function Search() {
* more intelligently by removing and adding the changed albums, but this is * more intelligently by removing and adding the changed albums, but this is
* an open todo. * an open todo.
*/ */
useEffect(() => { const fuse = useMemo(() => (
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions); new Fuse(Object.values(albums) as Album[], fuseOptions)
setFuseReady(true); ), [albums]);
}, [albums, setFuseReady]);
/** /**
* This function retrieves search results from Jellyfin. It is a seperate * This function retrieves search results from Jellyfin. It is a seperate
@@ -175,14 +170,8 @@ export default function Search() {
} }
const retrieveResults = async () => { const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse // First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm); const fuseResults = fuse.search(searchTerm);
const albums: AlbumResult[] = fuseResults const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({ .map(({ item }) => ({
id: item.Id, id: item.Id,
@@ -214,10 +203,7 @@ export default function Search() {
}, [navigation, albums]); }, [navigation, albums]);
const SearchInput = React.useMemo(() => ( const SearchInput = React.useMemo(() => (
<Animated.View style={[ <Animated.View>
{ position: 'absolute', bottom: offsets.bottom, right: 0, left: 0 },
{ transform: [{ translateY: keyboardHeight }] },
]}>
<ColoredBlurView> <ColoredBlurView>
<Container style={[ defaultStyles.border ]}> <Container style={[ defaultStyles.border ]}>
<View> <View>
@@ -269,69 +255,70 @@ export default function Search() {
</ScrollView> */} </ScrollView> */}
</ColoredBlurView> </ColoredBlurView>
</Animated.View> </Animated.View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight, offsets]); ), [searchTerm, setSearchTerm, defaultStyles, isLoading]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results. const searchResults = useMemo(() => ([
// Therefore we delay rendering to when we are certain it's there. ...fuseResults,
if (!fuseIsReady) { ...jellyfinResults,
return null; ]), [fuseResults, jellyfinResults]);
}
return ( return (
<SafeAreaView style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1, marginBottom: offsets.bottom }}>
<FlatList <KeyboardAvoidingView behavior="height" style={{ flex: 1 }}>
keyboardShouldPersistTaps="handled" <FlatList
style={{ flex: 2, }} keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: offsets.bottom + SEARCH_INPUT_HEIGHT }} style={{ flex: 2, }}
scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }} contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: SEARCH_INPUT_HEIGHT }}
data={[...jellyfinResults, ...fuseResults]} scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => { data={searchResults}
const album = albums[trackAlbum || id]; renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
// GUARD: If the album cannot be found in the store, we // GUARD: If the album cannot be found in the store, we
// cannot display it. // cannot display it.
if (!album) { if (!album) {
return null; return null;
} }
return ( return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}> <TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
<SearchResult> <SearchResult>
<ShadowWrapper> <ShadowWrapper>
<AlbumImage source={{ uri: getImage(album) }} style={defaultStyles.imageBackground} /> <AlbumImage source={{ uri: getImage(album) }} style={defaultStyles.imageBackground} />
</ShadowWrapper> </ShadowWrapper>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text numberOfLines={1}> <Text numberOfLines={1}>
{trackName || album.Name} {trackName || album.Name}
</Text> </Text>
{(album.AlbumArtist || album.Name) && ( {(album.AlbumArtist || album.Name) && (
<HalfOpacity style={defaultStyles.text} numberOfLines={1}> <HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist' {type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}` ? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}` : `${t('track')}${album.AlbumArtist}${album.Name}`
} }
</HalfOpacity> </HalfOpacity>
)} )}
</View> </View>
<View style={{ marginLeft: 16 }}> <View style={{ marginLeft: 16 }}>
<DownloadIcon trackId={id} /> <DownloadIcon trackId={id} />
</View> </View>
<View style={{ marginLeft: 16 }}> <View style={{ marginLeft: 16 }}>
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} /> <ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
</View> </View>
</SearchResult> </SearchResult>
</TouchableHandler> </TouchableHandler>
); );
}} }}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]} extraData={[searchTerm, albums]}
/> />
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? ( {(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer> <FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text> <Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer> </FullSizeContainer>
) : null} ) : null}
{SearchInput} {SearchInput}
</KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );
} }