Appearance
收藏、搜索功能
在完成了新闻列表和详情页的开发后,我们需要添加收藏和搜索功能,提升用户体验。在这一节中,我们将学习如何实现这两个功能。
1. 收藏功能
1.1 创建收藏状态管理
首先,让我们创建一个收藏状态管理的 Redux slice:
js
// src/redux/slices/favoritesSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import AsyncStorage from '@react-native-async-storage/async-storage';
const FAVORITES_KEY = 'news_app_favorites';
// 异步加载收藏列表
export const loadFavorites = createAsyncThunk(
'favorites/loadFavorites',
async (_, { rejectWithValue }) => {
try {
const favoritesStr = await AsyncStorage.getItem(FAVORITES_KEY);
return favoritesStr ? JSON.parse(favoritesStr) : [];
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 异步保存收藏列表
const saveFavoritesToStorage = async (favorites) => {
try {
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
} catch (error) {
console.error('保存收藏失败:', error);
}
};
const favoritesSlice = createSlice({
name: 'favorites',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {
addFavorite: (state, action) => {
const existingIndex = state.items.findIndex(item => item.url === action.payload.url);
if (existingIndex === -1) {
state.items.push(action.payload);
saveFavoritesToStorage(state.items);
}
},
removeFavorite: (state, action) => {
state.items = state.items.filter(item => item.url !== action.payload.url);
saveFavoritesToStorage(state.items);
},
clearFavorites: (state) => {
state.items = [];
saveFavoritesToStorage(state.items);
},
},
extraReducers: (builder) => {
builder
.addCase(loadFavorites.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loadFavorites.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(loadFavorites.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { addFavorite, removeFavorite, clearFavorites } = favoritesSlice.actions;
// 选择器
export const selectFavorites = (state) => state.favorites.items;
export const selectIsFavorite = (url) => (state) =>
state.favorites.items.some(item => item.url === url);
export default favoritesSlice.reducer;1.2 更新 store.js
js
// src/redux/store.js
import { configureStore } from '@reduxjs/toolkit';
import newsReducer from './slices/newsSlice';
import searchReducer from './slices/searchSlice';
import favoritesReducer from './slices/favoritesSlice';
export const store = configureStore({
reducer: {
news: newsReducer,
search: searchReducer,
favorites: favoritesReducer,
},
});
export default store;1.3 更新新闻详情页,添加收藏按钮
js
// src/screens/NewsDetailScreen.js
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, Image, TouchableOpacity, Share, Linking } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useDispatch, useSelector } from 'react-redux';
import { addFavorite, removeFavorite, selectIsFavorite } from '../redux/slices/favoritesSlice';
const NewsDetailScreen = () => {
const route = useRoute();
const navigation = useNavigation();
const dispatch = useDispatch();
const { news } = route.params;
const isFavorite = useSelector(selectIsFavorite(news.url));
const handleShare = async () => {
try {
await Share.share({
message: `${news.title}\n\n${news.description}\n\n阅读全文: ${news.url}`,
url: news.url,
});
} catch (error) {
console.error('分享失败:', error);
}
};
const handleOpenUrl = async () => {
try {
const supported = await Linking.canOpenURL(news.url);
if (supported) {
await Linking.openURL(news.url);
} else {
console.error('无法打开链接:', news.url);
}
} catch (error) {
console.error('打开链接失败:', error);
}
};
const handleFavorite = () => {
if (isFavorite) {
dispatch(removeFavorite(news));
} else {
dispatch(addFavorite(news));
}
};
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Ionicons name="arrow-back" size={24} color="#333" />
</TouchableOpacity>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.favoriteButton}
onPress={handleFavorite}
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={24}
color={isFavorite ? "#FF3B30" : "#333"}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.shareButton}
onPress={handleShare}
>
<Ionicons name="share-outline" size={24} color="#333" />
</TouchableOpacity>
</View>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{news.urlToImage && (
<Image
source={{ uri: news.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
)}
<View style={styles.newsContent}>
<Text style={styles.title}>{news.title}</Text>
<View style={styles.metaInfo}>
<Text style={styles.source}>{news.sourceName}</Text>
<Text style={styles.date}>{news.formattedDate}</Text>
</View>
{news.author && (
<Text style={styles.author}>作者: {news.author}</Text>
)}
<Text style={styles.description}>{news.description}</Text>
<Text style={styles.contentText}>{news.content || '阅读全文请点击下方链接'}</Text>
<TouchableOpacity
style={styles.readMoreButton}
onPress={handleOpenUrl}
>
<Text style={styles.readMoreText}>阅读全文</Text>
<Ionicons name="arrow-forward" size={16} color="#007AFF" />
</TouchableOpacity>
</View>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
backButton: {
padding: 8,
},
headerButtons: {
flexDirection: 'row',
},
favoriteButton: {
padding: 8,
marginRight: 8,
},
shareButton: {
padding: 8,
},
content: {
flex: 1,
},
image: {
width: '100%',
height: 250,
},
newsContent: {
padding: 16,
},
title: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 12,
color: '#333',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
source: {
fontSize: 14,
color: '#007AFF',
fontWeight: '500',
},
date: {
fontSize: 14,
color: '#999',
},
author: {
fontSize: 14,
color: '#666',
marginBottom: 16,
},
description: {
fontSize: 16,
lineHeight: 24,
color: '#333',
marginBottom: 16,
},
contentText: {
fontSize: 16,
lineHeight: 24,
color: '#333',
marginBottom: 24,
},
readMoreButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
backgroundColor: '#F5F5F5',
borderRadius: 8,
},
readMoreText: {
fontSize: 16,
color: '#007AFF',
marginRight: 8,
},
});
export default NewsDetailScreen;1.4 创建收藏页面
js
// src/screens/FavoritesScreen.js
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { loadFavorites, removeFavorite, clearFavorites, selectFavorites } from '../redux/slices/favoritesSlice';
import { Ionicons } from '@expo/vector-icons';
const FavoritesScreen = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const favorites = useSelector(selectFavorites);
useEffect(() => {
dispatch(loadFavorites());
}, [dispatch]);
const handleNewsPress = (news) => {
navigation.navigate('NewsDetail', { news });
};
const handleRemoveFavorite = (news) => {
Alert.alert(
'移除收藏',
'确定要从收藏中移除这篇新闻吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: () => dispatch(removeFavorite(news)),
},
],
{ cancelable: true }
);
};
const handleClearAll = () => {
if (favorites.length === 0) return;
Alert.alert(
'清空收藏',
'确定要清空所有收藏吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '清空',
style: 'destructive',
onPress: () => dispatch(clearFavorites()),
},
],
{ cancelable: true }
);
};
const renderFavoriteItem = ({ item }) => (
<TouchableOpacity
style={styles.favoriteItem}
onPress={() => handleNewsPress(item)}
activeOpacity={0.7}
>
{item.urlToImage && (
<View style={styles.imageContainer}>
<Image
source={{ uri: item.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
</View>
)}
<View style={styles.contentContainer}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<View style={styles.metaInfo}>
<Text style={styles.source}>{item.sourceName}</Text>
<Text style={styles.date}>{item.formattedDate}</Text>
</View>
</View>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveFavorite(item)}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>我的收藏</Text>
{favorites.length > 0 && (
<TouchableOpacity onPress={handleClearAll}>
<Text style={styles.clearButton}>清空</Text>
</TouchableOpacity>
)}
</View>
<FlatList
data={favorites}
renderItem={renderFavoriteItem}
keyExtractor={(item, index) => index.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="heart-outline" size={64} color="#E0E0E0" />
<Text style={styles.emptyText}>还没有收藏的新闻</Text>
<Text style={styles.emptySubText}>浏览新闻时点击收藏按钮添加</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
},
clearButton: {
fontSize: 16,
color: '#FF3B30',
},
listContent: {
padding: 12,
},
favoriteItem: {
flexDirection: 'row',
backgroundColor: 'white',
borderRadius: 8,
padding: 12,
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
},
imageContainer: {
width: 80,
height: 80,
borderRadius: 8,
overflow: 'hidden',
marginRight: 12,
},
image: {
width: '100%',
height: '100%',
},
contentContainer: {
flex: 1,
justifyContent: 'center',
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
metaInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
},
source: {
fontSize: 12,
color: '#007AFF',
},
date: {
fontSize: 12,
color: '#999',
},
removeButton: {
padding: 8,
justifyContent: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 80,
},
emptyText: {
fontSize: 16,
color: '#999',
marginTop: 16,
marginBottom: 8,
},
emptySubText: {
fontSize: 14,
color: '#999',
},
});
export default FavoritesScreen;2. 搜索功能
2.1 更新搜索状态管理
我们已经在之前创建了搜索状态管理,现在让我们更新搜索页面:
js
// src/screens/SearchScreen.js
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, TextInput, FlatList, TouchableOpacity, Image, ActivityIndicator, Keyboard } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { fetchSearchResults, setQuery, resetSearch } from '../redux/slices/searchSlice';
import { Ionicons } from '@expo/vector-icons';
const SearchScreen = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const { results, loading, error, query, hasMore, currentPage } = useSelector(state => state.search);
const [searchText, setSearchText] = useState('');
const [debounceTimer, setDebounceTimer] = useState(null);
useEffect(() => {
return () => {
dispatch(resetSearch());
};
}, [dispatch]);
const handleSearch = useCallback((text) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (text.trim()) {
const timer = setTimeout(() => {
dispatch(setQuery(text));
dispatch(fetchSearchResults({ query: text, page: 1 }));
}, 500);
setDebounceTimer(timer);
} else {
dispatch(resetSearch());
}
}, [dispatch, debounceTimer]);
const handleNewsPress = (news) => {
Keyboard.dismiss();
navigation.navigate('NewsDetail', { news });
};
const handleLoadMore = () => {
if (!loading && hasMore) {
dispatch(fetchSearchResults({ query, page: currentPage + 1 }));
}
};
const renderNewsItem = ({ item }) => (
<TouchableOpacity
style={styles.newsItem}
onPress={() => handleNewsPress(item)}
activeOpacity={0.7}
>
{item.urlToImage && (
<View style={styles.imageContainer}>
<Image
source={{ uri: item.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
</View>
)}
<View style={styles.newsContent}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
<View style={styles.newsFooter}>
<Text style={styles.source}>{item.sourceName}</Text>
<Text style={styles.date}>{item.formattedDate}</Text>
</View>
</View>
</TouchableOpacity>
);
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color="#007AFF" />
</View>
);
};
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="搜索新闻..."
placeholderTextColor="#999"
value={searchText}
onChangeText={(text) => {
setSearchText(text);
handleSearch(text);
}}
returnKeyType="search"
autoFocus
/>
{searchText.length > 0 && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => {
setSearchText('');
dispatch(resetSearch());
}}
>
<Ionicons name="close-circle" size={20} color="#999" />
</TouchableOpacity>
)}
</View>
{error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>搜索失败: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => handleSearch(searchText)}
>
<Text style={styles.retryText}>重试</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={results}
renderItem={renderNewsItem}
keyExtractor={(item, index) => index.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
ListFooterComponent={renderFooter}
ListEmptyComponent={
<View style={styles.emptyContainer}>
{searchText.length > 0 ? (
<>
<Ionicons name="search-outline" size={64} color="#E0E0E0" />
<Text style={styles.emptyText}>没有找到相关新闻</Text>
<Text style={styles.emptySubText}>尝试使用其他关键词搜索</Text>
</>
) : (
<>
<Ionicons name="search-outline" size={64} color="#E0E0E0" />
<Text style={styles.emptyText}>输入关键词搜索新闻</Text>
<Text style={styles.emptySubText}>例如: 科技、体育、娱乐</Text>
</>
)}
</View>
}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#333',
},
clearButton: {
padding: 4,
},
listContent: {
padding: 12,
},
newsItem: {
backgroundColor: 'white',
borderRadius: 8,
overflow: 'hidden',
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
},
imageContainer: {
width: '100%',
height: 180,
},
image: {
width: '100%',
height: '100%',
},
newsContent: {
padding: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
description: {
fontSize: 14,
color: '#666',
marginBottom: 12,
},
newsFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
source: {
fontSize: 12,
color: '#999',
},
date: {
fontSize: 12,
color: '#999',
},
footerLoader: {
paddingVertical: 20,
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: 'red',
marginBottom: 16,
textAlign: 'center',
},
retryButton: {
paddingVertical: 8,
paddingHorizontal: 16,
backgroundColor: '#007AFF',
borderRadius: 4,
},
retryText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 80,
},
emptyText: {
fontSize: 16,
color: '#999',
marginTop: 16,
marginBottom: 8,
},
emptySubText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
paddingHorizontal: 40,
},
});
export default SearchScreen;3. 更新导航
现在,让我们更新导航,添加收藏页面和搜索页面:
js
// src/navigation/AppNavigator.js
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import HomeScreen from '../screens/HomeScreen';
import NewsDetailScreen from '../screens/NewsDetailScreen';
import FavoritesScreen from '../screens/FavoritesScreen';
import SearchScreen from '../screens/SearchScreen';
const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
const HomeStack = () => {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="HomeMain" component={HomeScreen} />
<Stack.Screen name="NewsDetail" component={NewsDetailScreen} />
</Stack.Navigator>
);
};
const AppNavigator = () => {
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Home') {
iconName = focused ? 'home' : 'home-outline';
} else if (route.name === 'Search') {
iconName = focused ? 'search' : 'search-outline';
} else if (route.name === 'Favorites') {
iconName = focused ? 'heart' : 'heart-outline';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: 'gray',
})}
>
<Tab.Screen name="Home" component={HomeStack} options={{ title: '首页' }} />
<Tab.Screen name="Search" component={SearchScreen} options={{ title: '搜索' }} />
<Tab.Screen name="Favorites" component={FavoritesScreen} options={{ title: '收藏' }} />
</Tab.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;4. 优化搜索体验
4.1 添加搜索历史
我们可以添加搜索历史功能,让用户快速访问之前的搜索记录:
js
// src/services/searchHistory.js
import AsyncStorage from '@react-native-async-storage/async-storage';
const SEARCH_HISTORY_KEY = 'news_app_search_history';
const MAX_HISTORY_ITEMS = 10;
// 获取搜索历史
export const getSearchHistory = async () => {
try {
const historyStr = await AsyncStorage.getItem(SEARCH_HISTORY_KEY);
return historyStr ? JSON.parse(historyStr) : [];
} catch (error) {
console.error('获取搜索历史失败:', error);
return [];
}
};
// 添加搜索历史
export const addSearchHistory = async (query) => {
try {
const history = await getSearchHistory();
// 移除重复项
const filteredHistory = history.filter(item => item !== query);
// 添加到开头
const newHistory = [query, ...filteredHistory].slice(0, MAX_HISTORY_ITEMS);
await AsyncStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
} catch (error) {
console.error('添加搜索历史失败:', error);
}
};
// 清除搜索历史
export const clearSearchHistory = async () => {
try {
await AsyncStorage.removeItem(SEARCH_HISTORY_KEY);
} catch (error) {
console.error('清除搜索历史失败:', error);
}
};
// 移除单个搜索历史
export const removeSearchHistoryItem = async (query) => {
try {
const history = await getSearchHistory();
const filteredHistory = history.filter(item => item !== query);
await AsyncStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(filteredHistory));
} catch (error) {
console.error('移除搜索历史失败:', error);
}
};4.2 更新搜索页面,添加搜索历史
js
// src/screens/SearchScreen.js (带搜索历史)
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, TextInput, FlatList, TouchableOpacity, Image, ActivityIndicator, Keyboard } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { fetchSearchResults, setQuery, resetSearch } from '../redux/slices/searchSlice';
import { Ionicons } from '@expo/vector-icons';
import { getSearchHistory, addSearchHistory, clearSearchHistory, removeSearchHistoryItem } from '../services/searchHistory';
const SearchScreen = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const { results, loading, error, query, hasMore, currentPage } = useSelector(state => state.search);
const [searchText, setSearchText] = useState('');
const [debounceTimer, setDebounceTimer] = useState(null);
const [searchHistory, setSearchHistory] = useState([]);
useEffect(() => {
loadSearchHistory();
return () => {
dispatch(resetSearch());
};
}, [dispatch]);
const loadSearchHistory = async () => {
const history = await getSearchHistory();
setSearchHistory(history);
};
const handleSearch = useCallback(async (text) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (text.trim()) {
const timer = setTimeout(async () => {
dispatch(setQuery(text));
dispatch(fetchSearchResults({ query: text, page: 1 }));
// 添加到搜索历史
await addSearchHistory(text);
await loadSearchHistory();
}, 500);
setDebounceTimer(timer);
} else {
dispatch(resetSearch());
}
}, [dispatch, debounceTimer]);
const handleHistoryItemPress = (item) => {
setSearchText(item);
handleSearch(item);
};
const handleClearHistory = async () => {
await clearSearchHistory();
setSearchHistory([]);
};
const handleRemoveHistoryItem = async (item) => {
await removeSearchHistoryItem(item);
await loadSearchHistory();
};
const handleNewsPress = (news) => {
Keyboard.dismiss();
navigation.navigate('NewsDetail', { news });
};
const handleLoadMore = () => {
if (!loading && hasMore) {
dispatch(fetchSearchResults({ query, page: currentPage + 1 }));
}
};
const renderNewsItem = ({ item }) => (
<TouchableOpacity
style={styles.newsItem}
onPress={() => handleNewsPress(item)}
activeOpacity={0.7}
>
{item.urlToImage && (
<View style={styles.imageContainer}>
<Image
source={{ uri: item.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
</View>
)}
<View style={styles.newsContent}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
<View style={styles.newsFooter}>
<Text style={styles.source}>{item.sourceName}</Text>
<Text style={styles.date}>{item.formattedDate}</Text>
</View>
</View>
</TouchableOpacity>
);
const renderHistoryItem = ({ item }) => (
<TouchableOpacity
style={styles.historyItem}
onPress={() => handleHistoryItemPress(item)}
>
<Ionicons name="time-outline" size={16} color="#999" style={styles.historyIcon} />
<Text style={styles.historyText}>{item}</Text>
<TouchableOpacity
style={styles.historyRemoveButton}
onPress={() => handleRemoveHistoryItem(item)}
>
<Ionicons name="close" size={16} color="#999" />
</TouchableOpacity>
</TouchableOpacity>
);
const renderFooter = () => {
if (!loading) return null;
return (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color="#007AFF" />
</View>
);
};
const renderContent = () => {
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>搜索失败: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => handleSearch(searchText)}
>
<Text style={styles.retryText}>重试</Text>
</TouchableOpacity>
</View>
);
}
if (results.length > 0) {
return (
<FlatList
data={results}
renderItem={renderNewsItem}
keyExtractor={(item, index) => index.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
ListFooterComponent={renderFooter}
/>
);
}
if (searchText.length > 0) {
return (
<View style={styles.emptyContainer}>
<Ionicons name="search-outline" size={64} color="#E0E0E0" />
<Text style={styles.emptyText}>没有找到相关新闻</Text>
<Text style={styles.emptySubText}>尝试使用其他关键词搜索</Text>
</View>
);
}
if (searchHistory.length > 0) {
return (
<View style={styles.historyContainer}>
<View style={styles.historyHeader}>
<Text style={styles.historyTitle}>搜索历史</Text>
<TouchableOpacity onPress={handleClearHistory}>
<Text style={styles.clearHistoryText}>清空</Text>
</TouchableOpacity>
</View>
<FlatList
data={searchHistory}
renderItem={renderHistoryItem}
keyExtractor={(item, index) => index.toString()}
showsVerticalScrollIndicator={false}
/>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<Ionicons name="search-outline" size={64} color="#E0E0E0" />
<Text style={styles.emptyText}>输入关键词搜索新闻</Text>
<Text style={styles.emptySubText}>例如: 科技、体育、娱乐</Text>
</View>
);
};
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="搜索新闻..."
placeholderTextColor="#999"
value={searchText}
onChangeText={(text) => {
setSearchText(text);
handleSearch(text);
}}
returnKeyType="search"
autoFocus
/>
{searchText.length > 0 && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => {
setSearchText('');
dispatch(resetSearch());
}}
>
<Ionicons name="close-circle" size={20} color="#999" />
</TouchableOpacity>
)}
</View>
{renderContent()}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#333',
},
clearButton: {
padding: 4,
},
listContent: {
padding: 12,
},
newsItem: {
backgroundColor: 'white',
borderRadius: 8,
overflow: 'hidden',
marginBottom: 12,
elevation: 2,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
},
imageContainer: {
width: '100%',
height: 180,
},
image: {
width: '100%',
height: '100%',
},
newsContent: {
padding: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
description: {
fontSize: 14,
color: '#666',
marginBottom: 12,
},
newsFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
source: {
fontSize: 12,
color: '#999',
},
date: {
fontSize: 12,
color: '#999',
},
footerLoader: {
paddingVertical: 20,
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: 'red',
marginBottom: 16,
textAlign: 'center',
},
retryButton: {
paddingVertical: 8,
paddingHorizontal: 16,
backgroundColor: '#007AFF',
borderRadius: 4,
},
retryText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 80,
},
emptyText: {
fontSize: 16,
color: '#999',
marginTop: 16,
marginBottom: 8,
},
emptySubText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
paddingHorizontal: 40,
},
historyContainer: {
flex: 1,
padding: 16,
},
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
historyTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
clearHistoryText: {
fontSize: 14,
color: '#007AFF',
},
historyItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
historyIcon: {
marginRight: 12,
},
historyText: {
flex: 1,
fontSize: 16,
color: '#333',
},
historyRemoveButton: {
padding: 4,
},
});
export default SearchScreen;5. 总结
通过本章节的学习,我们掌握了如何使用 React Native 实现收藏和搜索功能,包括:
- 如何使用 Redux Toolkit 管理收藏状态
- 如何使用 AsyncStorage 持久化存储收藏和搜索历史
- 如何实现新闻收藏和取消收藏功能
- 如何实现新闻搜索功能
- 如何添加搜索历史功能
- 如何优化搜索体验
这些功能将使我们的新闻/资讯APP更加完善,提供更好的用户体验。
6. 下一步
接下来,我们将学习如何实现全局状态管理,进一步提升应用的性能和可维护性。
