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同案例五一樣,懸停是通過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),
),
),
);
}
}