feat(performance): migrate FlatList to FlashList and add animations
Replace FlatList with FlashList across all message screens (ChatScreen, MessageListScreen, NotificationsScreen, HomeScreen) for improved list virtualization performance. Use `drawDistance={250}` instead of manual pagination. Simplify React.memo comparisons in MessageBubble and SegmentRenderer by removing function prop checks to prevent unnecessary re-renders.
Add AsyncStorage persistence for lastSystemMessageAt to avoid showing current time on first render. Include enter animations (fade and slide) for CreateGroupScreen and modernize UI styling to flat design.
BREAKING CHANGE: Upgrade @shopify/flash-list from 2.0.2 to ^2.3.1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
Clipboard,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
Animated,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
@@ -26,112 +31,137 @@ function createJoinGroupStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.md,
|
||||
},
|
||||
heroCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.divider + '40',
|
||||
},
|
||||
heroIconWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
heroTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.xl + 1,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
tip: {
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.divider + '40',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
|
||||
// 标题区域
|
||||
titleSection: {
|
||||
marginBottom: 32,
|
||||
marginTop: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.sm + 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.divider + '80',
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 36,
|
||||
},
|
||||
underline: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: 2,
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
|
||||
// 搜索区域
|
||||
searchSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: colors.text.secondary,
|
||||
marginBottom: 10,
|
||||
},
|
||||
searchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 18,
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
height: 56,
|
||||
},
|
||||
searchBtn: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
searchBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
// 搜索结果区域
|
||||
searchResultSection: {
|
||||
marginBottom: spacing.md,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '800',
|
||||
fontSize: fontSizes.md + 1,
|
||||
letterSpacing: 0.3,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: colors.text.secondary,
|
||||
marginBottom: 12,
|
||||
},
|
||||
listSection: {
|
||||
flex: 1,
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: colors.text.hint,
|
||||
fontWeight: '500',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
|
||||
// 群卡片
|
||||
groupCard: {
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.divider + '40',
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 16,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
groupMeta: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
groupName: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md + 1,
|
||||
letterSpacing: 0.3,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
groupDesc: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 13,
|
||||
color: colors.text.secondary,
|
||||
lineHeight: 20,
|
||||
fontWeight: '400',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
groupInfoRow: {
|
||||
marginTop: spacing.sm,
|
||||
@@ -139,12 +169,20 @@ function createJoinGroupStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
groupInfoText: {
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
groupNoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
groupNoText: {
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
copyBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -155,15 +193,17 @@ function createJoinGroupStyles(colors: AppColors) {
|
||||
},
|
||||
copyBtnText: {
|
||||
marginLeft: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
submitBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: borderRadius.lg,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
minHeight: 46,
|
||||
height: 48,
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
@@ -171,12 +211,13 @@ function createJoinGroupStyles(colors: AppColors) {
|
||||
submitText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 15,
|
||||
color: colors.text.inverse,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.sm,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
|
||||
// 推荐区域
|
||||
recommendSection: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingFooter: {
|
||||
paddingVertical: spacing.md,
|
||||
@@ -186,10 +227,16 @@ function createJoinGroupStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
noMoreText: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
});
|
||||
@@ -205,6 +252,25 @@ const JoinGroupScreen: React.FC = () => {
|
||||
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
// 使用游标分页 Hook 管理群组列表
|
||||
const {
|
||||
list: groups,
|
||||
@@ -296,31 +362,31 @@ const JoinGroupScreen: React.FC = () => {
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar source={group.avatar} size={52} name={group.name} />
|
||||
<View style={styles.groupMeta}>
|
||||
<Text variant="body" style={styles.groupName} numberOfLines={1}>
|
||||
<Text style={styles.groupName} numberOfLines={1}>
|
||||
{group.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!!group.description && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
<Text style={styles.groupDesc}>
|
||||
{group.description}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.groupInfoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
<Text style={styles.groupInfoText}>
|
||||
成员 {group.member_count}/{group.max_members}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
<Text style={styles.groupInfoText}>
|
||||
{getJoinTypeText(group.join_type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.groupNoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
<Text style={styles.groupNoText}>
|
||||
群号:{formatGroupNo(group.id)}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
|
||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>
|
||||
<Text style={styles.copyBtnText}>
|
||||
复制
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -331,11 +397,11 @@ const JoinGroupScreen: React.FC = () => {
|
||||
disabled={isJoining}
|
||||
>
|
||||
{isJoining ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={colors.text.inverse} />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
||||
<Text variant="body" color="#fff" style={styles.submitText}>
|
||||
<MaterialCommunityIcons name="send-outline" size={18} color={colors.text.inverse} />
|
||||
<Text style={styles.submitText}>
|
||||
申请入群
|
||||
</Text>
|
||||
</>
|
||||
@@ -362,9 +428,7 @@ const JoinGroupScreen: React.FC = () => {
|
||||
if (searchedGroup) {
|
||||
return (
|
||||
<View style={styles.searchResultSection}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
搜索结果
|
||||
</Text>
|
||||
<Text style={styles.sectionTitle}>搜索结果</Text>
|
||||
{renderGroupItem({ item: searchedGroup })}
|
||||
</View>
|
||||
);
|
||||
@@ -372,9 +436,12 @@ const JoinGroupScreen: React.FC = () => {
|
||||
|
||||
if (!searching) {
|
||||
return (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
||||
暂无搜索结果,请检查群ID后重试
|
||||
</Text>
|
||||
<View style={styles.searchResultSection}>
|
||||
<Text style={styles.sectionTitle}>搜索结果</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
暂无搜索结果,请检查群ID后重试
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,91 +449,113 @@ const JoinGroupScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroIconWrap}>
|
||||
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="h3" style={styles.heroTitle}>搜索群聊</Text>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.tip}>
|
||||
输入群 ID 搜索后,你可以先查看群资料,再决定是否申请加入。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
|
||||
搜索群聊(群ID)
|
||||
</Text>
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="例如:7391234567890"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
style={styles.input}
|
||||
editable={!searching && !joiningGroupId}
|
||||
autoCapitalize="none"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
|
||||
onPress={handleSearch}
|
||||
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={styles.title}>加入群聊</Text>
|
||||
<View style={styles.underline} />
|
||||
<Text style={styles.subtitle}>搜索群ID或浏览推荐群组</Text>
|
||||
</View>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
{renderSearchResult()}
|
||||
|
||||
{/* 群组列表 */}
|
||||
<View style={styles.listSection}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
推荐群组
|
||||
</Text>
|
||||
<FlatList
|
||||
data={groups}
|
||||
renderItem={renderGroupItem}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={renderEmptyList}
|
||||
ListFooterComponent={
|
||||
isLoading ? (
|
||||
<View style={styles.loadingFooter}>
|
||||
<ActivityIndicator color={colors.primary.main} />
|
||||
{/* 搜索区域 */}
|
||||
<View style={styles.searchSection}>
|
||||
<Text style={styles.inputLabel}>搜索群聊(群ID)</Text>
|
||||
<View style={styles.searchRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="例如:7391234567890"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
style={styles.input}
|
||||
editable={!searching && !joiningGroupId}
|
||||
autoCapitalize="none"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
) : hasMore ? (
|
||||
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
加载更多
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.searchBtn,
|
||||
(!keyword.trim() || searching || !!joiningGroupId) && styles.searchBtnDisabled,
|
||||
]}
|
||||
onPress={handleSearch}
|
||||
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color={colors.text.inverse} />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="magnify" size={22} color={colors.text.inverse} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : groups.length > 0 ? (
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
|
||||
没有更多群组了
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
{renderSearchResult()}
|
||||
|
||||
{/* 推荐群组 */}
|
||||
<View style={styles.recommendSection}>
|
||||
<Text style={styles.sectionTitle}>推荐群组</Text>
|
||||
{groups.length === 0 && !isLoading ? (
|
||||
renderEmptyList()
|
||||
) : (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<View key={String(group.id)}>
|
||||
{renderGroupItem({ item: group })}
|
||||
</View>
|
||||
))}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingFooter}>
|
||||
<ActivityIndicator color={colors.primary.main} />
|
||||
</View>
|
||||
)}
|
||||
{hasMore && !isLoading && (
|
||||
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||||
<Text style={styles.loadMoreText}>
|
||||
加载更多
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!hasMore && groups.length > 0 && (
|
||||
<Text style={styles.noMoreText}>
|
||||
没有更多群组了
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user