ArkUI 选项卡(对照 Android):用 Tabs 组织“同级页面切换”,并映射到 TabRow/NavigationBar/ViewPager2

线性、层叠、弹性、相对、栅格和动态布局解决的是“页面内部怎么摆”。到 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。

videoRecord_20260619_124753

Android View:`TabLayout + ViewPager2`

传统 View 体系里,顶部频道常见写法是 TabLayout + ViewPager2ViewPager2 管页面,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 + 左右滑动 + 页面预加载”,再把 TabRowHorizontalPager 组合起来;如果只是底部一级入口,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

  1. 先判断 tab 语义:顶部频道,还是底部一级入口。
  2. 父组件只保存当前 tab 的 index/key,不保存每个页面的业务状态。
  3. 每个 tab 做成独立组件,筛选、输入、列表位置放在组件内部或页面级状态里。
  4. 如果外部入口要落到某个 tab,用稳定 key,不要把业务逻辑绑定到 index。
  5. 详情页、设置页、编辑页不要塞进 tab 分支,交给导航栈处理。

参考素材(精选)

// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-06-19

上一篇