线性、层叠、弹性、相对、栅格和动态布局解决的是“页面内部怎么摆”。到 Tabs 这一篇,问题换成了页面组织:同一层级的多个内容面板,怎么切换,状态放在哪里,底部入口和顶部频道要不要写成同一种结构。
先看一个首页场景:
- 顶部频道:推荐 / 关注 / 热榜。
- 底部入口:首页 / 发现 / 我的。
这两类都可以用 tab 思路处理,但语义不一样。顶部频道更像同一页面里的内容分类;底部入口更像应用的一组一级模块。写代码前先分清这一点,后面状态和导航才不会混在一起。
Tabs 解决同级页面切换
Tabs 不是路由栈。它适合放同一层级的内容面板,切换时通常不入栈。
Tabs:推荐、关注、热榜之间切换。Navigation:从列表进入详情、从首页进入设置。
如果用 Tabs 做详情跳转,返回键和页面恢复会变得难处理;如果用路由栈做频道切换,用户每切一次 tab 都像打开了一个新页面,也不对。
下面把首页拆成三块:HomePage 负责容器,FeedTab / ExploreTab / ProfileTab 负责各自内容,tab key 用来表达业务身份。
ArkUI:父组件只管切换
interface HomeTabItem {
key: string
title: string
}
@Entry
@Component
struct HomePage {
@State private currentIndex: number = 0
@State private currentKey: string = 'feed'
private readonly tabs: HomeTabItem[] = [
{ key: 'feed', title: '推荐' },
{ key: 'explore', title: '发现' },
{ key: 'profile', title: '我的' }
]
private updateTab(index: number) {
this.currentIndex = index
this.currentKey = this.tabs[index].key
}
build() {
Column() {
Tabs({ index: this.currentIndex, barPosition: BarPosition.Start }) {
TabContent() {
FeedTab()
}
.tabBar(this.tabs[0].title)
TabContent() {
ExploreTab()
}
.tabBar(this.tabs[1].title)
TabContent() {
ProfileTab()
}
.tabBar(this.tabs[2].title)
}
.onChange((index: number) => {
this.updateTab(index)
})
}
.width('100%')
.height('100%')
}
}
这里有两个细节值得留意。
第一,Tabs 需要 index,所以父组件保存 currentIndex。业务侧不要只认 index,还要保存 currentKey。后面从通知、外部链接或实验开关进入某个 tab 时,用 feed / explore / profile 这种稳定 key 比用 0 / 1 / 2 更耐改。
第二,HomePage 不保存每个 tab 的筛选项、滚动位置、分页游标。这些状态放在各自的 tab 组件里。
每个 tab 自己维护状态
下面不是完整业务页,只保留状态边界。重点是:切换 tab 时,父容器只改变当前页签;每个页签内部的状态不要塞回 HomePage。
@Component
struct FeedTab {
@State private filter: string = 'latest'
build() {
Column({ space: 12 }) {
Row({ space: 8 }) {
Button('最新')
.type(ButtonType.Capsule)
.onClick(() => {
this.filter = 'latest'
})
Button('热门')
.type(ButtonType.Capsule)
.onClick(() => {
this.filter = 'hot'
})
}
Text(`当前筛选:${this.filter}`)
.fontSize(14)
.fontColor('#64748B')
List() {
ForEach(['文章 A', '文章 B', '文章 C'], (title: string) => {
ListItem() {
Text(title)
.fontSize(16)
.padding(14)
}
})
}
.layoutWeight(1)
}
.padding(16)
}
}
@Component
struct ExploreTab {
@State private keyword: string = ''
build() {
Column({ space: 12 }) {
TextInput({ placeholder: '搜索主题' })
.onChange((value: string) => {
this.keyword = value
})
Text(this.keyword.length > 0 ? `搜索:${this.keyword}` : '这里放发现页内容')
.fontSize(16)
}
.padding(16)
}
}
@Component
struct ProfileTab {
@State private showOnlyMine: boolean = true
build() {
Column({ space: 12 }) {
Toggle({ type: ToggleType.Switch, isOn: this.showOnlyMine })
.onChange((checked: boolean) => {
this.showOnlyMine = checked
})
Text(this.showOnlyMine ? '只看我的内容' : '显示全部内容')
.fontSize(16)
}
.padding(16)
}
}
这个例子里,FeedTab 有筛选状态,ExploreTab 有输入状态,ProfileTab 有开关状态。父组件不知道这些细节。首页后续加埋点、改默认 tab、从通知进入某个 tab,都不会碰到每个页面内部的 UI 状态。
顶部 Tabs 和底部 Tabs
顶部 tab 和底部 tab 的代码差异很小,主要是 tab bar 的位置。产品语义差异更大。
// 顶部频道:推荐 / 关注 / 热榜
Tabs({ index: this.currentIndex, barPosition: BarPosition.Start }) {
// TabContent ...
}
// 底部一级入口:首页 / 发现 / 我的
Tabs({ index: this.currentIndex, barPosition: BarPosition.End }) {
// TabContent ...
}
顶部 tab 通常用于同一个页面内的内容分类,比如推荐流和关注流。底部 tab 更接近应用主入口,最好控制在 3 到 5 个,并且不要把详情页塞进底部 tab。

