From a6a4198ac56c9b65f1dc243d63fecf3a678be634 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 25 Apr 2026 15:09:00 +0800 Subject: [PATCH] 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 --- package-lock.json | 13 +- package.json | 2 +- src/components/business/PostCard/PostCard.tsx | 10 +- src/screens/home/HomeScreen.tsx | 262 +++------ src/screens/message/ChatScreen.tsx | 49 +- src/screens/message/CreateGroupScreen.tsx | 532 +++++++++++------- src/screens/message/JoinGroupScreen.tsx | 431 ++++++++------ src/screens/message/MessageListScreen.tsx | 51 +- src/screens/message/NotificationsScreen.tsx | 12 +- .../components/ChatScreen/MessageBubble.tsx | 34 +- .../components/ChatScreen/SegmentRenderer.tsx | 8 - src/stores/message/MessageManager.ts | 5 +- src/stores/message/index.ts | 1 + src/stores/message/store.ts | 16 + 14 files changed, 746 insertions(+), 680 deletions(-) diff --git a/package-lock.json b/package-lock.json index 832e5c5..113ee3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "with_you", - "version": "0.1.sha", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { @@ -14,7 +14,7 @@ "@react-navigation/material-top-tabs": "^7.4.16", "@react-navigation/native": "^7.1.31", "@react-navigation/native-stack": "^7.14.2", - "@shopify/flash-list": "2.0.2", + "@shopify/flash-list": "^2.3.1", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "date-fns": "^4.1.0", @@ -3664,13 +3664,10 @@ } }, "node_modules/@shopify/flash-list": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.0.2.tgz", - "integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==", + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.3.1.tgz", + "integrity": "sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==", "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, "peerDependencies": { "@babel/runtime": "*", "react": "*", diff --git a/package.json b/package.json index a20d882..8cf4af2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@react-navigation/material-top-tabs": "^7.4.16", "@react-navigation/native": "^7.1.31", "@react-navigation/native-stack": "^7.14.2", - "@shopify/flash-list": "2.0.2", + "@shopify/flash-list": "^2.3.1", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", "date-fns": "^4.1.0", diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 54f0872..35016c2 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -600,8 +600,14 @@ const PostCardInner: React.FC = (normalizedProps) => { {author?.nickname || '匿名用户'} - - {formatNumber(post.likes_count || 0)} + + + {formatNumber(post.likes_count || 0)} + diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 9669476..a7b9e23 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -7,7 +7,6 @@ import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react'; import { View, - FlatList, ScrollView, Platform, StyleSheet, @@ -20,6 +19,7 @@ import { Clipboard, Modal, } from 'react-native'; +import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter, useFocusEffect } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -31,7 +31,7 @@ import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore import { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; -import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; +import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { postSyncService } from '@/services/post'; @@ -114,22 +114,12 @@ function createHomeStyles(colors: AppColors) { flex: 1, }, listItem: {}, - waterfallScroll: { - flex: 1, - }, - waterfallContainer: { - width: '100%', + gridContent: { flexGrow: 1, }, - waterfallColumnsRow: { - width: '100%', - flexDirection: 'row', - alignItems: 'flex-start', + gridItem: { + marginBottom: 0, }, - waterfallColumn: { - flex: 1, - }, - waterfallItem: {}, floatingButton: { position: 'absolute', right: 20, @@ -237,8 +227,8 @@ export const HomeScreen: React.FC = () => { const homeListScrollYRef = useRef(0); const tabBarIdleRestoreTimerRef = useRef | null>(null); - // FlatList 和 ScrollView 的 ref,用于滚动到顶部 - const flatListRef = useRef | null>(null); + // FlashList 和 ScrollView 的 ref,用于滚动到顶部 + const flashListRef = useRef | null>(null); const scrollViewRef = useRef(null); const clearTabBarIdleRestoreTimer = useCallback(() => { @@ -298,8 +288,8 @@ export const HomeScreen: React.FC = () => { // 监听首页 Tab 点击事件,滚动到顶部 useEffect(() => { if (homeTabPressCount > 0) { - // 滚动 FlatList 到顶部 - flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + // 滚动 FlashList 到顶部 + flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); // 滚动 ScrollView 到顶部(网格模式) scrollViewRef.current?.scrollTo({ y: 0, animated: true }); // 重置底部 Tab 栏状态 @@ -403,20 +393,12 @@ export const HomeScreen: React.FC = () => { } }, [posts.length, listKey, hasMore, isLoadingMore]); - // 网格模式滚动处理 - 检测是否滚动到底部 + 底部 Tab 显隐 + // 网格模式滚动处理 - 底部 Tab 显隐(加载更多由 FlashList onEndReached 处理) const handleGridScroll = useCallback( (event: NativeSyntheticEvent) => { handleHomeVerticalScroll(event); - const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; - const scrollY = contentOffset.y; - const visibleHeight = layoutMeasurement.height; - const contentHeight = contentSize.height; - - if (scrollY + visibleHeight >= contentHeight - 200) { - loadMore(); - } }, - [handleHomeVerticalScroll, loadMore] + [handleHomeVerticalScroll] ); // 刷新方法 - 先获取正确类型的帖子,再刷新 @@ -733,7 +715,7 @@ export const HomeScreen: React.FC = () => { // 渲染帖子卡片(列表模式) const keyExtractor = useCallback((item: Post) => item.id, []); - const renderPostList = useCallback(({ item }: { item: Post }) => { + const renderPostList = useCallback>(({ item }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -756,154 +738,56 @@ export const HomeScreen: React.FC = () => { ); }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); - // 估算帖子在瀑布流中的高度(用于均匀分配) - const estimatePostHeight = (post: Post, columnWidth: number): number => { - const hasImage = post.images && post.images.length > 0; - const hasTitle = !!post.title; - const hasContent = !!post.content; - - let height = 0; - - // 图片区域高度(如果有图) - if (hasImage) { - // 使用帖子 ID 生成一致的宽高比 - const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0); - const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; - const aspectRatio = aspectRatios[hash % aspectRatios.length]; - height += columnWidth / aspectRatio; - } else { - // 无图帖子显示正文预览区域 - if (hasContent) { - // 根据内容长度估算高度(每行约20像素,最多6行) - const contentLength = post.content?.length || 0; - const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20))); - height += 16 + estimatedLines * 20; // padding + 文本高度 - } - } - - // 标题高度 - if (hasTitle) { - const titleLines = hasImage ? 2 : 3; - height += 8 + titleLines * 20; // paddingTop + 文本高度 - } - - // 底部信息栏高度 - height += 40; // 用户信息 + 点赞数 - - // 间距 - height += 2; // marginBottom - - return height; - }; - - // 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀 - const distributePostsToColumns = useMemo(() => { - const columns: Post[][] = Array.from({ length: gridColumns }, () => []); - const columnHeights: number[] = Array(gridColumns).fill(0); - - // 防御性检查:确保 posts 存在且是数组 - if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) { - return columns; - } - - // 计算单列宽度 - const totalGap = (gridColumns - 1) * responsiveGap; - const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; - - displayPosts.forEach((post) => { - const postHeight = estimatePostHeight(post, columnWidth); - - // 找到当前高度最小的列 - const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights)); - columns[minHeightIndex].push(post); - columnHeights[minHeightIndex] += postHeight; - }); - - return columns; - }, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]); - - // 渲染单列帖子 - const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( - - {column.map(post => { - const authorId = post.author?.id || ''; - const isPostAuthor = currentUser?.id === authorId; - return ( - - stableOnPostAction(post.id, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - - // 渲染响应式网格布局(平板/桌面端使用多列) - const renderResponsiveGrid = () => { - if (isMobile && !isTablet) { - // 移动端使用瀑布流布局(2列) - return ( - - } - > - {renderLatestCapsules()} - - {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} - - {/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} - {isLoadingMore && displayPosts.length > 0 && ( - - - - )} - - ); - } - - // 平板/桌面端使用 ResponsiveGrid 组件 + // 渲染网格项(用于 FlashList 多列模式) + const renderGridItem = useCallback>(({ item }) => { + const authorId = item.author?.id || ''; + const isPostAuthor = currentUser?.id === authorId; return ( - - {displayPosts.map(post => { - const authorId = post.author?.id || ''; - const isPostAuthor = currentUser?.id === authorId; - return ( - stableOnPostAction(post.id, action)} - isPostAuthor={isPostAuthor} - /> - ); - })} - + + stableOnPostAction(item.id, action)} + isPostAuthor={isPostAuthor} + /> + + ); + }, [currentUser?.id, stableOnPostAction]); + + // 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式) + const renderResponsiveGrid = () => { + return ( + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + onScroll={handleGridScroll} + scrollEventThrottle={16} + ListEmptyComponent={renderEmpty} + ListFooterComponent={isLoadingMore ? : null} + drawDistance={250} + /> ); }; @@ -926,23 +810,19 @@ export const HomeScreen: React.FC = () => { return renderResponsiveGrid(); } - // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 + // 移动端和宽屏都使用单列 FlashList,宽屏下居中显示 return ( - { scrollEventThrottle={16} ListEmptyComponent={renderEmpty} ListFooterComponent={isLoadingMore ? : null} - initialNumToRender={8} - maxToRenderPerBatch={8} - updateCellsBatchingPeriod={60} - windowSize={7} - removeClippedSubviews={false} + drawDistance={250} /> ); }; diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 2d846a5..20402a4 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -22,12 +22,12 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, - FlatList, ActivityIndicator, KeyboardAvoidingView, Platform, TouchableOpacity, } from 'react-native'; +import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useNavigation, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; @@ -51,6 +51,7 @@ import { PANEL_HEIGHTS, ChatScreenProps, } from './components/ChatScreen'; +import { GroupMessage } from './components/ChatScreen/types'; export const ChatScreen: React.FC = (props) => { const navigation = useNavigation(); @@ -92,12 +93,12 @@ export const ChatScreen: React.FC = (props) => { isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null, ]), [isWideScreen, styles.container, props.isEmbedded]); - const listContentStyle = useMemo(() => ([ - styles.listContent, - isWideScreen && !props.isEmbedded - ? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const } - : { paddingHorizontal: 16 }, - ]), [isWideScreen, styles.listContent, props.isEmbedded]); + const listContentStyle = useMemo(() => ({ + paddingHorizontal: isWideScreen && !props.isEmbedded ? 24 : 16, + ...(isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const } : {}), + paddingTop: 12, + paddingBottom: 24, + }), [isWideScreen, props.isEmbedded]); const inputWrapperStyle = useMemo(() => ([ styles.inputWrapper, @@ -285,7 +286,7 @@ export const ChatScreen: React.FC = (props) => { return map; }, [messages]); - const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => { + const renderMessage = useCallback>(({ item, index }) => { // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; return ( @@ -330,7 +331,7 @@ export const ChatScreen: React.FC = (props) => { handleReplyPreviewPress, ]); - const keyExtractor = useCallback((item: any) => String(item.id), []); + const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); @@ -448,22 +449,18 @@ export const ChatScreen: React.FC = (props) => { ) : ( - { isUserDraggingRef.current = true; @@ -477,26 +474,6 @@ export const ChatScreen: React.FC = (props) => { }} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} - onScrollToIndexFailed={(info) => { - const targetId = replyTargetMessageIdRef.current; - if (!targetId || !flatListRef.current) return; - - flatListRef.current.scrollToOffset({ - offset: Math.max(0, info.averageItemLength * info.index), - animated: true, - }); - - setTimeout(() => { - const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); - if (retryIndex >= 0) { - flatListRef.current?.scrollToIndex({ - index: retryIndex, - animated: true, - viewPosition: 0.5, - }); - } - }, 120); - }} /> )} {showEdgeLoadingIndicator && ( diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index 905a95f..a389467 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -1,10 +1,16 @@ /** - * CreateGroupScreen 创建群聊界面 + * CreateGroupScreen 创建群聊界面(现代化扁平风格) * 允许用户创建新的群聊,设置群名称、描述,并选择初始成员 * 支持响应式布局 + * + * 设计风格: + * - 纯白/浅色背景,扁平化设计 + * - 灰色填充输入框,圆角14px + * - 橙色圆角按钮 + * - 清晰的标签 + 输入框分组 */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { View, StyleSheet, @@ -15,6 +21,9 @@ import { FlatList, Image, Platform, + Animated, + KeyboardAvoidingView, + ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -24,7 +33,7 @@ import * as ImagePicker from 'expo-image-picker'; import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { groupService } from '@/services/message'; import { uploadService } from '@/services/upload'; -import { Avatar, Text, Button, Loading } from '../../components/common'; +import { Avatar, Text, Loading } from '../../components/common'; import { User } from '../../types'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; @@ -47,6 +56,25 @@ const CreateGroupScreen: React.FC = () => { // 邀请成员模态框状态 const [inviteModalVisible, setInviteModalVisible] = 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(); + }, []); + useEffect(() => { if (inviteModalVisible) { blurActiveElement(); @@ -180,133 +208,150 @@ const CreateGroupScreen: React.FC = () => { }; return ( - - + - {/* 群头像和名称区域 */} - - - - {uploadingAvatar ? ( - - - - ) : groupAvatar ? ( - - ) : ( - - )} - - - - - - - - - 群名称 * - - - - {groupName.length}/50 - - - - - {/* 群描述输入 */} - - - 群描述 - - - - - {groupDescription.length}/500 - - - - - {/* 已选成员 */} - {selectedMembers.length > 0 && ( - - - - 已选成员 - - - {selectedMembers.length}人 - - - item.id} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.selectedMembersList} - /> - - )} - - {/* 邀请成员按钮 */} - setInviteModalVisible(true)} - activeOpacity={0.8} + - - - - - 邀请成员 - - 仅可从互关好友中选择成员加入群聊 - - - - - + + {/* 标题区域 */} + + 创建群聊 + + 填写群信息并邀请成员 + - {/* 创建按钮 */} - -