Flutter TabBar 在实际项目中的运用
Tabs在实际的项目开发中,运用的十分广泛,此文根据在实际项目中使用整理了一个demo.再此开源,纯属技术交流,欢迎评论交流.
TabBar是flutter中非常常用的一个组件,Flutter提供的TabBar几乎可以满足我们大部分的业务需求,而且实现非常简单,我们可以仅用几行代码,就完成一个Tab滑动效果。
关于TabBar的基本使用,我这里就不介绍了,不熟悉的朋友可以自行百度看看,有很多的Demo。
下面我们针对TabBar在平时的开发中遇到的一些问题,来看下如何解决。
一. 解决汉字滑动抖动的问题
首先,我们来看下TabBar的抖动问题,这个问题发生在我们设置labelStyle
和unselectedLabelStyle
的字体大小不一致
时,这个需求其实在实际的开发当中也很常见,当我们选中一个Tab
时,当然希望选中的标题能够放大,突出一些,但是Flutter
的TabBar
居然会在滑动过程中抖动,开始以为是Debug
包的问题,后来发现Release
也一样。
Flutter的Issue中,其实已经有这样的问题了。不过到目前为止,这个问题也没修复,可能在老外的设计中,从来没有这种设计吧。不过Issue中也提到了很多方案来修复这个问题,其中比较好的一个方案,就是通过修改源码
来实现,在TabBar源码的_TabStyle
的build
函数中,将实现改为下面的方案。
///根据前后字体大小计算缩放倍率
final double _magnification = labelStyle!.fontSize! / unselectedLabelStyle!.fontSize!;
final double _scale = (selected ? lerpDouble(_magnification, 1, animation.value) : lerpDouble(1, _magnification, animation.value))!;
return DefaultTextStyle(
style: textStyle.copyWith(
color: color,
fontSize: unselectedLabelStyle!.fontSize,
),
child: IconTheme.merge(
data: IconThemeData(
size: 24.0,
color: color,
),
child: Transform.scale(
scale: _scale,
child: child,
),
),
);
这个方案的确可以修复这个问题,不过却需要修改源码,所以,有一些使用成本,那么有没有其它方案呢,其实,Issue
中已经给出了问题的来源,实际上就是Text
在计算Scala
的过程中,由于Baseline
不对齐导致的抖动,所以,我们可以换一种思路,将labelStyle
和unselectedLabelStyle
的字体大小设置成一样的,这样就不会抖动啦。
当然,这样的话需求也就满足不了了。
其实,我们是将Scala
的效果,放到外面来实现,在TabBar
的tabs
中,我们将滑动百分比传入,借助隐式动画来实现Scala
效果,就可以解决抖动的问题了。
AnimatedScale(
scale: 1 + progress * 0.3,
duration: const Duration(milliseconds: 100),
child: Text(tabName),
),
最终效果图
解决汉字滑动抖动二. 自定义下标宽度和位置
在实际的开发中,TabBar
往往和indicator
配合在一起进行使用,现在App
中indicator
设计的也是五花八门,有很多的样式。而在flutter
中 indicator
宽度默认是不能修改的,所以可以支持修改宽度indicator
也是很必要的。flutter
中UnderlineTabIndicator
是Tab
的默认实现,我们可以将UnderlineTabIndicator
源码复制出来然后取一个自己的名字如MyUnderlineTabIndicator
在这个类里面修改宽度。代码如下
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class MyUnderlineTabIndicator extends Decoration {
const MyUnderlineTabIndicator({
this.borderSide = const BorderSide(width: 2.0, color: Colors.white),
this.insets = EdgeInsets.zero, required this.wantWidth,
}) : assert(borderSide != null),
assert(insets != null);
final BorderSide borderSide;
final EdgeInsetsGeometry insets;
final double wantWidth;
@override
Decoration? lerpFrom(Decoration? a, double t) {
if (a is MyUnderlineTabIndicator) {
return MyUnderlineTabIndicator(
wantWidth:5,
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!,
);
}
return super.lerpFrom(a, t);
}
@override
Decoration? lerpTo(Decoration? b, double t) {
if (b is MyUnderlineTabIndicator) {
return MyUnderlineTabIndicator(
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!, wantWidth: 5,
);
}
return super.lerpTo(b, t);
}
@override
_UnderlinePainter createBoxPainter([ VoidCallback? onChanged ]) {
return _UnderlinePainter(this, onChanged);
}
Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {
assert(rect != null);
assert(textDirection != null);
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
//希望的宽度
double cw = (indicator.left + indicator.right) / 2;
return Rect.fromLTWH(cw - wantWidth / 2,
indicator.bottom - borderSide.width, wantWidth, borderSide.width);
}
@override
Path getClipPath(Rect rect, TextDirection textDirection) {
return Path()..addRect(_indicatorRectFor(rect, textDirection));
}
}
class _UnderlinePainter extends BoxPainter {
_UnderlinePainter(this.decoration, VoidCallback? onChanged)
: assert(decoration != null),
super(onChanged);
final MyUnderlineTabIndicator decoration;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
assert(configuration != null);
assert(configuration.size != null);
final Rect rect = offset & configuration.size!;
final TextDirection textDirection = configuration.textDirection!;
final Rect indicator = decoration._indicatorRectFor(rect, textDirection).deflate(decoration.borderSide.width / 2.0);
final Paint paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.round;
canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint);
}
}
修改indicator位置
indicatorWeight: 4,
indicatorPadding: EdgeInsets.symmetric(vertical: 8),
如果你想要indicator在垂直距离上更接近,那么可以使用indicatorPadding参数,如果你想让indicator更细,那么可以使用indicatorWeight参数。
最终效果图
自定义下标宽度和位置三. 自定义下标样式
在实际的开发中很多时候都需要自定义Indicator的样式,刚刚修改Indicator 样式时是将源码UnderlineTabIndicator拷贝出来进行修改,最定义也是一样的道理。
在源码最后的BoxPainter,就是我们绘制Indicator的核心,在这里根据Offset和ImageConfiguration,就可以拿到当前Indicator的参数,就可以进行绘制了。
例如我们最简单的,把Indicator绘制成一个圆,实际上只需要修改最后的draw函数,代码如下所示。
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
class CustomUnderlineTabIndicator extends Decoration {
const CustomUnderlineTabIndicator({
this.borderSide = const BorderSide(width: 2.0, color: Colors.white),
this.insets = EdgeInsets.zero,
}) : assert(borderSide != null),
assert(insets != null);
final BorderSide borderSide;
final EdgeInsetsGeometry insets;
@override
Decoration? lerpFrom(Decoration? a, double t) {
if (a is CustomUnderlineTabIndicator) {
return CustomUnderlineTabIndicator(
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!,
);
}
return super.lerpFrom(a, t);
}
@override
Decoration? lerpTo(Decoration? b, double t) {
if (b is CustomUnderlineTabIndicator) {
return CustomUnderlineTabIndicator(
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!,
);
}
return super.lerpTo(b, t);
}
@override
BoxPainter createBoxPainter([ VoidCallback? onChanged ]) {
return _UnderlinePainter(this, onChanged);
}
Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {
assert(rect != null);
assert(textDirection != null);
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
return Rect.fromLTWH(
indicator.left,
indicator.bottom - borderSide.width,
indicator.width,
borderSide.width,
);
}
@override
Path getClipPath(Rect rect, TextDirection textDirection) {
return Path()..addRect(_indicatorRectFor(rect, textDirection));
}
}
class _UnderlinePainter extends BoxPainter {
_UnderlinePainter(this.decoration, VoidCallback? onChanged)
: assert(decoration != null),
super(onChanged);
final CustomUnderlineTabIndicator decoration;
final Paint _paint = Paint()
..color = Colors.orange
..style = PaintingStyle.fill;
final radius = 6.0;
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
assert(configuration != null);
assert(configuration.size != null);
final Rect rect = offset & configuration.size!;
canvas.drawCircle(
Offset(rect.bottomCenter.dx, rect.bottomCenter.dy - radius),
radius,
_paint,
);
}
}
最终效果图
自定义下标样式四. 自定义背景块样式
在开发中有时候会遇到带背景块的tabbar,很简单flutter提供有这个类ShapeDecoration可以用来实现这个效果。
indicator: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: Colors.cyan.shade200,
)
最终效果图
自定义背景块样式五. 动态获取tab
在实际项目开发中,一般这些tab都是通过后台接口返回的,重点是接口返回是异步的,需要在数据未返回时进行判断返回一个空的Widget。不难实现,直接上代码了。
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import '../config/Http_service.dart';
import '../model/cook_info_model.dart';
import 'my_underline_tabIndicator.dart';
class DynamicDataTab extends StatefulWidget {
final String titleStr;
const DynamicDataTab({Key? key, required this.titleStr}) : super(key: key);
@override
State<DynamicDataTab> createState() => _DynamicDataTabState();
}
class _DynamicDataTabState extends State<DynamicDataTab>
with SingleTickerProviderStateMixin {
TabController? _tabController;
List<CookInfoModel> _cookInfoList = CookInfoModelList([]).list;
// 获取数据
Future _getRecommendData() async {
EasyLoading.show(status: 'loading...');
try {
Map<String, dynamic> result =
await HttpService.getHomeRecommendData(page: 1, pageSize: 10);
EasyLoading.dismiss();
List list = [];
for (Map item in result['result']['list']) {
list.add(item['r']);
print(item['r']);
}
CookInfoModelList infoList = CookInfoModelList.fromJson(list);
setState(() {
_tabController =
TabController(length: infoList.list.length, vsync: this);
_cookInfoList = infoList.list;
});
} catch (e) {
print(e);
EasyLoading.dismiss();
} finally {
EasyLoading.dismiss();
}
}
@override
void initState() {
super.initState();
_getRecommendData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.titleStr),
),
body: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
padding: const EdgeInsets.only(top: 20),
color: Colors.white,
child: Column(
children: [
_cookInfoList.isEmpty
? PreferredSize(
preferredSize: const Size(0, 0), child: Container())
: TabBar(
controller: _tabController,
indicatorColor: Colors.blue,
indicatorWeight: 18,
isScrollable: true,
indicatorPadding: const EdgeInsets.symmetric(vertical: 6),
indicator: const MyUnderlineTabIndicator(
wantWidth: 30.0,
borderSide: BorderSide(
width: 6.0,
color: Color.fromRGBO(36, 217, 252, 1))),
tabs: getTabs()
.asMap()
.entries
.map(
(entry) => AnimatedBuilder(
animation: _tabController!.animation!,
builder: (ctx, snapshot) {
final forward = _tabController!.offset > 0;
final backward = _tabController!.offset < 0;
int _fromIndex;
int _toIndex;
double progress;
// Tab
if (_tabController!.indexIsChanging) {
_fromIndex = _tabController!.previousIndex;
_toIndex = _tabController!.index;
progress = (_tabController!.animation!.value -
_fromIndex)
.abs() /
(_toIndex - _fromIndex).abs();
} else {
// Scroll
_fromIndex = _tabController!.index;
_toIndex = forward
? _fromIndex + 1
: backward
? _fromIndex - 1
: _fromIndex;
progress = (_tabController!.animation!.value -
_fromIndex)
.abs();
}
var flag = entry.key == _fromIndex
? 1 - progress
: entry.key == _toIndex
? progress
: 0.0;
return buildTabContainer(
entry.value.text ?? '', flag);
},
),
)
.toList(),
),
Expanded(
child: _cookInfoList.isEmpty
? PreferredSize(
preferredSize: const Size(0, 0), child: Container())
: TabBarView(
controller: _tabController, children: getWidgets()))
],
),
),
);
}
List<Tab> getTabs() {
List<Tab> widgetList = [];
for (int i = 0; i < _cookInfoList.length; i++) {
CookInfoModel model = _cookInfoList[i];
if(model.stdname!.length > 5){
model.stdname = model.stdname?.substring(0,5);
}
widgetList.add(Tab(text: model.stdname!.isNotEmpty ? model.stdname : '暂无数据'));
}
return widgetList;
}
List<Widget> getWidgets() {
List<Widget> widgetList = [];
for (int i = 0; i < _cookInfoList.length; i++) {
CookInfoModel model = _cookInfoList[i];
widgetList.add(
Container(
padding: const EdgeInsets.only(left: 20, right: 20, top: 10),
child: SingleChildScrollView(
child: Column(
children: [
Container(
width: MediaQuery.of(context).size.width,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.white,
),
child: CachedNetworkImage(
imageUrl: model.img??"",
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
),
),
Text(
model.n ?? '',
style: const TextStyle(fontSize: 14, color: Colors.black54),
)
],
),
)
),
);
}
return widgetList;
}
buildTabContainer(String tabName, double alpha) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 1),
child: AnimatedScale(
scale: 1 + double.parse((alpha * 0.2).toStringAsFixed(2)),
duration: const Duration(milliseconds: 100),
child: Text(
tabName,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500, color: Colors.black),
),
),
);
}
}
最终效果图
动态获取tab六. 动态获取tab和tab悬停
动态获取tab同案例五一样,悬停是通过NestedScrollView
和SliverAppBar
来实现的,原理不复杂,不直接上代码。
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import '../widget/banner.dart';
import '../config/Http_service.dart';
import '../model/banner_model.dart';
import '../model/cook_info_model.dart';
import 'my_underline_tabIndicator.dart';
class DynamicDataHover extends StatefulWidget {
final String titleStr;
const DynamicDataHover({Key? key, required this.titleStr}) : super(key: key);
@override
State<DynamicDataHover> createState() => _DynamicDataHoverState();
}
class _DynamicDataHoverState extends State<DynamicDataHover>
with SingleTickerProviderStateMixin {
TabController? _tabController;
List<CookInfoModel> _cookInfoList = CookInfoModelList([]).list;
/// 轮播图数据
List<BannerModel> _bannerList = BannerModelList([]).list;
// 获取数据
Future _getRecommendData() async {
EasyLoading.show(status: 'loading...');
try {
Map<String, dynamic> result =
await HttpService.getHomeRecommendData(page: 1, pageSize: 10);
EasyLoading.dismiss();
/// 轮播图数据
BannerModelList bannerModelList =
BannerModelList.fromJson(result['result']['banner']);
print('哈哈哈哈哈或$result');
List list = [];
for (Map item in result['result']['list']) {
list.add(item['r']);
print(item['r']);
}
CookInfoModelList infoList = CookInfoModelList.fromJson(list);
setState(() {
_tabController =
TabController(length: infoList.list.length, vsync: this);
_cookInfoList = infoList.list;
_bannerList = bannerModelList.list;
});
} catch (e) {
print(e);
EasyLoading.dismiss();
} finally {
EasyLoading.dismiss();
}
}
@override
void initState() {
super.initState();
_getRecommendData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.titleStr),
),
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
backgroundColor: Colors.white,
elevation: 0,
pinned: true,
floating: true,
/// 去掉返回按钮
leading: const Text(''),
expandedHeight: 180,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Container(
color: Colors.white,
height: double.infinity,
child: Column(
children: <Widget>[
Container(
height: 120,
width: MediaQuery.of(context).size.width,
color: Colors.blue,
child: BannerView(
bannerList: _bannerList,
),
),
],
),
),
),
bottom: _cookInfoList.isEmpty
? PreferredSize(
preferredSize: const Size(0, 0), child: Container())
: TabBar(
controller: _tabController,
indicatorColor: Colors.blue,
indicatorWeight: 18,
isScrollable: true,
indicatorPadding:
const EdgeInsets.symmetric(vertical: 6),
indicator: const MyUnderlineTabIndicator(
wantWidth: 30.0,
borderSide: BorderSide(
width: 6.0,
color: Color.fromRGBO(36, 217, 252, 1))),
tabs: getTabs()
.asMap()
.entries
.map(
(entry) => AnimatedBuilder(
animation: _tabController!.animation!,
builder: (ctx, snapshot) {
final forward = _tabController!.offset > 0;
final backward = _tabController!.offset < 0;
int _fromIndex;
int _toIndex;
double progress;
// Tab
if (_tabController!.indexIsChanging) {
_fromIndex = _tabController!.previousIndex;
_toIndex = _tabController!.index;
progress =
(_tabController!.animation!.value -
_fromIndex)
.abs() /
(_toIndex - _fromIndex).abs();
} else {
// Scroll
_fromIndex = _tabController!.index;
_toIndex = forward
? _fromIndex + 1
: backward
? _fromIndex - 1
: _fromIndex;
progress =
(_tabController!.animation!.value -
_fromIndex)
.abs();
}
var flag = entry.key == _fromIndex
? 1 - progress
: entry.key == _toIndex
? progress
: 0.0;
return buildTabContainer(
entry.value.text ?? '', flag);
},
),
)
.toList(),
),
)
];
},
body: _cookInfoList.isEmpty
? PreferredSize(
preferredSize: const Size(0, 0), child: Container())
: TabBarView(controller: _tabController, children: getWidgets()),
),
);
}
List<Tab> getTabs() {
List<Tab> widgetList = [];
for (int i = 0; i < _cookInfoList.length; i++) {
CookInfoModel model = _cookInfoList[i];
if (model.stdname!.length > 5) {
model.stdname = model.stdname?.substring(0, 5);
}
widgetList.add(Tab(text: model.stdname!.isNotEmpty ? model.stdname : '暂无数据'));
}
return widgetList;
}
List<Widget> getWidgets() {
List<Widget> widgetList = [];
for (int i = 0; i < _cookInfoList.length; i++) {
CookInfoModel model = _cookInfoList[i];
widgetList.add(
Container(
padding: const EdgeInsets.only(left: 20, right: 20, top: 10,bottom: 15),
child: SingleChildScrollView(
child: Column(
children: [
Container(
width: MediaQuery.of(context).size.width,
clipBehavior: Clip.hardEdge,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.white,
),
child: CachedNetworkImage(
imageUrl: model.img ?? "",
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
height: 200,
),
),
const SizedBox(height: 15,),
Text(
model.n ?? '',
style: const TextStyle(fontSize: 14, color: Colors.black54),
)
],
),
)),
);
}
return widgetList;
}
buildTabContainer(String tabName, double alpha) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 1),
child: AnimatedScale(
scale: 1 + double.parse((alpha * 0.2).toStringAsFixed(2)),
duration: const Duration(milliseconds: 100),
child: Text(
tabName,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500, color: Colors.black),
),
),
);
}
}