跳到主要内容Flutter TabBar 标签系统实战:从基础到高级定制 | 极客日志Dart大前端
Flutter TabBar 标签系统实战:从基础到高级定制
Flutter TabBar 组件的完整实践记录,涵盖固定标签、滚动标签、图标标签、自定义指示器、动画切换、分段标签以及底部导航栏的实现代码。同时整理了性能优化技巧和 OpenHarmony 平台上的手势冲突、动画帧率等适配经验。
干移动开发的都知道,标签导航是绕不开的。Flutter 提供了 TabBar 这一套组件,用熟了能省很多事,但真要定制出点花样,还是得把它的脾气摸透。下面是我折腾一套标签系统时积累的代码和体会,从固定标签、滚动标签,到自定义指示器、动画切换,再到 OpenHarmony 上的一些坑,都记下来了。
TabBar 是怎么转起来的
Flutter 的 TabBar 其实是一套协作体系:
┌─────────────────────────────────────────────────────────────────┐
│ 应用层:TabBar, TabBarView, TabController │
├─────────────────────────────────────────────────────────────────┤
│ 指示器层:UnderlineTabIndicator, BoxDecoration 等 │
├─────────────────────────────────────────────────────────────────┤
│ 动画层:AnimationController, Tween, CurvedAnimation │
├─────────────────────────────────────────────────────────────────┤
│ 状态管理层:TabController, ChangeNotifier │
└─────────────────────────────────────────────────────────────────┘
核心就三个东西:TabBar 管显示标签,TabBarView 管内容切换,TabController 在中间协调。大部分需求靠 TabController 都能搞定,比如监听切换、程序跳转。
final tabController = TabController(length: 3, vsync: this);
tabController.addListener(() {
if (!tabController.indexIsChanging) {
print('当前标签:${tabController.index}');
}
});
tabController.animateTo(1);
设计标签时别太花,几个原则心里有数就行:标签意思清楚、点击区域够大、切换状态明显、数量别太多。
先来几个基础的
固定标签
最常见不过了,底部三四个图标那种。
import 'package:flutter/material.dart';
class FixedTabDemo extends StatefulWidget {
const FixedTabDemo({super.key});
@override
State<FixedTabDemo> createState() => _FixedTabDemoState();
}
class _FixedTabDemoState extends State<FixedTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('固定标签导航'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '首页'),
Tab(text: '发现'),
Tab(text: '我的'),
],
indicatorColor: Colors.blue,
labelColor: Colors.blue,
unselectedLabelColor: Colors.grey,
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildTabPage('首页', Colors.blue),
_buildTabPage('发现', Colors.green),
_buildTabPage('我的', Colors.orange),
],
),
);
}
Widget _buildTabPage(String title, Color color) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 20,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
),
title: Text('$title - ${index + 1}'),
subtitle: Text('这是 $title 的第 ${index + 1} 项'),
),
);
},
);
}
}
滚动标签
如果标签多,最上面那种可滑动的分类栏,把 isScrollable 打开就行。
class ScrollableTabDemo extends StatefulWidget {
const ScrollableTabDemo({super.key});
@override
State<ScrollableTabDemo> createState() => _ScrollableTabDemoState();
}
class _ScrollableTabDemoState extends State<ScrollableTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = [
'推荐', '热门', '视频', '小说', '娱乐',
'科技', '体育', '财经', '军事', '历史'
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('滚动标签导航'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: _tabs.map((tab) => Tab(text: tab)).toList(),
indicatorColor: Colors.teal,
labelColor: Colors.teal,
unselectedLabelColor: Colors.grey,
labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
unselectedLabelStyle: const TextStyle(fontSize: 14),
),
),
body: TabBarView(
controller: _tabController,
children: _tabs.map((tab) => _buildTabPage(tab)).toList(),
),
);
}
Widget _buildTabPage(String title) {
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.8,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 10,
itemBuilder: (context, index) {
return Card(
child: Column(
children: [
Expanded(
child: Container(
color: Colors.primaries[index % Colors.primaries.length].withOpacity(0.2),
child: Center(child: Text('$title - ${index + 1}')),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text('$title 内容 ${index + 1}'),
),
],
),
);
},
);
}
}
图标标签
class IconTabDemo extends StatefulWidget {
const IconTabDemo({super.key});
@override
State<IconTabDemo> createState() => _IconTabDemoState();
}
class _IconTabDemoState extends State<IconTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('图标标签导航'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.home), text: '首页'),
Tab(icon: Icon(Icons.search), text: '搜索'),
Tab(icon: Icon(Icons.favorite), text: '收藏'),
Tab(icon: Icon(Icons.person), text: '我的'),
],
indicatorColor: Colors.purple,
labelColor: Colors.purple,
unselectedLabelColor: Colors.grey,
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildIconPage(Icons.home, '首页', Colors.blue),
_buildIconPage(Icons.search, '搜索', Colors.green),
_buildIconPage(Icons.favorite, '收藏', Colors.red),
_buildIconPage(Icons.person, '我的', Colors.orange),
],
),
);
}
Widget _buildIconPage(IconData icon, String title, Color color) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 50, color: color),
),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
],
),
);
}
}
搞点不一样的:自定义指示器、动画和底部栏
自定义指示器
默认那条横线太素,可以自己画一个圆角背景。通过继承 Decoration 和 BoxPainter 来画。
class CustomIndicatorDemo extends StatefulWidget {
const CustomIndicatorDemo({super.key});
@override
State<CustomIndicatorDemo> createState() => _CustomIndicatorDemoState();
}
class _CustomIndicatorDemoState extends State<CustomIndicatorDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('自定义指示器'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '推荐'),
Tab(text: '热门'),
Tab(text: '最新'),
Tab(text: '关注'),
],
indicator: _CustomTabIndicator(color: Colors.blue, radius: 20),
labelColor: Colors.white,
unselectedLabelColor: Colors.grey,
),
),
body: TabBarView(
controller: _tabController,
children: List.generate(4, (index) => Center(child: Text('页面 ${index + 1}'))),
),
);
}
}
class _CustomTabIndicator extends Decoration {
final Color color;
final double radius;
const _CustomTabIndicator({required this.color, this.radius = 20});
@override
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
return _CustomTabIndicatorPainter(color, radius);
}
}
class _CustomTabIndicatorPainter extends BoxPainter {
final Color color;
final double radius;
_CustomTabIndicatorPainter(this.color, this.radius);
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
final rect = offset & configuration.size!;
final paint = Paint()..color = color..style = PaintingStyle.fill;
final rrect = RRect.fromRectAndRadius(
Rect.fromCenter(center: rect.center, width: rect.width - 16, height: rect.height - 8),
Radius.circular(radius),
);
canvas.drawRRect(rrect, paint);
}
}
动画标签切换
让选中态滑过去,用 AnimatedPositioned 配合自绘的指示条,比默认的切换更流畅。
class AnimatedTabDemo extends StatefulWidget {
const AnimatedTabDemo({super.key});
@override
State<AnimatedTabDemo> createState() => _AnimatedTabDemoState();
}
class _AnimatedTabDemoState extends State<AnimatedTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = ['消息', '通讯录', '发现', '我'];
int _currentIndex = 0;
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) {
setState(() => _currentIndex = _tabController.index);
}
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画标签切换')),
body: Column(
children: [
_buildAnimatedTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: _tabs.map((tab) => _buildTabPage(tab)).toList(),
),
),
],
),
);
}
Widget _buildAnimatedTabBar() {
return Container(
height: 56,
color: Colors.white,
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: _currentIndex * (MediaQuery.of(context).size.width / _tabs.length),
top: 0,
child: Container(
width: MediaQuery.of(context).size.width / _tabs.length,
height: 56,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.blue, width: 3)),
),
),
),
Row(
children: List.generate(_tabs.length, (index) {
final isSelected = index == _currentIndex;
return Expanded(
child: GestureDetector(
onTap: () => _tabController.animateTo(index),
behavior: HitTestBehavior.opaque,
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
color: isSelected ? Colors.blue : Colors.grey,
fontSize: isSelected ? 16 : 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
child: Center(child: Text(_tabs[index])),
),
),
);
}),
),
],
),
);
}
Widget _buildTabPage(String title) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 15,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text('$title - ${index + 1}'),
subtitle: Text('这是 $title 的内容'),
),
);
},
);
}
}
分段标签
类似 iOS 那种圆角背景的分段控制器,设置 indicator 为 BoxDecoration,再调一下颜色和阴影。
class SegmentedTabDemo extends StatefulWidget {
const SegmentedTabDemo({super.key});
@override
State<SegmentedTabDemo> createState() => _SegmentedTabDemoState();
}
class _SegmentedTabDemoState extends State<SegmentedTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> _tabs = ['日', '周', '月', '年'];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分段标签')),
body: Column(
children: [
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
tabs: _tabs.map((tab) => Tab(text: tab)).toList(),
indicator: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(color: Colors.blue.withOpacity(0.3), blurRadius: 4, offset: const Offset(0, 2)),
],
),
labelColor: Colors.white,
unselectedLabelColor: Colors.grey[700],
dividerColor: Colors.transparent,
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: _tabs.map((tab) => _buildTabPage(tab)).toList(),
),
),
],
),
);
}
Widget _buildTabPage(String title) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.calendar_today, size: 80, color: Colors.blue),
const SizedBox(height: 16),
Text('$title 统计数据', style: const TextStyle(fontSize: 24)),
],
),
);
}
}
底部标签栏
主屏幕最常用到的底部导航,其实可以完全用 TabBarView 加自定义底部栏来实现,灵活度更高。
class BottomTabDemo extends StatefulWidget {
const BottomTabDemo({super.key});
@override
State<BottomTabDemo> createState() => _BottomTabDemoState();
}
class _BottomTabDemoState extends State<BottomTabDemo>
with SingleTickerProviderStateMixin {
late TabController _tabController;
int _currentIndex = 0;
final List<_TabItem> _tabs = [
_TabItem(icon: Icons.home, activeIcon: Icons.home_filled, title: '首页'),
_TabItem(icon: Icons.search, activeIcon: Icons.search, title: '发现'),
_TabItem(icon: Icons.add_box_outlined, activeIcon: Icons.add_box, title: '发布'),
_TabItem(icon: Icons.favorite_border, activeIcon: Icons.favorite, title: '消息'),
_TabItem(icon: Icons.person_outline, activeIcon: Icons.person, title: '我的'),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() => _currentIndex = _tabController.index);
}
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
controller: _tabController,
physics: const NeverScrollableScrollPhysics(),
children: _tabs.map((tab) => _buildTabPage(tab.title)).toList(),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 10)],
),
child: SafeArea(
child: SizedBox(
height: 60,
child: Row(
children: List.generate(_tabs.length, (index) {
final tab = _tabs[index];
final isSelected = index == _currentIndex;
return Expanded(
child: GestureDetector(
onTap: () => _tabController.animateTo(index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
isSelected ? tab.activeIcon : tab.icon,
color: isSelected ? Colors.blue : Colors.grey,
size: isSelected ? 28 : 24,
),
),
const SizedBox(height: 4),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
color: isSelected ? Colors.blue : Colors.grey,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
child: Text(tab.title),
),
],
),
),
);
}),
),
),
),
),
);
}
Widget _buildTabPage(String title) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.home, size: 50, color: Colors.blue),
),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
],
),
);
}
}
class _TabItem {
final IconData icon;
final IconData activeIcon;
final String title;
const _TabItem({required this.icon, required this.activeIcon, required this.title});
}
整个入口页面,把例子串起来
把上面的 Demo 全放进一个列表页,方便跳转查看。代码比较直白,就是一堆卡片。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const TabBarHomePage(),
);
}
}
class TabBarHomePage extends StatelessWidget {
const TabBarHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('📑 TabBar 高级标签系统')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSectionCard(context, title: '固定标签', description: '基础固定标签导航', icon: Icons.tab, color: Colors.blue, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const FixedTabDemo()))),
_buildSectionCard(context, title: '滚动标签', description: '可滑动标签导航', icon: Icons.swipe, color: Colors.teal, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ScrollableTabDemo()))),
_buildSectionCard(context, title: '图标标签', description: '图标 + 文字标签', icon: Icons.image, color: Colors.purple, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const IconTabDemo()))),
_buildSectionCard(context, title: '自定义指示器', description: '圆角背景指示器', icon: Icons.brush, color: Colors.orange, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomIndicatorDemo()))),
_buildSectionCard(context, title: '动画标签', description: '动画切换效果', icon: Icons.animation, color: Colors.pink, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AnimatedTabDemo()))),
_buildSectionCard(context, title: '分段标签', description: 'iOS 风格分段', icon: Icons.view_module, color: Colors.indigo, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SegmentedTabDemo()))),
_buildSectionCard(context, title: '底部标签栏', description: '底部导航栏', icon: Icons.navigation, color: Colors.cyan, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BottomTabDemo()))),
],
),
);
}
Widget _buildSectionCard(BuildContext context, {required String title, required String description, required IconData icon, required Color color, required VoidCallback onTap}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(width: 56, height: 56, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: color, size: 28)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(description, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey[400]),
],
),
),
),
);
}
}
// ... (此处省略重复的 Demo 类定义,参考上文各章节代码)
一些实践建议和 OpenHarmony 上的坑
性能方面,能加 const 的地方都加上;如果标签页需要保持状态,别忘 AutomaticKeepAliveClientMixin。
class _TabPageState extends State<TabPage> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return ListView(...);
}
}
在 OpenHarmony 上跑的时候,遇到过手势冲突和动画帧率问题。尤其是滑动切换时和系统侧边栏手势容易打架,可以尝试调一下 physics。屏幕适配倒还好,Flutter 本身做得不错,但极少数设备上底部安全区需要额外处理一下。
总体来说,TabBar 这套东西覆盖面很广,大部分需求都能通过组合解决。别一上来就自己造轮子,先看看源码里能不能改。
参考资料
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online