react-native-gesture-handler#FlatList TypeScript Examples
The following examples show how to use
react-native-gesture-handler#FlatList.
You can vote up the ones you like or vote down the ones you don't like,
and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: Carousel.tsx From react-native-scroll-bottom-sheet with MIT License | 6 votes |
Carousel: React.FC<{ index: number }> = React.memo(
({ index }) => {
const renderItem = React.useCallback(() => <CarouselItem />, []);
return (
<View style={[styles.row, { borderTopWidth: index === 0 ? 0 : 1 }]}>
<Text style={styles.title}>{`Popular in ${Faker.address.city()}`}</Text>
<FlatList
contentContainerStyle={{ paddingHorizontal: 8 }}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
data={Array.from({ length: 10 }).map((_, i) => String(i))}
horizontal
keyExtractor={j => `row-${index}-item-${j}`}
renderItem={renderItem}
/>
</View>
);
},
() => true
)
Example #2
Source File: ChatPage.tsx From GiveNGo with MIT License | 5 votes |
export default function ChatPage({ navigation }: any) {
const renderItemAccessory = () => (
<Button size="tiny" status="primary">
View
</Button>
);
const RightActions = () => {
return (
<View
style={{ flex: 1, backgroundColor: 'red', justifyContent: 'center' }}
>
<Text
style={{
color: 'white',
paddingHorizontal: 300,
fontWeight: '600',
}}
>
Delete?
</Text>
</View>
);
};
const renderItem = ({ item, index }: any) => (
<Swipeable renderRightActions={RightActions}>
<ListItem
style={styles.items}
description={`${item.task}`}
accessoryLeft={ProfileIcon}
accessoryRight={renderItemAccessory}
onPress={() => navigation.navigate('Chat Feed')}
/>
</Swipeable>
);
return (
<React.Fragment>
<Layout
style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}
level="3"
>
<View>
<Text style={styles.title} category="h2">
Messages
</Text>
</View>
<Divider />
<FlatList
style={styles.container}
data={flatListData}
renderItem={renderItem}
keyExtractor={(item, index) => index.toString()}
showsHorizontalScrollIndicator={false}
/>
</Layout>
</React.Fragment>
);
}
Example #3
Source File: Explore.tsx From online-groceries-app with MIT License | 5 votes |
ExploreTab = ({navigation}: ExploreTabProps) => {
const ui_array = [
{id: 0},
{id: 1},
{id: 2},
{id: 3},
{id: 4},
{id: 5},
{id: 6},
{id: 7},
];
return (
<ScrollView style={styles.container}>
<Header title="Find Products" />
<View style={styles.searchBarBox}>
<SearchBar navigation={navigation} navigateTo="" />
</View>
<View style={styles.body}>
<FlatList
data={ui_array}
keyExtractor={(item) => item.id}
scrollEnabled={true}
numColumns={2}
renderItem={({item}) => {
return (
<CategoryCard
key={item.id}
bgColour="#F00"
borderColour="#0F0"
title="Teste"
image={ImageTest}
onPress={() => null}
/>
);
}}
/>
</View>
<View style={styles.scrollFooter} />
</ScrollView>
);
}
Example #4
Source File: StickyItemFlatList.tsx From react-native-sticky-item with MIT License | 5 votes |
AnimatedFlatList = Animated.createAnimatedComponent(FlatList)
Example #5
Source File: CoachingSettings.tsx From nyxo-app with GNU General Public License v3.0 | 5 votes |
CoachingSettings: FC = () => {
const {
data: months,
isLoading,
refetch: refetchCoaching
} = useListCoaching()
const {
data: activeMonth,
refetch: refetchActiveMonth
} = useGetActiveCoaching()
const renderItem: ListRenderItem<CoachingPeriod> = ({ item }) => (
<CoachingMonthCard key={`${item?.id}`} month={item} />
)
const data = months?.filter((m) => m?.id !== activeMonth?.id)
const refresh = () => {
refetchCoaching()
refetchActiveMonth()
}
return (
<SafeAreaView>
<FlatList
refreshControl={
<ThemedRefreshControl refreshing={isLoading} onRefresh={refresh} />
}
ListHeaderComponent={() => (
<>
<GoBackContainer>
<GoBack route={'Settings'} />
</GoBackContainer>
<Container>
<H2>COACHING.SETTINGS.TITLE</H2>
<H4>COACHING.SETTINGS.ACTIVE</H4>
</Container>
{activeMonth ? (
<CoachingMonthCard actionsEnabled={false} month={activeMonth} />
) : null}
<Container>
<H4>COACHING.SETTINGS.ALL</H4>
</Container>
</>
)}
data={data}
renderItem={renderItem}
/>
</SafeAreaView>
)
}
Example #6
Source File: ListView.tsx From rn-clean-architecture-template with MIT License | 5 votes |
ListView: ListViewFC = (props) => {
const {
refreshing,
ListFooterComponent,
data,
isLoadingMore,
LoadingComponent,
} = props;
const refreshIndicatorVisible =
refreshing === true && (data?.length ?? 0) > 0;
const skeletonDisplayable =
(refreshing && data?.length === 0) || isLoadingMore;
const emptyItem = () => {
if (refreshing) {
return null;
}
return <EmptyListView {...props.emptyListViewProps} />;
};
const footer = () => {
if (skeletonDisplayable) {
if (LoadingComponent) {
return LoadingComponent;
}
return (
<>
<SkeletonLoadingItem />
<SkeletonLoadingItem />
<SkeletonLoadingItem />
</>
);
}
return ListFooterComponent;
};
return (
<View style={[styles.container]}>
<FlatList
{...props}
ListEmptyComponent={emptyItem()}
ListFooterComponent={footer()}
refreshControl={
<RefreshControl
refreshing={refreshIndicatorVisible}
onRefresh={props.onRefresh}
/>
}
style={styles.list}
/>
</View>
);
}
Example #7
Source File: news.tsx From THUInfo with MIT License | 4 votes |
NewsScreen = ({navigation}: {navigation: RootNav}) => {
const [newsList, setNewsList] = useState<NewsSlice[]>([]);
const [refreshing, setRefreshing] = useState(true);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [inSearchMode, setInSearchMode] = useState(false);
const [searchKey, setSearchKey] = useState("");
const [channel, setChannel] = useState<SourceTag | undefined>();
const [fetchedAll, setFetchedAll] = useState(false);
const themeName = useColorScheme();
const theme = themes(themeName);
const style = styles(themeName);
const fetchNewsList = (
request: boolean = true,
searchMode: boolean | undefined = undefined,
) => {
setRefreshing(true);
setLoading(true);
if (request) {
setNewsList([]);
setPage(1);
setFetchedAll(false);
if (searchMode === undefined) {
setInSearchMode(false);
setSearchKey("");
} else {
setInSearchMode(searchMode);
}
} else {
if (fetchedAll) {
setRefreshing(false);
setLoading(false);
return;
}
setPage((p) => p + 1);
}
(searchMode === true ||
(searchMode === undefined && !request && inSearchMode)
? helper.searchNewsList(request ? 1 : page + 1, searchKey, channel)
: helper.getNewsList(request ? 1 : page + 1, 30, channel)
)
.then((res) => {
if (res.length === 0) {
setFetchedAll(true);
} else {
setNewsList((o) => o.concat(res));
}
})
.catch(() => {
Snackbar.show({
text: getStr("networkRetry"),
duration: Snackbar.LENGTH_LONG,
});
})
.then(() => {
setRefreshing(false);
setLoading(false);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(fetchNewsList, [channel]);
let screenHeight = Dimensions.get("window");
const flatListRef = React.useRef(null);
return (
<View style={{marginHorizontal: 12}}>
<ScrollView
style={{margin: 6}}
showsHorizontalScrollIndicator={false}
horizontal={true}>
<ChannelTag
channel={undefined}
selected={channel === undefined}
onPress={() => setChannel(undefined)}
/>
{sourceTags.map((tag) => (
<ChannelTag
key={tag}
channel={tag}
selected={channel === tag}
onPress={() => setChannel(tag)}
/>
))}
</ScrollView>
<FlatList
ref={flatListRef}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={fetchNewsList}
colors={[theme.colors.accent]}
/>
}
ListHeaderComponent={
<View style={{flexDirection: "row"}}>
<TextInput
value={searchKey}
onChangeText={setSearchKey}
style={{
flex: 3,
marginLeft: 12,
textAlignVertical: "center",
fontSize: 15,
paddingHorizontal: 12,
backgroundColor: theme.colors.themeBackground,
color: theme.colors.text,
borderColor: "#CCC",
borderWidth: 1,
borderRadius: 5,
}}
placeholder={getStr("searchNewsPrompt")}
/>
<SettingsLargeButton
text={getStr("search")}
onPress={() => {
fetchNewsList(true, searchKey !== "");
}}
disabled={refreshing || loading}
redText={false}
/>
</View>
}
ListEmptyComponent={
<View
style={{
margin: 15,
height: screenHeight.height * 0.6,
justifyContent: "center",
alignItems: "center",
}}>
<Text
style={{
fontSize: 18,
fontWeight: "bold",
alignSelf: "center",
margin: 5,
color: theme.colors.text,
}}>
{getStr("waitForLoading")}
</Text>
</View>
}
data={newsList}
keyExtractor={(item) => item.url}
renderItem={({item}) => (
<View style={style.newsSliceContainer}>
<TouchableOpacity
onPress={() => navigation.navigate("NewsDetail", {detail: item})}>
<Text
numberOfLines={2}
style={{
fontSize: 16,
fontWeight: "bold",
margin: 5,
lineHeight: 20,
color: theme.colors.text,
}}>
{item.name.trim()}
</Text>
<View
style={{margin: 5, flexDirection: "row", alignItems: "center"}}>
{item.source.length > 0 && (
<>
<Text
style={{fontWeight: "bold", color: theme.colors.text}}>
{item.source}
</Text>
<View
style={{
marginHorizontal: 6,
height: 12,
width: 4,
borderRadius: 2,
backgroundColor: theme.colors.accent,
}}
/>
</>
)}
<Text style={{fontWeight: "bold", color: theme.colors.text}}>
{getStr(item.channel)}
</Text>
</View>
<Text style={{color: "gray", margin: 5}}>
{item.date}
{item.topped && (
<Text style={{color: "red"}}>
{" "}
{getStr("topped")}
</Text>
)}
</Text>
</TouchableOpacity>
</View>
)}
onEndReached={() => fetchNewsList(false)}
onEndReachedThreshold={0.6}
ListFooterComponent={
loading && newsList.length !== 0 ? (
<View style={style.footerContainer}>
<ActivityIndicator size="small" />
<Text style={{margin: 10, color: theme.colors.text}}>
{getStr("loading")}
</Text>
</View>
) : null
}
/>
</View>
);
}
Example #8
Source File: Settings.tsx From BitcoinWalletMobile with MIT License | 4 votes |
Settings: React.FC<Props> = (props) => {
const [recoveryWordsModalVisible, setrecoveryWordsModalVisible] = useState(false)
const [exitModalVisible, setExitModalVisible] = useState(false)
const languageSelector = (state: WalletState) => state.language
const language = useSelector(languageSelector)
const currencySelector = (state: WalletState) => state.currency
const currency = useSelector(currencySelector)
const data = [getTranslated(language).currency, getTranslated(language).language, getTranslated(language).seed_phrase, "Support multiple devices", getTranslated(language).exit_wallet]
const clearAndDelete = async () => {
setExitModalVisible(false)
store.dispatch(clearWallet())
}
const showRecoveryWordsModal = () => {
setrecoveryWordsModalVisible(true)
}
const hideRecoveryWordsModal = () => {
setrecoveryWordsModalVisible(false)
}
const showExitModal = () => {
setExitModalVisible(true)
}
const hideExitModal = () => {
setExitModalVisible(false)
}
const pushCurrency = () => {
// @ts-ignore
props.navigation.navigate('PickerView', { type: "Choose Currency" })
}
const pushLanguage = () => {
// @ts-ignore
props.navigation.navigate('PickerView', { type: "Choose Language" })
}
interface ChildProps {
item: string
}
const conditonalView: React.FC<ChildProps> = ({ item }) => {
if (item == getTranslated(language).currency) {
return <View style={{ marginTop: 20 }}><SettingsItem label={getTranslated(language).currency} subLabel={currency} onClick={pushCurrency} /></View>
}
if (item == getTranslated(language).seed_phrase) {
return <View style={{ marginTop: 50 }}><SettingsItem label={getTranslated(language).seed_phrase} subLabel="" onClick={showRecoveryWordsModal} /></View>
}
if (item == getTranslated(language).exit_wallet) {
return <View style={{ marginTop: 50 }}><SettingsItem label={getTranslated(language).exit_wallet} subLabel="" onClick={showExitModal} /></View>
}
if (item == "Support multiple devices") {
return <SettingsItem label="Support multiple devices" subLabel="" onClick={() => { }} />
}
else {
return <SettingsItem label={getTranslated(language).language} subLabel={getLanguageBigName(language)} onClick={pushLanguage} />
}
}
const renderItem: ListRenderItem<string> = ({ item }) => (
conditonalView({ item: item })
)
return (
<View style={styles.container}>
<Header screen={getTranslated(language).settings} />
<Screen>
<View style={styles.content}>
<FlatList data={data}
renderItem={renderItem}
keyExtractor={item => item} />
<RecoveryWordsModal isVisible={recoveryWordsModalVisible} hideModal={hideRecoveryWordsModal} />
<ExitWalletModal isVisible={exitModalVisible} hideModal={hideExitModal} deleteCallback={clearAndDelete} />
</View>
</Screen>
</View>
);
}
Example #9
Source File: StickyItemFlatList.tsx From react-native-sticky-item with MIT License | 4 votes |
StickyItemFlatList = forwardRef( <T extends {}>(props: StickyItemFlatListProps<T>, ref: Ref<FlatList<T>>) => { const { initialScrollIndex = 0, decelerationRate = DEFAULT_DECELERATION_RATE, itemWidth, itemHeight, separatorSize = DEFAULT_SEPARATOR_SIZE, borderRadius = DEFAULT_BORDER_RADIUS, stickyItemActiveOpacity = DEFAULT_STICKY_ITEM_ACTIVE_OPACITY, stickyItemWidth, stickyItemHeight, stickyItemBackgroundColors, stickyItemContent, onStickyItemPress, isRTL = DEFAULT_IS_RTL, ItemSeparatorComponent = Separator, ...rest } = props; // refs const flatListRef = useRef<FlatList<T>>(null); const tapRef = useRef<TapGestureHandler>(null); //#region variables const itemWidthWithSeparator = useMemo( () => itemWidth + separatorSize, [itemWidth, separatorSize] ); const separatorProps = useMemo( () => ({ size: separatorSize, }), [separatorSize] ); //#endregion //#region styles const contentContainerStyle = useMemo( () => [ rest.contentContainerStyle, { paddingLeft: itemWidth + separatorSize * 2, paddingRight: separatorSize, }, ], [rest.contentContainerStyle, itemWidth, separatorSize] ); //#endregion //#region methods const getHitSlop = useCallback( isMinimized => { const verticalPosition = isMinimized ? -((itemHeight - stickyItemHeight) / 2) : 0; const startPosition = isMinimized ? 0 : -separatorSize; const endPosition = isMinimized ? -(SCREEN_WIDTH - stickyItemWidth) : -(SCREEN_WIDTH - separatorSize - itemWidth); return { top: verticalPosition, right: isRTL ? startPosition : endPosition, left: isRTL ? endPosition : startPosition, bottom: verticalPosition, }; }, [ itemWidth, itemHeight, stickyItemWidth, stickyItemHeight, separatorSize, isRTL, ] ); const getItemLayout = useCallback( (_, index) => { return { length: itemWidthWithSeparator, // sticky item + previous items width offset: itemWidthWithSeparator + itemWidthWithSeparator * index, index, }; }, [itemWidthWithSeparator] ); //#endregion //#region gesture const x = useValue(0); const tapState = useValue(State.UNDETERMINED); const tapGestures = useGestureHandler({ state: tapState }); const onScroll = event([ { nativeEvent: { contentOffset: { x, }, }, }, ]); const onScrollEnd = event([ { nativeEvent: { contentOffset: { x, }, }, }, ]); //#endregion //#region effects //@ts-ignore useImperativeHandle(ref, () => flatListRef.current!.getNode()); useCode( () => cond(eq(tapState, State.END), [ call([tapState], () => { if (onStickyItemPress) { onStickyItemPress(); } }), set(tapState, State.UNDETERMINED), ]), [tapState] ); useCode( () => onChange( x, call([x], args => { if (tapRef.current) { const isMinimized = args[0] > 0; // @ts-ignore tapRef.current.setNativeProps({ hitSlop: getHitSlop(isMinimized), }); } }) ), [ x, itemWidth, itemHeight, stickyItemWidth, stickyItemWidth, separatorSize, ] ); useEffect(() => { /** * @DEV * to fix stick item position with fast refresh */ x.setValue(0); if (tapRef.current) { // @ts-ignore tapRef.current.setNativeProps({ hitSlop: getHitSlop(initialScrollIndex !== 0), }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [getHitSlop]); //#endregion // render const renderSeparator = useCallback(() => { if (typeof ItemSeparatorComponent === 'function') { // @ts-ignore return ItemSeparatorComponent(separatorProps); } else { // @ts-ignore return <ItemSeparatorComponent size={separatorProps.size} />; } }, [ItemSeparatorComponent, separatorProps]); return ( <TapGestureHandler ref={tapRef} waitFor={flatListRef} shouldCancelWhenOutside={true} {...tapGestures} > <Animated.View> <AnimatedFlatList {...rest} ref={flatListRef} initialScrollIndex={initialScrollIndex} inverted={isRTL} ItemSeparatorComponent={renderSeparator} contentContainerStyle={contentContainerStyle} horizontal={true} showsHorizontalScrollIndicator={false} scrollEventThrottle={1} pagingEnabled={true} decelerationRate={decelerationRate} snapToAlignment={'start'} snapToInterval={itemWidth + separatorSize} onScroll={onScroll} onScrollAnimationEnd={onScrollEnd} getItemLayout={getItemLayout} /> <StickyItem x={x} tapState={tapState} itemWidth={itemWidth} itemHeight={itemHeight} separatorSize={separatorSize} borderRadius={borderRadius} stickyItemActiveOpacity={stickyItemActiveOpacity} stickyItemWidth={stickyItemWidth} stickyItemHeight={stickyItemHeight} stickyItemBackgroundColors={stickyItemBackgroundColors} stickyItemContent={stickyItemContent} isRTL={isRTL} /> </Animated.View> </TapGestureHandler> ); } )
Example #10
Source File: index.tsx From jellyfin-audio-player with MIT License | 4 votes |
function Downloads() {
const defaultStyles = useDefaultStyles();
const dispatch = useAppDispatch();
const getImage = useGetImage();
const { entities, ids } = useTypedSelector((state) => state.downloads);
const tracks = useTypedSelector((state) => state.music.tracks.entities);
// Calculate the total download size
const totalDownloadSize = useMemo(() => (
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
), [ids, entities]);
/**
* Handlers for actions in this components
*/
// Delete a single downloaded track
const handleDelete = useCallback((id: EntityId) => {
dispatch(removeDownloadedTrack(id));
}, [dispatch]);
// Delete all downloaded tracks
const handleDeleteAllTracks = useCallback(() => ids.forEach(handleDelete), [handleDelete, ids]);
// Retry a single failed track
const retryTrack = useCallback((id: EntityId) => {
dispatch(queueTrackForDownload(id));
}, [dispatch]);
// Retry all failed tracks
const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
const handleRetryFailed = useCallback(() => (
failedIds.forEach(retryTrack)
), [failedIds, retryTrack]);
/**
* Render section
*/
const ListHeaderComponent = useMemo(() => (
<View style={[{ paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 0.5 }, defaultStyles.border]}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={[
defaultStyles.textHalfOpacity,
{ marginRight: 8, flex: 1, fontSize: 12 },
]}
numberOfLines={1}
>
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
</Text>
<Button
icon={TrashIcon}
title={t('delete-all-tracks')}
onPress={handleDeleteAllTracks}
disabled={!ids.length}
size="small"
/>
</View>
{failedIds.length > 0 && (
<Button
icon={ArrowClockwise}
title={t('retry-failed-downloads')}
onPress={handleRetryFailed}
disabled={failedIds.length === 0}
style={{ marginTop: 4 }}
/>
)}
</View>
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
<DownloadedTrack>
<View style={{ marginRight: 12 }}>
<ShadowWrapper size="small">
<AlbumImage source={{ uri: getImage(item as string) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
</View>
<View style={{ flexShrink: 1, marginRight: 8 }}>
<Text style={[{ fontSize: 16, marginBottom: 4 }, defaultStyles.text]} numberOfLines={1}>
{tracks[item]?.Name}
</Text>
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
{tracks[item]?.AlbumArtist} {tracks[item]?.Album ? `— ${tracks[item]?.Album}` : ''}
</Text>
</View>
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
{entities[item]?.isComplete && entities[item]?.size ? (
<Text style={[defaultStyles.textQuarterOpacity, { marginRight: 12, fontSize: 12 }]}>
{formatBytes(entities[item]?.size || 0)}
</Text>
) : null}
<View style={{ marginRight: 12 }}>
<DownloadIcon trackId={item} />
</View>
<Button onPress={() => handleDelete(item)} size="small" icon={TrashIcon} />
{!entities[item]?.isComplete && (
<Button onPress={() => retryTrack(item)} size="small" icon={ArrowClockwise} style={{ marginLeft: 4 }} />
)}
</View>
</DownloadedTrack>
), [entities, retryTrack, handleDelete, defaultStyles, tracks, getImage]);
// If no tracks have been downloaded, show a short message describing this
if (!ids.length) {
return (
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}>
{t('no-downloads')}
</Text>
</View>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
{ListHeaderComponent}
<FlatList
data={ids}
style={{ flex: 1, paddingTop: 12 }}
contentContainerStyle={{ flexGrow: 1 }}
renderItem={renderItem}
/>
</SafeAreaView>
);
}
Example #11
Source File: index.tsx From jellyfin-audio-player with MIT License | 4 votes |
export default function Search() {
const defaultStyles = useDefaultStyles();
// Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setLoading] = useState(false);
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const albums = useTypedSelector(state => state.music.albums.entities);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<MusicNavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* Since it is impractical to have a global fuse variable, we need to
* instantiate it for thsi function. With this effect, we generate a new
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
setFuseReady(true);
}, [albums, setFuseReady]);
/**
* This function retrieves search results from Jellyfin. It is a seperate
* callback, so that we can make sure it is properly debounced and doesn't
* cause execessive jank in the interface.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
// First, query the Jellyfin API
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
// Convert the current results to album ids
const albumIds = currentResults.map(item => item.id);
// Parse the result in correct typescript form
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
// Filter any results that are already displayed
const items = results.filter(item => (
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
// Then convert the results to proper result form
)).map((item) => ({
type: item.Type,
id: item.Id,
album: item.Type === 'Audio'
? item.AlbumId
: undefined,
name: item.Type === 'Audio'
? item.Name
: undefined,
}));
// Lastly, we'll merge the two and assign them to the state
setJellyfinResults([...items] as CombinedResults);
// Loading is now complete
setLoading(false);
}, 50), [dispatch, setJellyfinResults]);
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
useEffect(() => {
if (!searchTerm) {
return;
}
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
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
setFuseResults(albums);
setLoading(true);
try {
// Wrap the call in a try/catch block so that we catch any
// network issues in search and just use local search if the
// network is unavailable
fetchJellyfinResults(searchTerm, albums);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
};
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers
const selectAlbum = useCallback((id: string) =>
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
);
const HeaderComponent = React.useMemo(() => (
<View>
<Container style={[
defaultStyles.border,
defaultStyles.view,
{ transform: [{ translateY: keyboardHeight }]},
]}>
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.input, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
icon
/>
<SearchIndicator width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />
{isLoading && <Loading><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
if (!fuseIsReady) {
return null;
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
style={{ flex: 2 }}
data={[...jellyfinResults, ...fuseResults]}
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
// cannot display it.
if (!album) {
return null;
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum}>
<SearchResult>
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
</Text>
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')} • ${album.AlbumArtist}`
: `${t('track')} • ${album.AlbumArtist} — ${album.Name}`
}
</HalfOpacity>
</View>
<View style={{ marginLeft: 16 }}>
<DownloadIcon trackId={id} />
</View>
<View style={{ marginLeft: 16 }}>
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
</View>
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
{HeaderComponent}
</SafeAreaView>
);
}