Android View:`TabLayout + ViewPager2`
传统 View 体系里,顶部频道常见写法是 TabLayout + ViewPager2。ViewPager2 管页面,TabLayoutMediator 把 tab 标题和页面位置绑定起来。
class HomeFragment : Fragment(R.layout.fragment_home) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pager = view.findViewById<ViewPager2>(R.id.homePager)
val tabs = view.findViewById<TabLayout>(R.id.homeTabs)
val pages = listOf("推荐", "发现", "我的")
pager.adapter = HomePagerAdapter(this)
TabLayoutMediator(tabs, pager) { tab, position ->
tab.text = pages[position]
}.attach()
}
}
这套组合和 ArkUI Tabs 的对应关系很直观:容器负责位置切换,页面内容交给子 Fragment 或子 View。不要把三个页面的状态全写进 HomeFragment。
Android Compose:顶部 `TabRow`,底部 `NavigationBar`
Compose 顶部频道可以用 TabRow。如果需要左右滑动,再配 HorizontalPager。
enum class HomeTab(val title: String) {
Feed("推荐"),
Explore("发现"),
Profile("我的")
}
@Composable
fun HomeTopTabs() {
var currentTab by rememberSaveable { mutableStateOf(HomeTab.Feed) }
Column {
TabRow(selectedTabIndex = currentTab.ordinal) {
HomeTab.entries.forEach { tab ->
Tab(
selected = tab == currentTab,
onClick = { currentTab = tab },
text = { Text(tab.title) }
)
}
}
when (currentTab) {
HomeTab.Feed -> FeedTab()
HomeTab.Explore -> ExploreTab()
HomeTab.Profile -> ProfileTab()
}
}
}
底部主入口更适合 NavigationBar。它看起来也像 tab,但语义更接近应用一级模块。
@Composable
fun HomeBottomBar() {
var currentTab by rememberSaveable { mutableStateOf(HomeTab.Feed) }
Scaffold(
bottomBar = {
NavigationBar {
HomeTab.entries.forEach { tab ->
NavigationBarItem(
selected = tab == currentTab,
onClick = { currentTab = tab },
icon = {},
label = { Text(tab.title) }
)
}
}
}
) { padding ->
Box(Modifier.padding(padding)) {
when (currentTab) {
HomeTab.Feed -> FeedTab()
HomeTab.Explore -> ExploreTab()
HomeTab.Profile -> ProfileTab()
}
}
}
}
如果需要“点 tab + 左右滑动 + 页面预加载”,再把 TabRow 和 HorizontalPager 组合起来;如果只是底部一级入口,NavigationBar 会更贴近 Android 侧的产品语义。
对照表:先分清语义,再选组件
| 需求 | ArkUI | Android View | Android Compose |
|---|---|---|---|
| 顶部频道切换 | Tabs + BarPosition.Start |
TabLayout + ViewPager2 |
TabRow + HorizontalPager |
| 底部一级入口 | Tabs + BarPosition.End 或自定义底部栏 |
BottomNavigationView + NavHostFragment |
NavigationBar + NavHost 或本地状态切换 |
| 保留每页状态 | 状态留在 TabContent 内部页面 |
子 Fragment / RecyclerView 自己管理 | rememberSaveable,每个页面独立管理 |
| 支持左右滑动 | Tabs 的滑动能力 |
ViewPager2 |
HorizontalPager |
| 详情页跳转 | 交给 Navigation / router |
交给 Navigation / Fragment stack | 交给 Navigation Compose |
可以参考的 checklist
- 先判断 tab 语义:顶部频道,还是底部一级入口。
- 父组件只保存当前 tab 的 index/key,不保存每个页面的业务状态。
- 每个 tab 做成独立组件,筛选、输入、列表位置放在组件内部或页面级状态里。
- 如果外部入口要落到某个 tab,用稳定 key,不要把业务逻辑绑定到 index。
- 详情页、设置页、编辑页不要塞进 tab 分支,交给导航栈处理。
参考素材(精选)
- HarmonyOS:Tabs
- HarmonyOS:TabContent
- Android Developers:Tabs in Compose
- Android Developers:Pager in Compose
- Android Developers:ViewPager2
- Android Developers:TabLayoutMediator
// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-06-19