Skip to content

收藏、搜索功能

在完成了新闻列表和详情页的开发后,我们需要添加收藏和搜索功能,提升用户体验。在这一节中,我们将学习如何实现这两个功能。

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 实现收藏和搜索功能,包括:

  1. 如何使用 Redux Toolkit 管理收藏状态
  2. 如何使用 AsyncStorage 持久化存储收藏和搜索历史
  3. 如何实现新闻收藏和取消收藏功能
  4. 如何实现新闻搜索功能
  5. 如何添加搜索历史功能
  6. 如何优化搜索体验

这些功能将使我们的新闻/资讯APP更加完善,提供更好的用户体验。

6. 下一步

接下来,我们将学习如何实现全局状态管理,进一步提升应用的性能和可维护性。

© 2026 编程马·菜鸟教程 版权所有