feat: optimize home screen data loading with pagination and refresh functionality

This commit is contained in:
imeepos
2026-01-26 17:33:21 +08:00
parent 23fae07a58
commit 818f6e8591
6 changed files with 391 additions and 107 deletions

View File

@@ -1,38 +1,63 @@
import { useLocalSearchParams, useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { useEffect } from 'react'
import React, { useEffect, useCallback, useState, useMemo } from 'react'
import {
Platform,
ScrollView,
FlatList,
StyleSheet,
StatusBar as RNStatusBar,
View
View,
ActivityIndicator,
RefreshControl,
Dimensions,
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { TitleBar, HeroSlider, TabNavigation, TemplateGrid } from '@/components/blocks/home'
import { TitleBar, HeroSlider, TabNavigation, TemplateCard } from '@/components/blocks/home'
import ErrorState from '@/components/ErrorState'
import LoadingState from '@/components/LoadingState'
import { useActivates } from '@/hooks/use-activates'
import { useCategories } from '@/hooks/use-categories'
import { useCategoriesWithTags } from '@/hooks/use-categories-with-tags'
import { useCategoryTemplates } from '@/hooks/use-category-templates'
import { useStickyTabs } from '@/hooks/use-sticky-tabs'
import { useTabNavigation } from '@/hooks/use-tab-navigation'
import { useTemplateFilter } from '@/hooks/use-template-filter'
const NUM_COLUMNS = 3
const HORIZONTAL_PADDING = 16
const CARD_GAP = 5
const SCREEN_WIDTH = Dimensions.get('window').width
// 计算卡片宽度
const calculateCardWidth = () => {
return (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - CARD_GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
}
const CARD_WIDTH = calculateCardWidth()
export default function HomeScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const params = useLocalSearchParams()
// 数据加载
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
// 分类数据加载
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategoriesWithTags()
const { load: loadActivates, data: activatesData } = useActivates()
// 模板数据加载(分页)
const {
templates,
loading: templatesLoading,
loadingMore,
execute: loadTemplates,
loadMore,
hasMore,
} = useCategoryTemplates()
// 标签导航状态
const {
activeIndex,
selectedCategoryId,
currentCategory,
tabs,
selectTab,
selectCategoryById,
@@ -51,9 +76,9 @@ export default function HomeScreen() {
handleTitleBarLayout,
} = useStickyTabs()
// 模板过滤
// 模板过滤 - 使用 useMemo 缓存
const { filteredTemplates } = useTemplateFilter({
templates: currentCategory?.templates || [],
templates,
excludeVideo: true,
})
@@ -63,6 +88,13 @@ export default function HomeScreen() {
loadActivates()
}, [])
// 当分类变化时加载模板
useEffect(() => {
if (selectedCategoryId) {
loadTemplates({ categoryId: selectedCategoryId })
}
}, [selectedCategoryId])
// 路由参数同步
useEffect(() => {
if (params.categoryId) {
@@ -70,57 +102,72 @@ export default function HomeScreen() {
}
}, [params.categoryId])
// 状态判断
// 状态判断 - 使用 useMemo 缓存
const showLoading = categoriesLoading
const showEmptyState = !categoriesLoading && categoriesData?.categories?.length === 0
const showEmptyTemplates = !categoriesLoading && !showEmptyState && filteredTemplates.length === 0
const showEmptyState = useMemo(() =>
!categoriesLoading && categoriesData?.categories?.length === 0,
[categoriesLoading, categoriesData?.categories?.length]
)
const showEmptyTemplates = useMemo(() =>
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0,
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length]
)
// 导航处理
const handlePointsPress = () => router.push('/membership' as any)
const handleSearchPress = () => router.push('/searchTemplate')
const handleActivityPress = (link: string) => router.push(link as any)
const handleArrowPress = () => router.push({
const [refreshing, setRefreshing] = useState(false)
// 下拉刷新处理
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([
loadCategories(),
selectedCategoryId ? loadTemplates({ categoryId: selectedCategoryId }) : Promise.resolve(),
])
setRefreshing(false)
}, [selectedCategoryId, loadCategories, loadTemplates])
// 加载更多处理
const handleEndReached = useCallback(() => {
if (!loadingMore && hasMore && !templatesLoading) {
loadMore()
}
}, [loadingMore, hasMore, templatesLoading, loadMore])
// 导航处理 - 使用 useCallback memoize
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router])
const handleActivityPress = useCallback((link: string) => router.push(link as any), [router])
const handleArrowPress = useCallback(() => router.push({
pathname: '/channels' as any,
params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
})
const handleTemplatePress = (id: string) => router.push({
}), [router, selectedCategoryId])
const handleTemplatePress = useCallback((id: string) => router.push({
pathname: '/templateDetail' as any,
params: { id },
})
}), [router])
// 渲染模板卡片
const renderTemplateItem = useCallback(({ item }: { item: typeof filteredTemplates[0] }) => {
if (!item.id) return null
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<TitleBar
onPointsPress={handlePointsPress}
onSearchPress={handleSearchPress}
onLayout={handleTitleBarLayout}
<TemplateCard
id={item.id}
title={item.title}
previewUrl={item.previewUrl}
webpPreviewUrl={item.webpPreviewUrl}
coverImageUrl={item.coverImageUrl}
aspectRatio={item.aspectRatio}
cardWidth={CARD_WIDTH}
onPress={handleTemplatePress}
/>
)
}, [handleTemplatePress])
{/* 吸顶标签导航 */}
{isSticky && !showLoading && !showEmptyState && (
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
<TabNavigation
tabs={tabs}
activeIndex={activeIndex}
onTabPress={selectTab}
showArrow={true}
onArrowPress={handleArrowPress}
isSticky={true}
/>
</View>
)}
// 提取 key
const keyExtractor = useCallback((item: typeof filteredTemplates[0]) => item.id || '', [])
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
>
// 列表头部组件
const ListHeaderComponent = useMemo(() => (
<>
{/* 活动轮播图 */}
<HeroSlider
activities={activatesData?.activities || []}
@@ -148,7 +195,7 @@ export default function HomeScreen() {
{/* 错误状态 */}
{categoriesError && (
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} />
<ErrorState message="加载分类失败" onRetry={() => loadCategories()} variant="error" />
)}
{/* 空状态 - 分类数据为空 */}
@@ -158,17 +205,118 @@ export default function HomeScreen() {
{/* 空状态 - 当前分类下没有模板 */}
{showEmptyTemplates && (
<ErrorState message="该分类暂无模板" onRetry={() => loadCategories()} />
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
)}
{/* 模板网格 */}
{!showLoading && !showEmptyState && !showEmptyTemplates && (
<TemplateGrid
templates={filteredTemplates}
onTemplatePress={handleTemplatePress}
/>
{/* 模板加载中 */}
{templatesLoading && !showLoading && (
<View style={styles.templatesLoadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</ScrollView>
</>
), [
activatesData?.activities,
handleActivityPress,
showLoading,
showEmptyState,
isSticky,
tabsHeight,
tabs,
activeIndex,
selectTab,
handleArrowPress,
categoriesError,
showEmptyTemplates,
templatesLoading,
selectedCategoryId,
handleTabsLayout,
loadCategories,
loadTemplates,
])
// 列表底部组件
const ListFooterComponent = useMemo(() => {
if (loadingMore) {
return (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
)
}
return null
}, [loadingMore])
// 获取有效的模板列表(过滤掉没有 id 的)
const validTemplates = useMemo(() =>
filteredTemplates.filter(t => !!t.id),
[filteredTemplates]
)
// 只有在非加载状态且有数据时才显示列表
const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<TitleBar
onPointsPress={handlePointsPress}
onSearchPress={handleSearchPress}
onLayout={handleTitleBarLayout}
/>
{/* 吸顶标签导航 */}
{isSticky && !showLoading && !showEmptyState && (
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
<TabNavigation
tabs={tabs}
activeIndex={activeIndex}
onTabPress={selectTab}
showArrow={true}
onArrowPress={handleArrowPress}
isSticky={true}
/>
</View>
)}
<FlatList
data={showTemplateList ? validTemplates : []}
keyExtractor={keyExtractor}
renderItem={renderTemplateItem}
numColumns={NUM_COLUMNS}
columnWrapperStyle={styles.columnWrapper}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
removeClippedSubviews={Platform.OS === 'android'}
maxToRenderPerBatch={12}
windowSize={5}
initialNumToRender={12}
getItemLayout={(_, index) => ({
length: CARD_WIDTH * 1.5,
offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS),
index,
})}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#9966FF"
colors={['#9966FF', '#FF6699', '#FF9966']}
progressBackgroundColor="#1C1E22"
progressViewOffset={10}
/>
}
/>
</SafeAreaView>
)
}
@@ -183,9 +331,14 @@ const styles = StyleSheet.create({
backgroundColor: '#090A0B',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: HORIZONTAL_PADDING,
paddingBottom: 20,
backgroundColor: '#090A0B',
},
columnWrapper: {
gap: CARD_GAP,
marginBottom: CARD_GAP,
},
stickyTabsWrapper: {
position: 'absolute',
top: 0,
@@ -194,4 +347,14 @@ const styles = StyleSheet.create({
zIndex: 100,
backgroundColor: '#090A0B',
},
templatesLoadingContainer: {
paddingVertical: 40,
alignItems: 'center',
justifyContent: 'center',
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
})

View File

@@ -1,21 +1,118 @@
import React from 'react'
import { View, TouchableOpacity, ViewProps } from 'react-native'
import { View, Pressable, ViewProps, StyleSheet } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import Svg, { Path, Circle, Defs, Rect, LinearGradient as SvgLinearGradient, Stop } from 'react-native-svg'
import Text from './ui/Text'
interface ErrorStateProps extends ViewProps {
message?: string
onRetry?: () => void
/** 是否显示为空状态图标(默认)或错误图标 */
variant?: 'empty' | 'error'
}
export default function ErrorState({ message = 'An error occurred', onRetry, testID, ...props }: ErrorStateProps) {
/** 空状态图标 - 文件夹 */
function EmptyIcon() {
return (
<View testID={testID} className="flex-1 items-center justify-center px-4" {...props}>
<Text className="text-center text-gray-600 dark:text-gray-400">{message}</Text>
<Svg width={80} height={80} viewBox="0 0 80 80" fill="none">
<Defs>
<SvgLinearGradient id="folderGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<Stop offset="0%" stopColor="#9966FF" stopOpacity={0.3} />
<Stop offset="100%" stopColor="#FF6699" stopOpacity={0.3} />
</SvgLinearGradient>
</Defs>
<Rect x="10" y="20" width="60" height="45" rx="6" fill="url(#folderGrad)" stroke="#4A4A4A" strokeWidth="2" />
<Path d="M10 26C10 22.6863 12.6863 20 16 20H30L36 14H64C67.3137 14 70 16.6863 70 20V26H10Z" fill="#2A2A2A" stroke="#4A4A4A" strokeWidth="2" />
<Circle cx="40" cy="45" r="12" stroke="#6B6B6B" strokeWidth="2" strokeDasharray="4 4" />
<Path d="M36 45H44M40 41V49" stroke="#6B6B6B" strokeWidth="2" strokeLinecap="round" />
</Svg>
)
}
/** 错误状态图标 */
function ErrorIcon() {
return (
<Svg width={80} height={80} viewBox="0 0 80 80" fill="none">
<Circle cx="40" cy="40" r="28" stroke="#FF6B6B" strokeWidth="2" strokeOpacity={0.5} />
<Circle cx="40" cy="40" r="20" fill="#2A2A2A" stroke="#FF6B6B" strokeWidth="2" />
<Path d="M40 30V44" stroke="#FF6B6B" strokeWidth="3" strokeLinecap="round" />
<Circle cx="40" cy="52" r="2" fill="#FF6B6B" />
</Svg>
)
}
export default function ErrorState({
message = 'An error occurred',
onRetry,
testID,
variant = 'empty',
...props
}: ErrorStateProps) {
return (
<View testID={testID} style={styles.container} {...props}>
{/* 图标 */}
<View style={styles.iconContainer}>
{variant === 'error' ? <ErrorIcon /> : <EmptyIcon />}
</View>
{/* 消息文本 */}
<Text style={styles.message}>{message}</Text>
{/* 重试按钮 - 使用渐变边框风格 */}
{onRetry && (
<TouchableOpacity onPress={onRetry} className="mt-4 px-6 py-2 bg-blue-500 rounded-lg">
<Text className="text-white">Retry</Text>
</TouchableOpacity>
<Pressable onPress={onRetry} style={styles.retryButton}>
<LinearGradient
colors={['#9966FF', '#FF6699', '#FF9966']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBorder}
>
<View style={styles.buttonInner}>
<Text style={styles.buttonText}></Text>
</View>
</LinearGradient>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
minHeight: 280,
},
iconContainer: {
marginBottom: 20,
},
message: {
fontSize: 15,
color: '#8E8E93',
textAlign: 'center',
lineHeight: 22,
},
retryButton: {
marginTop: 24,
borderRadius: 22,
overflow: 'hidden',
},
gradientBorder: {
padding: 1.5,
borderRadius: 22,
},
buttonInner: {
backgroundColor: '#1C1E22',
paddingHorizontal: 28,
paddingVertical: 12,
borderRadius: 20,
},
buttonText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
},
})

View File

@@ -1,4 +1,4 @@
import React, { memo } from 'react'
import React, { memo, useCallback, useMemo } from 'react'
import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { Image } from 'expo-image'
import { LinearGradient } from 'expo-linear-gradient'
@@ -41,6 +41,11 @@ export function getImageUri(
return webpPreviewUrl || previewUrl || coverImageUrl
}
// 渐变颜色常量,避免每次渲染创建新数组
const GRADIENT_COLORS = ['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)'] as const
const GRADIENT_START = { x: 0, y: 0 }
const GRADIENT_END = { x: 0, y: 1 }
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
id,
title,
@@ -52,8 +57,26 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
onPress,
testID,
}) => {
const aspectRatio = parseAspectRatio(aspectRatioString)
const imageUri = getImageUri(webpPreviewUrl, previewUrl, coverImageUrl)
const aspectRatio = useMemo(() => parseAspectRatio(aspectRatioString), [aspectRatioString])
const imageUri = useMemo(() => getImageUri(webpPreviewUrl, previewUrl, coverImageUrl), [webpPreviewUrl, previewUrl, coverImageUrl])
// 使用 useCallback 缓存 onPress 回调
const handlePress = useCallback(() => {
if (id) {
onPress(id)
}
}, [id, onPress])
// 缓存样式计算
const cardStyle = useMemo(() => [styles.card, { width: cardWidth }], [cardWidth])
const containerStyle = useMemo(() =>
[styles.cardImageContainer, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
[aspectRatio]
)
const imageStyle = useMemo(() =>
[styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
[aspectRatio]
)
// 如果没有 id则不渲染卡片
if (!id) {
@@ -62,28 +85,23 @@ const TemplateCardComponent: React.FC<TemplateCardProps> = ({
return (
<Pressable
style={[styles.card, { width: cardWidth }]}
onPress={() => onPress(id)}
style={cardStyle}
onPress={handlePress}
testID={testID}
>
<View
style={[
styles.cardImageContainer,
aspectRatio ? { aspectRatio } : undefined,
].filter(Boolean) as ViewStyle[]}
>
<View style={containerStyle}>
<Image
source={{ uri: imageUri }}
style={[
styles.cardImage,
aspectRatio ? { aspectRatio } : undefined,
].filter(Boolean) as ViewStyle[]}
style={imageStyle}
contentFit="cover"
cachePolicy="memory-disk"
recyclingKey={id}
transition={200}
/>
<LinearGradient
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
colors={GRADIENT_COLORS}
start={GRADIENT_START}
end={GRADIENT_END}
style={styles.cardImageGradient}
/>
<Text style={styles.cardTitle} numberOfLines={1}>

View File

@@ -1,12 +1,13 @@
import React, { useState, memo } from 'react'
import { View, StyleSheet, LayoutChangeEvent } from 'react-native'
import type { CategoryTemplate } from '@repo/sdk'
import type { CategoryTemplate, TemplateDetail } from '@repo/sdk'
import { TemplateCard } from './TemplateCard'
export type Template = CategoryTemplate
// 支持 CategoryTemplate 和 TemplateDetail 两种类型
export type Template = CategoryTemplate | TemplateDetail
export interface TemplateGridProps {
templates: CategoryTemplate[]
templates: Template[]
onTemplatePress: (id: string) => void
numColumns?: number
horizontalPadding?: number
@@ -40,7 +41,7 @@ const TemplateGridComponent: React.FC<TemplateGridProps> = ({
const [gridWidth, setGridWidth] = useState(0)
// 过滤掉没有 id 的模板
const validTemplates = templates.filter((template): template is CategoryTemplate & { id: string } =>
const validTemplates = templates.filter((template): template is Template & { id: string } =>
!!template.id
)

View File

@@ -7,7 +7,7 @@ import { type ApiError } from '@/lib/types'
import { OWNER_ID } from '@/lib/auth'
import { handleError } from './use-error'
type ListCategoryTemplatesParams = Omit<ListTemplatesInput, 'ownerId'>
type ListCategoryTemplatesParams = Partial<Omit<ListTemplatesInput, 'ownerId'>>
interface UseCategoryTemplatesOptions {
categoryId?: string

View File

@@ -1,19 +1,24 @@
import { useMemo } from 'react'
import type { CategoryTemplate } from '@repo/sdk'
import type { CategoryTemplate, TemplateDetail } from '@repo/sdk'
/**
* Template type that can be filtered (supports both CategoryTemplate and TemplateDetail)
*/
type FilterableTemplate = CategoryTemplate | TemplateDetail
/**
* Options for useTemplateFilter hook
*/
export interface UseTemplateFilterOptions {
templates: CategoryTemplate[]
export interface UseTemplateFilterOptions<T extends FilterableTemplate = FilterableTemplate> {
templates: T[]
excludeVideo?: boolean
}
/**
* Return type for useTemplateFilter hook
*/
export interface UseTemplateFilterReturn {
filteredTemplates: CategoryTemplate[]
export interface UseTemplateFilterReturn<T extends FilterableTemplate = FilterableTemplate> {
filteredTemplates: T[]
}
/**
@@ -29,7 +34,7 @@ const VIDEO_EXTENSIONS = ['mp4', 'mov', 'webm']
* @param options.excludeVideo - Whether to exclude video templates (default: false)
* @returns Object containing filtered templates
*/
export function useTemplateFilter(options: UseTemplateFilterOptions): UseTemplateFilterReturn {
export function useTemplateFilter<T extends FilterableTemplate>(options: UseTemplateFilterOptions<T>): UseTemplateFilterReturn<T> {
const { templates, excludeVideo = false } = options
const filteredTemplates = useMemo(() => {