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:
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// FlatList 和 ScrollView 的 ref,用于滚动到顶部
|
||||
const flatListRef = useRef<FlatList<Post> | null>(null);
|
||||
// FlashList 和 ScrollView 的 ref,用于滚动到顶部
|
||||
const flashListRef = useRef<FlashListRef<Post> | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView | null>(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<NativeScrollEvent>) => {
|
||||
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<ListRenderItem<Post>>(({ 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) => (
|
||||
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
|
||||
{column.map(post => {
|
||||
const authorId = post.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
variant="grid"
|
||||
onAction={(action) => stableOnPostAction(post.id, action)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染响应式网格布局(平板/桌面端使用多列)
|
||||
const renderResponsiveGrid = () => {
|
||||
if (isMobile && !isTablet) {
|
||||
// 移动端使用瀑布流布局(2列)
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.waterfallScroll}
|
||||
contentContainerStyle={[
|
||||
styles.waterfallContainer,
|
||||
{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={handleGridScroll}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderLatestCapsules()}
|
||||
<View style={styles.waterfallColumnsRow}>
|
||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
||||
</View>
|
||||
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */}
|
||||
{isLoadingMore && displayPosts.length > 0 && (
|
||||
<View style={styles.loadingMoreFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 平板/桌面端使用 ResponsiveGrid 组件
|
||||
// 渲染网格项(用于 FlashList 多列模式)
|
||||
const renderGridItem = useCallback<ListRenderItem<Post>>(({ item }) => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<ResponsiveGrid
|
||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
|
||||
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
|
||||
>
|
||||
{displayPosts.map(post => {
|
||||
const authorId = post.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
variant={viewMode === 'grid' ? 'grid' : 'list'}
|
||||
onAction={(action) => stableOnPostAction(post.id, action)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGrid>
|
||||
<View style={styles.gridItem}>
|
||||
<PostCard
|
||||
post={item}
|
||||
variant="grid"
|
||||
onAction={(action) => stableOnPostAction(item.id, action)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [currentUser?.id, stableOnPostAction]);
|
||||
|
||||
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
|
||||
const renderResponsiveGrid = () => {
|
||||
return (
|
||||
<FlashList
|
||||
ref={scrollViewRef as any}
|
||||
data={displayPosts}
|
||||
renderItem={renderGridItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={gridColumns}
|
||||
masonry
|
||||
optimizeItemArrangement
|
||||
ListHeaderComponent={renderLatestCapsules()}
|
||||
ListHeaderComponentStyle={styles.listHeader}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={refresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
onScroll={handleGridScroll}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
|
||||
drawDistance={250}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -926,23 +810,19 @@ export const HomeScreen: React.FC = () => {
|
||||
return renderResponsiveGrid();
|
||||
}
|
||||
|
||||
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
||||
// 移动端和宽屏都使用单列 FlashList,宽屏下居中显示
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
data={displayPosts}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={keyExtractor}
|
||||
ListHeaderComponent={renderLatestCapsules()}
|
||||
ListHeaderComponentStyle={styles.listHeader}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
paddingHorizontal: listHorizontalPadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
alignItems: 'center', // 确保子项居中
|
||||
}
|
||||
]}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: listHorizontalPadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -958,11 +838,7 @@ export const HomeScreen: React.FC = () => {
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
updateCellsBatchingPeriod={60}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={false}
|
||||
drawDistance={250}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user