Flutter TabBar 在实际项目中的运用

Tabs在实际的项目开发中,运用的十分广泛,此文根据在实际项目中使用整理了一个demo.再此开源,纯属技术交流,欢迎评论交流.

TabBar是flutter中非常常用的一个组件,Flutter提供的TabBar几乎可以满足我们大部分的业务需求,而且实现非常简单,我们可以仅用几行代码,就完成一个Tab滑动效果。
关于TabBar的基本使用,我这里就不介绍了,不熟悉的朋友可以自行百度看看,有很多的Demo。

下面我们针对TabBar在平时的开发中遇到的一些问题,来看下如何解决。

一. 解决汉字滑动抖动的问题

首先,我们来看下TabBar的抖动问题,这个问题发生在我们设置labelStyleunselectedLabelStyle字体大小不一致时,这个需求其实在实际的开发当中也很常见,当我们选中一个Tab时,当然希望选中的标题能够放大,突出一些,但是FlutterTabBar居然会在滑动过程中抖动,开始以为是Debug包的问题,后来发现Release也一样。

Flutter的Issue中,其实已经有这样的问题了。不过到目前为止,这个问题也没修复,可能在老外的设计中,从来没有这种设计吧。不过Issue中也提到了很多方案来修复这个问题,其中比较好的一个方案,就是通过修改源码来实现,在TabBar源码的_TabStylebuild函数中,将实现改为下面的方案。

///根据前后字体大小计算缩放倍率
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不对齐导致的抖动,所以,我们可以换一种思路,将labelStyleunselectedLabelStyle的字体大小设置成一样的,这样就不会抖动啦。

当然,这样的话需求也就满足不了了。

其实,我们是将Scala的效果,放到外面来实现,在TabBartabs中,我们将滑动百分比传入,借助隐式动画来实现Scala效果,就可以解决抖动的问题了。

AnimatedScale(
  scale: 1 + progress * 0.3,
  duration: const Duration(milliseconds: 100),
  child: Text(tabName),
),
最终效果图
解决汉字滑动抖动
二. 自定义下标宽度和位置

在实际的开发中,TabBar 往往和indicator 配合在一起进行使用,现在Appindicator设计的也是五花八门,有很多的样式。而在flutterindicator 宽度默认是不能修改的,所以可以支持修改宽度indicator 也是很必要的。flutterUnderlineTabIndicatorTab的默认实现,我们可以将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 createState() => _DynamicDataTabState();
}

class _DynamicDataTabState extends State
    with SingleTickerProviderStateMixin {
  TabController? _tabController;
  List _cookInfoList = CookInfoModelList([]).list;

  // 获取数据
  Future _getRecommendData() async {
    EasyLoading.show(status: 'loading...');
    try {
      Map 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 getTabs() {
    List 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 getWidgets() {
    List 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同案例五一样,悬停是通过NestedScrollViewSliverAppBar来实现的,原理不复杂,不直接上代码。

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 createState() => _DynamicDataHoverState();
}

class _DynamicDataHoverState extends State
    with SingleTickerProviderStateMixin {
  TabController? _tabController;
  List _cookInfoList = CookInfoModelList([]).list;

  /// 轮播图数据
  List _bannerList = BannerModelList([]).list;

  // 获取数据
  Future _getRecommendData() async {
    EasyLoading.show(status: 'loading...');
    try {
      Map 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 [
            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: [
                      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 getTabs() {
    List 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 getWidgets() {
    List 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),
        ),
      ),
    );
  }
}
最终效果图
动态获取tab和tab悬停
七. 动态获取tab+tab悬停+下拉刷新上拉加载
最终效果图
动态获取tab+tab悬停+下拉刷新上拉加载
八. tab嵌套tab
最终效果图
tab嵌套tab
九. tab自由布局
tab自由布局
项目地址请移步: 项目地址
Flutter timer的使用: 项目地址

你可能感兴趣的:(Flutter TabBar 在实际项目中的运用)