Flutter之TabBarView组件项目实战示例
作者:风雨_83
TabBarView
TabBarView 是 Material 组件库中提供了 Tab 布局组件,通常和 TabBar 配合使用。
TabBarView 封装了 PageView,它的构造方法:
TabBarView({ Key? key, required this.children, // tab 页 this.controller, // TabController this.physics, this.dragStartBehavior = DragStartBehavior.start, })
TabController 用于监听和控制 TabBarView 的页面切换,通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController
。
TabBar
TabBar 为 TabBarView 的导航标题,如下图所示
TabBar 有很多配置参数,通过这些参数我们可以定义 TabBar 的样式,很多属性都是在配置 indicator 和 label,拿上图来举例,Label 是每个Tab 的文本,indicator 指 “新闻” 下面的白色下划线。
const TabBar({ Key? key, required this.tabs, // 具体的 Tabs,需要我们创建 this.controller, this.isScrollable = false, // 是否可以滑动 this.padding, this.indicatorColor,// 指示器颜色,默认是高度为2的一条下划线 this.automaticIndicatorColorAdjustment = true, this.indicatorWeight = 2.0,// 指示器高度 this.indicatorPadding = EdgeInsets.zero, //指示器padding this.indicator, // 指示器 this.indicatorSize, // 指示器长度,有两个可选值,一个tab的长度,一个是label长度 this.labelColor, this.labelStyle, this.labelPadding, this.unselectedLabelColor, this.unselectedLabelStyle, this.mouseCursor, this.onTap, ... })
TabBar
通常位于 AppBar
的底部,它也可以接收一个 TabController
,如果需要和 TabBarView
联动, TabBar
和 TabBarView
使用同一个 TabController
即可,注意,联动时 TabBar
和 TabBarView
的孩子数量需要一致。如果没有指定 controller
,则会在组件树中向上查找并使用最近的一个 DefaultTabController
。另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar
, tab 可以是任何 Widget,不过Material 组件库中已经实现了一个 Tab 组件,我们一般都会直接使用它:
const Tab({ Key? key, this.text, //文本 this.icon, // 图标 this.iconMargin = const EdgeInsets.only(bottom: 10.0), this.height, this.child, // 自定义 widget })
注意,text
和 child
是互斥的,不能同时制定。
全部代码:
import 'package:flutter/material.dart'; /// @Author wywinstonwy /// @Date 2022/1/18 9:09 上午 /// @Description: class MyTabbarView1 extends StatefulWidget { const MyTabbarView1({Key? key}) : super(key: key); @override _MyTabbarView1State createState() => _MyTabbarView1State(); } class _MyTabbarView1State extends State<MyTabbarView1>with SingleTickerProviderStateMixin { List<String> tabs =['头条','新车','导购','小视频','改装赛事']; late TabController tabController; @override void initState() { // TODO: implement 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: Text('TabbarView',textAlign: TextAlign.center,), bottom:TabBar( unselectedLabelColor: Colors.white.withOpacity(0.5), labelColor: Colors.white, // indicatorSize:TabBarIndicatorSize.label, indicator:const UnderlineTabIndicator(), controller: tabController, tabs: tabs.map((e){ return Tab(text: e,); }).toList()) , ), body: Column( children: [ Expanded( flex: 1, child: TabBarView( controller: tabController, children: tabs.map((e){ return Center(child: Text(e,style: TextStyle(fontSize: 50),),); }).toList()),) ],), ); } }
运行效果:
滑动页面时顶部的 Tab 也会跟着动,点击顶部 Tab 时页面也会跟着切换。为了实现 TabBar 和 TabBarView 的联动,我们显式创建了一个 TabController,由于 TabController 又需要一个 TickerProvider (vsync 参数), 我们又混入了 SingleTickerProviderStateMixin;
由于 TabController 中会执行动画,持有一些资源,所以我们在页面销毁时必须得释放资源(dispose)。综上,我们发现创建 TabController 的过程还是比较复杂,实战中,如果需要 TabBar 和 TabBarView 联动,通常会创建一个 DefaultTabController 作为它们共同的父级组件,这样它们在执行时就会从组件树向上查找,都会使用我们指定的这个 DefaultTabController。
我们修改后的实现如下:
class TabViewRoute2 extends StatelessWidget { @override Widget build(BuildContext context) { List tabs = ["新闻", "历史", "图片"]; return DefaultTabController( length: tabs.length, child: Scaffold( appBar: AppBar( title: Text("App Name"), bottom: TabBar( tabs: tabs.map((e) => Tab(text: e)).toList(), ), ), body: TabBarView( //构建 children: tabs.map((e) { return KeepAliveWrapper( child: Container( alignment: Alignment.center, child: Text(e, textScaleFactor: 5), ), ); }).toList(), ), ), ); } }
可以看到我们无需去手动管理 Controller 的生命周期,也不需要提供 SingleTickerProviderStateMixin,同时也没有其它的状态需要管理,也就不需要用 StatefulWidget 了,这样简单很多。
TabBarView+项目实战
实现导航信息流切换效果并缓存前面数据:
1 构建导航头部搜索框
import 'package:flutter/material.dart'; import 'package:qctt_flutter/constant/colors_definition.dart'; enum SearchBarType { home, normal, homeLight } class SearchBar extends StatefulWidget { final SearchBarType searchBarType; final String hint; final String defaultText; final void Function()? inputBoxClick; final void Function()? cancelClick; final ValueChanged<String>? onChanged; SearchBar( {this.searchBarType = SearchBarType.normal, this.hint = '搜一搜你感兴趣的内容', this.defaultText = '', this.inputBoxClick, this.cancelClick, this.onChanged}); @override _SearchBarState createState() => _SearchBarState(); } class _SearchBarState extends State<SearchBar> { @override Widget build(BuildContext context) { return Container( color: Colors.white, height: 74, child: searchBarView, ); } Widget get searchBarView { if (widget.searchBarType == SearchBarType.normal) { return _genNormalSearch; } return _homeSearchBar; } Widget get _genNormalSearch { return Container( color: Colors.white, padding: EdgeInsets.only(top: 40, left: 20, right: 60, bottom: 5), child: Container( height: 30, decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: Colors.grey.withOpacity(0.5)), padding: EdgeInsets.only(left: 5, right: 5), child: Row( children: [ const Icon( Icons.search, color: Colors.grey, size: 24, ), Container(child: _inputBox), const Icon( Icons.clear, color: Colors.grey, size: 24, ) ], ), ),); } //可编辑输入框 Widget get _homeSearchBar{ return Container( padding: EdgeInsets.only(top: 40, left: 20, right: 40, bottom: 5), decoration: BoxDecoration(gradient: LinearGradient( colors: [mainColor,mainColor.withOpacity(0.2)], begin:Alignment.topCenter, end: Alignment.bottomCenter )), child: Container( height: 30, decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), color: Colors.grey.withOpacity(0.5)), padding: EdgeInsets.only(left: 5, right: 5), child: Row( children: [ const Icon( Icons.search, color: Colors.grey, size: 24, ), Container(child: _inputBox), ], ), ),); } //构建文本输入框 Widget get _inputBox { return Expanded( child: TextField( style: const TextStyle( fontSize: 18.0, color: Colors.black, fontWeight: FontWeight.w300), decoration: InputDecoration( // contentPadding: EdgeInsets.fromLTRB(1, 3, 1, 3), // contentPadding: EdgeInsets.only(bottom: 0), contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12), border: InputBorder.none, hintText: widget.hint, hintStyle: TextStyle(fontSize: 15), enabledBorder: const OutlineInputBorder( // borderSide: BorderSide(color: Color(0xFFDCDFE6)), borderSide: BorderSide(color: Colors.transparent), borderRadius: BorderRadius.all(Radius.circular(4.0)), ), focusedBorder: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), borderSide: BorderSide(color: Colors.transparent))), ), ); ; } }
通常一个应该会出现多出输入框,但是每个地方的输入框样式和按钮功能类型会有一定的区别,可以通过初始化传参的方式进行区分。如上面事例中enum SearchBarType { home, normal, homeLight }
枚举每个功能页面出现SearchBar的样式和响应事件。
2 构建导航头部TabBar
//导航tabar 关注 头条 新车 ,,。 _buildTabBar() { return TabBar( controller: _controller, isScrollable: true,//是否可滚动 labelColor: Colors.black,//文字颜色 labelPadding: const EdgeInsets.fromLTRB(20, 0, 10, 5), //下划线样式设置 indicator: const UnderlineTabIndicator( borderSide: BorderSide(color: Color(0xff2fcfbb), width: 3), insets: EdgeInsets.fromLTRB(0, 0, 0, 10), ), tabs: tabs.map<Tab>((HomeChannelModel model) { return Tab( text: model.name, ); }).toList()); }
因为Tabbar需要和TabBarView
进行联动,需要定义一个TabController
进行绑定
3 构建导航底部TabBarView容器
//TabBarView容器 信息流列表 _buildTabBarPageView() { return KeepAliveWrapper(child:Expanded( flex: 1, child: Container( color: Colors.grey.withOpacity(0.3), child: TabBarView( controller: _controller, children: _buildItems(), ), ))); }
4 构建导航底部结构填充
底部内容结构包含轮播图左右切换,信息流上下滚动,下拉刷新,上拉加载更多、刷新组件用到SmartRefresher
,轮播图和信息流需要拼接,需要用CustomScrollView
。
代码如下:
_buildRefreshView() { //刷新组件 return SmartRefresher( controller: _refreshController, enablePullDown: true, enablePullUp: true, onLoading: () async { page++; print('onLoading $page'); //加载频道数据 widget.homeChannelModel.termId == 0 ? _getTTHomeNews() : _getHomeNews(); }, onRefresh: () async { page = 1; print('onRefresh $page'); //加载频道数据 widget.homeChannelModel.termId == 0 ? _getTTHomeNews() : _getHomeNews(); }, //下拉头部UI样式 header: const WaterDropHeader( idleIcon: Icon( Icons.car_repair, color: Colors.blue, size: 30, ), ), //上拉底部UI样式 footer: CustomFooter( builder: (BuildContext context, LoadStatus? mode) { Widget body; if (mode == LoadStatus.idle) { body = const Text("pull up load"); } else if (mode == LoadStatus.loading) { body = const CupertinoActivityIndicator(); } else if (mode == LoadStatus.failed) { body = const Text("Load Failed!Click retry!"); } else if (mode == LoadStatus.canLoading) { body = const Text("release to load more"); } else { body = const Text("No more Data"); } return Container( height: 55.0, child: Center(child: body), ); }, ), //customScrollview拼接轮播图和信息流。 child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: _buildFutureScroll() ), SliverList( delegate: SliverChildBuilderDelegate((content, index) { NewsModel newsModel = newsList[index]; return _buildChannelItems(newsModel); }, childCount: newsList.length), ) ], ), ); }
5 构建导航底部结构轮播图
轮播图单独封装SwiperView小组件
//首页焦点轮播图数据获取 _buildFutureScroll(){ return FutureBuilder( future: _getHomeFocus(), builder: (BuildContext context, AsyncSnapshot<FocusDataModel> snapshot){ print('轮播图数据加载 ${snapshot.connectionState} 对应数据:${snapshot.data}'); Container widget; switch(snapshot.connectionState){ case ConnectionState.done: if(snapshot.data != null){ widget = snapshot.data!.focusList!.isNotEmpty?Container( height: 200, width: MediaQuery.of(context).size.width, child: SwiperView(snapshot.data!.focusList!, MediaQuery.of(context).size.width), ):Container(); }else{ widget = Container(); } break; case ConnectionState.waiting: widget = Container(); break; case ConnectionState.none: widget = Container(); break; default : widget = Container(); break; } return widget; }); }
轮播图组件封装,整体基于第三方flutter_swiper_tv
import "package:flutter/material.dart"; import 'package:flutter_swiper_tv/flutter_swiper.dart'; import 'package:qctt_flutter/http/api.dart'; import 'package:qctt_flutter/models/home_channel.dart'; import 'package:qctt_flutter/models/home_focus_model.dart'; class SwiperView extends StatelessWidget { // const SwiperView({Key? key}) : super(key: key); final double width; final List<FocusItemModel> items; const SwiperView(this.items,this.width,{Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Swiper( itemCount: items.length, itemWidth: width, containerWidth: width, itemBuilder: (BuildContext context,int index){ FocusItemModel focusItemModel = items[index]; return Stack(children: [ Container(child:Image.network(focusItemModel.picUrlList![0],fit: BoxFit.fitWidth,width: width,)) ], ); }, pagination: const SwiperPagination(), // control: const SwiperControl(), ); } }
6 构建导航底部结构信息流
信息流比较多,每条信息流样式各一,具体要根据服务端返回的数据进行判定。如本项目不至于22种样式,
_buildChannelItems(NewsModel model) { //0,无图,1单张小图 3、三张小图 4.大图推广 5.小图推广 6.专题(统一大图) // 8.视频小图,9.视频大图 ,,11.banner广告,12.车展, // 14、视频直播 15、直播回放 16、微头条无图 17、微头条一图 // 18、微头条二图以上 19分组小视频 20单个小视频 22 文章折叠卡片(关注频道) switch (model.style) { case '1': return GestureDetector( child: OnePicArticleView(model), onTap: ()=>_jumpToPage(model), ); case '3': return GestureDetector( child: ThreePicArticleView(model), onTap: ()=>_jumpToPage(model), ); case '4': return GestureDetector( child: AdBigPicView(newsModel: model,), onTap: ()=>_jumpToPage(model),) ; case '9': return GestureDetector( child: Container( padding: const EdgeInsets.only(left: 10, right: 10), child: VideoBigPicView(model), ), onTap: ()=>_jumpToPage(model), ); case '15': return GestureDetector( child: Container( width: double.infinity, padding: const EdgeInsets.only(left: 10, right: 10), child: LiveItemView(model), ), onTap: ()=>_jumpToPage(model), ); case '16'://16、微头条无图 return GestureDetector( child: Container( width: double.infinity, padding: const EdgeInsets.only(left: 10, right: 10), child: WTTImageView(model), ), onTap: ()=>_jumpToPage(model), ); case '17'://17、微头条一图 return GestureDetector( child: Container( width: double.infinity, padding: const EdgeInsets.only(left: 10, right: 10), child: WTTImageView(model), ), onTap:()=> _jumpToPage(model), ); case '18'://18、微头条二图以上 //18、微头条二图以上 return GestureDetector( child: Container( width: double.infinity, padding: const EdgeInsets.only(left: 10, right: 10), child: WTTImageView(model), ), onTap: ()=>_jumpToPage(model), ); case '19': //19分组小视频 return Container( width: double.infinity, padding: const EdgeInsets.only(left: 10, right: 10), child: SmallVideoGroupView(model.videoList), ); case '20': //20小视频 左上方带有蓝色小视频标记 return Container( padding: const EdgeInsets.only(left: 10, right: 10), child: VideoBigPicView(model), ); default: return Container( height: 20, color: Colors.blue, ); } }
每种样式需要单独封装Cell组件视图。
通过_buildChannelItems(NewsModel model)
方法返回的是单独的Cell视图,需要提交给对应的list进行组装:
SliverList( delegate: SliverChildBuilderDelegate((content, index) { NewsModel newsModel = newsList[index]; return _buildChannelItems(newsModel); }, childCount: newsList.length), )
这样整个App首页的大体结构就完成了,包含App顶部搜索,基于Tabbar的头部频道导航。TabbarView头部导航联动。CustomScrollView
对轮播图信息流进行拼接,等。网络数据是基于Dio进行了简单封装,具体不在这里细说。具体接口涉及隐私,不展示。
至于底部BottomNavigationBar
会在后续组件介绍的时候详细介绍到。
总结
本章主要介绍了TabBarView的基本用法以及实际复杂项目中TabBarView的组合使用场景,更多关于Flutter TabBarView组件的资料请关注脚本之家其它相关文章!