Appearance
列表、详情页开发
在完成了 API 对接后,我们需要开发新闻列表和详情页,为用户提供良好的阅读体验。在这一节中,我们将学习如何使用 React Native 开发新闻列表和详情页。
1. 创建新闻列表组件
首先,让我们创建一个新闻列表组件,用于展示新闻列表:
js
// src/components/NewsList.js
import React, { useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, ActivityIndicator, TouchableOpacity, Image } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { fetchTopHeadlines, setCategory } from '../redux/slices/newsSlice';
import { useNavigation } from '@react-navigation/native';
const categories = [
{ id: 'general', name: '综合' },
{ id: 'business', name: '商业' },
{ id: 'entertainment', name: '娱乐' },
{ id: 'health', name: '健康' },
{ id: 'science', name: '科技' },
{ id: 'sports', name: '体育' },
{ id: 'technology', name: '技术' },
];
const NewsList = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const { headlines, loading, error, selectedCategory, selectedCountry } = useSelector(state => state.news);
useEffect(() => {
dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }));
}, [dispatch, selectedCountry, selectedCategory]);
const handleCategoryChange = (categoryId) => {
dispatch(setCategory(categoryId));
};
const handleNewsPress = (news) => {
navigation.navigate('NewsDetail', { news });
};
const renderNewsItem = useCallback(({ 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>
), [handleNewsPress]);
const renderCategory = ({ item }) => (
<TouchableOpacity
style={[
styles.categoryButton,
selectedCategory === item.id && styles.selectedCategoryButton
]}
onPress={() => handleCategoryChange(item.id)}
>
<Text
style={[
styles.categoryText,
selectedCategory === item.id && styles.selectedCategoryText
]}
>
{item.name}
</Text>
</TouchableOpacity>
);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>加载失败: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }))}
>
<Text style={styles.retryText}>重试</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={categories}
renderItem={renderCategory}
keyExtractor={item => item.id}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoryList}
contentContainerStyle={styles.categoryListContent}
/>
<FlatList
data={headlines}
renderItem={renderNewsItem}
keyExtractor={(item, index) => index.toString()}
contentContainerStyle={styles.newsListContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>暂无新闻</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: 'red',
marginBottom: 16,
},
retryButton: {
paddingVertical: 8,
paddingHorizontal: 16,
backgroundColor: '#007AFF',
borderRadius: 4,
},
retryText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
},
categoryList: {
backgroundColor: 'white',
maxHeight: 50,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
categoryListContent: {
paddingHorizontal: 12,
paddingVertical: 12,
},
categoryButton: {
paddingVertical: 6,
paddingHorizontal: 16,
marginRight: 8,
borderRadius: 16,
backgroundColor: '#F0F0F0',
},
selectedCategoryButton: {
backgroundColor: '#007AFF',
},
categoryText: {
fontSize: 14,
color: '#333',
},
selectedCategoryText: {
color: 'white',
fontWeight: '500',
},
newsListContent: {
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',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
},
});
export default NewsList;2. 创建新闻详情页
接下来,让我们创建新闻详情页,用于展示新闻的详细内容:
js
// src/screens/NewsDetailScreen.js
import React 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';
const NewsDetailScreen = () => {
const route = useRoute();
const navigation = useNavigation();
const { news } = route.params;
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);
}
};
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>
<TouchableOpacity
style={styles.shareButton}
onPress={handleShare}
>
<Ionicons name="share-outline" size={24} color="#333" />
</TouchableOpacity>
</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,
},
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;3. 创建轮播图组件
为了展示热门新闻,我们可以创建一个轮播图组件:
js
// src/components/NewsCarousel.js
import React, { useState, useRef } from 'react';
import { View, Text, StyleSheet, Image, TouchableOpacity, Dimensions } from 'react-native';
import Carousel from 'react-native-snap-carousel';
import { useNavigation } from '@react-navigation/native';
const { width: screenWidth } = Dimensions.get('window');
const NewsCarousel = ({ news }) => {
const navigation = useNavigation();
const [activeIndex, setActiveIndex] = useState(0);
const carouselRef = useRef(null);
const handleNewsPress = (item) => {
navigation.navigate('NewsDetail', { news: item });
};
const renderItem = ({ item, index }) => (
<TouchableOpacity
style={styles.slide}
onPress={() => handleNewsPress(item)}
activeOpacity={0.9}
>
<Image
source={{ uri: item.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
<View style={styles.overlay}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
<Text style={styles.source}>{item.sourceName}</Text>
</View>
</TouchableOpacity>
);
if (!news || news.length === 0) {
return null;
}
return (
<View style={styles.container}>
<Carousel
ref={carouselRef}
data={news.slice(0, 5)} // 只展示前5条新闻
renderItem={renderItem}
sliderWidth={screenWidth}
itemWidth={screenWidth - 32}
layout="default"
loop
autoplay
autoplayInterval={5000}
onSnapToItem={(index) => setActiveIndex(index)}
/>
<View style={styles.pagination}>
{news.slice(0, 5).map((_, index) => (
<View
key={index}
style={[
styles.paginationDot,
index === activeIndex && styles.paginationDotActive
]}
/>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
height: 250,
marginVertical: 12,
},
slide: {
flex: 1,
borderRadius: 12,
overflow: 'hidden',
},
image: {
width: '100%',
height: '100%',
},
overlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 16,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: 'white',
marginBottom: 8,
},
source: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
},
pagination: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 12,
},
paginationDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#E0E0E0',
marginHorizontal: 4,
},
paginationDotActive: {
backgroundColor: '#007AFF',
},
});
export default NewsCarousel;4. 更新主屏幕
现在,让我们更新主屏幕,整合新闻列表和轮播图:
js
// src/screens/HomeScreen.js
import React, { useEffect } from 'react';
import { View, StyleSheet, ScrollView, RefreshControl } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { fetchTopHeadlines } from '../redux/slices/newsSlice';
import NewsCarousel from '../components/NewsCarousel';
import NewsList from '../components/NewsList';
const HomeScreen = () => {
const dispatch = useDispatch();
const { headlines, loading, selectedCountry, selectedCategory } = useSelector(state => state.news);
const [refreshing, setRefreshing] = React.useState(false);
useEffect(() => {
dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }));
}, [dispatch, selectedCountry, selectedCategory]);
const onRefresh = React.useCallback(() => {
setRefreshing(true);
dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }))
.finally(() => setRefreshing(false));
}, [dispatch, selectedCountry, selectedCategory]);
return (
<View style={styles.container}>
<ScrollView
style={styles.scrollView}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#007AFF']}
tintColor="#007AFF"
/>
}
>
<NewsCarousel news={headlines} />
<NewsList />
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
scrollView: {
flex: 1,
},
});
export default HomeScreen;5. 设置导航
现在,让我们设置导航,将主屏幕和详情页连接起来:
js
// src/navigation/AppNavigator.js
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import HomeScreen from '../screens/HomeScreen';
import NewsDetailScreen from '../screens/NewsDetailScreen';
const Stack = createStackNavigator();
const AppNavigator = () => {
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="NewsDetail" component={NewsDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;6. 更新 App.js
最后,让我们更新 App.js,整合所有组件:
js
// App.js
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
import { store } from './src/redux/store';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<Provider store={store}>
<SafeAreaProvider>
<AppNavigator />
</SafeAreaProvider>
</Provider>
);
}7. 优化列表性能
为了提高列表的性能,我们可以进行以下优化:
- 使用
useCallback缓存回调函数 - 使用
keyExtractor提供唯一的键 - 使用
getItemLayout优化列表布局 - 使用
removeClippedSubviews减少渲染的视图
js
// src/components/NewsList.js (优化版)
import React, { useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, ActivityIndicator, TouchableOpacity, Image } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { fetchTopHeadlines, setCategory } from '../redux/slices/newsSlice';
import { useNavigation } from '@react-navigation/native';
const categories = [
{ id: 'general', name: '综合' },
{ id: 'business', name: '商业' },
{ id: 'entertainment', name: '娱乐' },
{ id: 'health', name: '健康' },
{ id: 'science', name: '科技' },
{ id: 'sports', name: '体育' },
{ id: 'technology', name: '技术' },
];
const NewsList = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const { headlines, loading, error, selectedCategory, selectedCountry } = useSelector(state => state.news);
useEffect(() => {
dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }));
}, [dispatch, selectedCountry, selectedCategory]);
const handleCategoryChange = useCallback((categoryId) => {
dispatch(setCategory(categoryId));
}, [dispatch]);
const handleNewsPress = useCallback((news) => {
navigation.navigate('NewsDetail', { news });
}, [navigation]);
const renderNewsItem = useCallback(({ item, index }) => (
<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>
), [handleNewsPress]);
const renderCategory = useCallback(({ item }) => (
<TouchableOpacity
style={[
styles.categoryButton,
selectedCategory === item.id && styles.selectedCategoryButton
]}
onPress={() => handleCategoryChange(item.id)}
>
<Text
style={[
styles.categoryText,
selectedCategory === item.id && styles.selectedCategoryText
]}
>
{item.name}
</Text>
</TouchableOpacity>
), [selectedCategory, handleCategoryChange]);
const keyExtractor = useCallback((item, index) => index.toString(), []);
const getItemLayout = useCallback((data, index) => ({
length: 300, // 估计每个 item 的高度
offset: 300 * index,
index,
}), []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>加载失败: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchTopHeadlines({ country: selectedCountry, category: selectedCategory }))}
>
<Text style={styles.retryText}>重试</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={categories}
renderItem={renderCategory}
keyExtractor={item => item.id}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoryList}
contentContainerStyle={styles.categoryListContent}
/>
<FlatList
data={headlines}
renderItem={renderNewsItem}
keyExtractor={keyExtractor}
contentContainerStyle={styles.newsListContent}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
getItemLayout={getItemLayout}
maxToRenderPerBatch={10}
windowSize={10}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>暂无新闻</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
// 与之前相同的样式
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: 'red',
marginBottom: 16,
},
retryButton: {
paddingVertical: 8,
paddingHorizontal: 16,
backgroundColor: '#007AFF',
borderRadius: 4,
},
retryText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
},
categoryList: {
backgroundColor: 'white',
maxHeight: 50,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
categoryListContent: {
paddingHorizontal: 12,
paddingVertical: 12,
},
categoryButton: {
paddingVertical: 6,
paddingHorizontal: 16,
marginRight: 8,
borderRadius: 16,
backgroundColor: '#F0F0F0',
},
selectedCategoryButton: {
backgroundColor: '#007AFF',
},
categoryText: {
fontSize: 14,
color: '#333',
},
selectedCategoryText: {
color: 'white',
fontWeight: '500',
},
newsListContent: {
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',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
},
});
export default NewsList;8. 图片优化
为了优化图片加载性能,我们可以使用 react-native-fast-image 库:
bash
npm install react-native-fast-image然后,我们可以更新图片组件:
js
// src/components/OptimizedImage.js
import React from 'react';
import { StyleSheet, View, ActivityIndicator } from 'react-native';
import FastImage from 'react-native-fast-image';
const OptimizedImage = ({ source, style, resizeMode = 'cover' }) => {
return (
<View style={[styles.container, style]}>
<FastImage
source={source}
style={styles.image}
resizeMode={resizeMode}
placeholder={require('../assets/placeholder.png')}
fallback={require('../assets/fallback.png')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
image: {
width: '100%',
height: '100%',
},
});
export default OptimizedImage;9. 总结
通过本章节的学习,我们掌握了如何使用 React Native 开发新闻列表和详情页,包括:
- 如何创建新闻列表组件
- 如何创建新闻详情页
- 如何创建轮播图组件
- 如何设置导航
- 如何优化列表性能
- 如何优化图片加载
这些技能将帮助我们构建更加流畅、美观的新闻/资讯APP。
10. 下一步
接下来,我们将学习如何实现收藏和搜索功能,进一步增强应用的功能。
