ArkUI Tabs 切换后状态丢失怎么办:中式美食分类页怎么保留关键词、筛选和滚动位置

中式美食的分类页不是只有一个"切换 Tab"的动作。用户可能在"川菜"里搜了"家常",又按难度筛了一下,再往下滑到二十几条;这时他切到"收藏"看一眼,再切回来,如果页面又回到顶部、关键词没了、筛选也没了,用户会觉得刚才的操作白做了。这个问题在代码里经常表现为:Tab 一切换就重新初始化列表状态。
我更倾向于把每个 Tab 当成一个独立状态桶。川菜有川菜的关键词、筛选、排序和滚动位置;收藏有收藏的;推荐也有推荐的。切换 Tab 只是换当前状态桶,不应该把所有状态都清空。
本文导读
| 问题 | 处理方式 |
|---|---|
| 切回 Tab 后关键词没了 | 每个 Tab 保存自己的 keyword |
| 筛选和排序被重置 | filter/sort 放进 TabState |
| 滚动位置回到顶部 | 离开 Tab 前缓存 firstVisibleIndex |
| 用户想重新开始筛选 | 提供明确的清空按钮,不用 Tab 切换代替清空 |
环境和文件边界
| 项目 | 内容 |
|---|---|
| 开发工具 | DevEco Studio |
| HarmonyOS SDK | API 12,Stage 模型 |
| 页面入口 | entry/src/main/ets/features/category/CategoryPage.ets |
| 状态模型 | entry/src/main/ets/features/category/CategoryTabState.ets |
| 列表组件 | entry/src/main/ets/components/RecipeList.ets |
| 数据层 | entry/src/main/ets/data/repository/RecipeRepository.ets |
错误写法:切换 Tab 就重置整页状态
ts
@Component
export struct CategoryPage {
@State currentTab: string = 'recommend'
@State keyword: string = ''
@State sort: string = 'default'
@State recipes: RecipeCardModel[] = []
async onTabChange(tab: string): Promise<void> {
this.currentTab = tab
this.keyword = ''
this.sort = 'default'
this.recipes = await this.repository.queryByTab(tab)
}
}
这段代码的问题是把"切换 Tab"和"清空条件"混在了一起。用户切到别的 Tab 只是临时去看别的内容,不代表他想把当前 Tab 的搜索和筛选都删掉。尤其是菜谱类页面,用户经常来回比较,状态丢了会非常明显。
给每个 Tab 一个状态桶
ts
export interface CategoryTabState {
tabKey: string
keyword: string
filterKey: string
sortKey: string
firstVisibleIndex: number
recipes: RecipeCardModel[]
loading: boolean
}
export function createTabState(tabKey: string): CategoryTabState {
return {
tabKey,
keyword: '',
filterKey: 'all',
sortKey: 'default',
firstVisibleIndex: 0,
recipes: [],
loading: false
}
}
状态桶的好处是边界清楚。Tab A 的关键词不会覆盖 Tab B,Tab B 的滚动位置也不会影响 Tab A。后面排查时也简单:状态丢了,就看对应 tabKey 的状态桶有没有被重建。
用 Map 管住所有 Tab
ts
export class CategoryViewModel {
private states: Map<string, CategoryTabState> = new Map()
currentTab: string = 'recommend'
getCurrentState(): CategoryTabState {
if (!this.states.has(this.currentTab)) {
this.states.set(this.currentTab, createTabState(this.currentTab))
}
return this.states.get(this.currentTab)!
}
switchTab(tabKey: string): CategoryTabState {
this.currentTab = tabKey
return this.getCurrentState()
}
}
这里关键点是:switchTab 不清空旧状态,只是切换当前指针。真正要清空时,应该走单独的方法,不要偷偷藏在 Tab 切换里。
搜索和筛选只改当前状态桶
ts
export class CategoryViewModel {
async updateKeyword(keyword: string): Promise<void> {
const state = this.getCurrentState()
state.keyword = keyword
state.firstVisibleIndex = 0
state.recipes = await this.repository.queryRecipes({
tabKey: state.tabKey,
keyword: state.keyword,
filterKey: state.filterKey,
sortKey: state.sortKey
})
}
async updateFilter(filterKey: string): Promise<void> {
const state = this.getCurrentState()
state.filterKey = filterKey
state.firstVisibleIndex = 0
state.recipes = await this.repository.queryRecipes(state)
}
}
搜索和筛选会改变列表内容,所以滚动位置回到顶部是合理的。但这个"回到顶部"只应该影响当前 Tab,不应该影响用户在其他 Tab 里已经浏览到的位置。
离开 Tab 前记住滚动位置
ts
@Component
export struct RecipeList {
@Prop state: CategoryTabState
private scroller: Scroller = new Scroller()
aboutToDisappear(): void {
this.state.firstVisibleIndex = this.scroller.currentOffset().yOffset > 0
? this.estimateFirstVisibleIndex()
: 0
}
build() {
List({ scroller: this.scroller }) {
ForEach(this.state.recipes, (item: RecipeCardModel) => {
ListItem() {
RecipeCard({ recipe: item })
}
}, (item: RecipeCardModel) => item.id)
}
.onAppear(() => {
this.scroller.scrollToIndex(this.state.firstVisibleIndex)
})
}
}
滚动位置不一定非要做到像浏览器历史那样精确,但至少不要每次切回来都回到顶部。对于中式美食这种列表页,记住 firstVisibleIndex 已经能解决大部分"我刚才看到哪了"的问题。
清空按钮单独负责重置
ts
export class CategoryViewModel {
async resetCurrentTab(): Promise<void> {
const state = this.getCurrentState()
state.keyword = ''
state.filterKey = 'all'
state.sortKey = 'default'
state.firstVisibleIndex = 0
state.loading = true
try {
state.recipes = await this.repository.queryRecipes(state)
} finally {
state.loading = false
}
}
}
清空按钮才是清空条件的入口。这样用户能理解:点清空,条件没了;切 Tab,只是换页面。两个动作分开以后,页面行为会稳定很多。
页面层怎么绑定
ts
@Component
export struct CategoryPage {
@State viewModel: CategoryViewModel = new CategoryViewModel()
build() {
Column() {
Tabs() {
TabContent() {
RecipeList({ state: this.viewModel.getCurrentState() })
}
.tabBar('推荐')
TabContent() {
RecipeList({ state: this.viewModel.switchTab('favorite') })
}
.tabBar('收藏')
}
Button('清空当前筛选')
.onClick(() => this.viewModel.resetCurrentTab())
}
}
}
真实项目里我不会在 build() 里反复创建状态,也不会把 switchTab 写得有副作用太重。这里把代码压短是为了说明分工:Tabs 只换当前状态桶,按钮才重置当前状态桶。
验收记录
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 在川菜 Tab 输入"家常"并筛选辣度 | 关键词和筛选生效 |
| 2 | 向下滚动到列表中段 | 当前 Tab 记录滚动位置 |
| 3 | 切到收藏 Tab 再切回川菜 | 关键词、筛选、滚动位置仍在 |
| 4 | 点击清空当前筛选 | 只清空当前 Tab,不影响收藏 Tab |
| 5 | 切换排序 | 当前 Tab 回到顶部,其他 Tab 不变 |
排查顺序
| 顺序 | 先看什么 | 原因 |
|---|---|---|
| 1 | Tab key 是否稳定 | key 变了状态桶就会被重建 |
| 2 | 切换 Tab 时有没有清空 keyword/filter | 切换和清空不能混在一起 |
| 3 | 滚动位置有没有写回状态桶 | 没写回就只能回顶部 |
| 4 | 清空按钮是否只清当前 Tab | 全局清空会破坏其他 Tab 的浏览状态 |
边界和风险
| 场景 | 处理方式 |
|---|---|
| Tab 数量很多 | 只缓存用户访问过的 TabState |
| 数据被后台刷新 | 保留筛选条件,重新查询当前 Tab |
| 用户主动下拉刷新 | 可以刷新 recipes,但不要自动清关键词 |
| 内存占用增加 | 只保存必要字段,不缓存大对象和图片内容 |
小结
Tabs 状态丢失,本质上不是 ArkUI 控件难用,而是业务动作没有分清。切换 Tab 是"换当前视图",清空按钮才是"重置条件"。如果把这两个动作混在一起,页面看起来能跑,但用户一来回切换就会发现刚才的操作全没了。
中式美食这里我会给每个分类一个状态桶,把关键词、筛选、排序、滚动位置都收进去。列表只读取当前状态桶,清空只清当前状态桶。这样用户在不同分类之间来回看菜谱时,页面不会突然忘记他刚才在做什么。