feat(performance): migrate FlatList to FlashList and add animations
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m9s
Frontend CI / ota-android (push) Successful in 10m37s
Frontend CI / build-android-apk (push) Has been cancelled

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:
lafay
2026-04-25 15:09:00 +08:00
parent 19054d64b3
commit a6a4198ac5
14 changed files with 746 additions and 680 deletions

View File

@@ -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>
);
};