开发 Flutter ViewTabBar 组件发布到 Pub 仓库 🤩

9,175 阅读6分钟

ViewTabBar.png

前言

在移动端 App 项目中,我们使用 Flutter 来进行开发和迭代。在不久前,设计人员提供了新版的设计稿,其中新增了一组 新闻版块 轮播图。我们在 pub.dev 官方插件库查找下,匹配到一款评分极高的轮播插件 carousel_slider

不过就在打算用 carousel_slider 时,突然想到之前开发的 CustomTabBar 组件,简单适配改造下,似乎可以完美实现 UI 效果,一旦发布到 Pub 官方,后续也更加便于维护和迭代。

前期准备

  • vpn 账号,可以访问 Google 资源
  • pub.dev 官方库查找,避免 Plugin 重名
  • pub.dev 官方库注册,需要 Google 账号关联绑定
  • godaddy.com 购买域名(免备案),认证为 Verified Publisher

创建插件项目

  flutter create --org com.example --template=plugin view_tabbar
  ├── example                                # 示例项目工程
  │   ├── integration_test                   # 示例项目集成测试
  │   │   ├── plugin_integration_test.dart   # 定义集成测试脚本
  │   │ 
  │   ├── lib                                # 示例 Demo 库,这里可以直接引用 view_tabbar
  │   │   ├── main.dart                      # 编写示例 Demo,可以在运行并调试
  │   │ 
  │   ├── test                               # 示例项目单元测试
  │   │   ├── widget_test.dart               # 定义单元测试脚本
  │   │ 
  │   ├── pubspec.lock                       # 储存示例项目依赖包信息
  │   ├── pubspec.yaml                       # 示例项目的核心配置文件
  │
  ├── lib                                   # 源码库
  │   ├── view_tabbar.dart                  # 编写源码
  │
  ├── test                                  # 插件单元测试
  │   ├── widget_test.dart                  # 编写测试脚本
  │
  │   ├── pubspec.lock                      # 插件项目依赖包信息 
  │   ├── pubspec.yaml                      # 插件项目的核心配置文件

开发 ViewTabBar

概览简介

ViewTabBar 基于 TabBarController 和 PageController,实现了 TabBar 和 PageView 之间在 UI 上的解耦及联动。

  • 可实现 TabBar + PageView (horizontal)
  • 可实现 TabBar + PageView (vertical)
  • 可实现 Carousel (轮播图)

实现原理

文件描述说明
view_tabbar_models.dart定义数据模型 (ScrollTabItem、 ScrollProgress、 IndicatorPosition)
view_tabbar_controller.dart处理 Tab 标签的切换/滚动,监听 Progress 进度,执行 CallBack 处理
view_tabbar_indicator.dart定义 Indicator 抽象类,实现了 StandardIndicator 实例,根据 Progress 更新位置
view_tabbar_transform.dart定义 Transform 抽象类,实现了两个 Transform 实例(Scale、Color),根据 Progress 转换
view_tabbar.dart渲染 ViewTabBar 组件, 监听到 pageController 变化时,更新 Tab 和 Indicator
触发 TabItem onTap 时,则实时更新 pageController.page

在了解上述源码文件的作用和含义之后,其实现原理就不难理解了。

  • 监听 pageController 变化 (如 PageView 滑动),实时更新 TabBar/Indicator 位置和状态
  • 触发 TabBarItem onTap 事件时,实时更新 pageController (animateToPage/jumpToPage)

API 使用说明

ViewTabBar
API说明必选默认值
pinnedtabbar 固定false
builderwidget 构建
itemCounttabbar 数量
directiontabbar 方向Axis.horizontal
indicatortabbar 指示器
pageControllerPageView controllerPageController
tabBarControllerViewTabBar controllerViewTabBarController
animationDuration动画时长,Duration.zero -> 禁用动画Duration(milliseconds: 300)
controllerToScrollPageView 滚动时,联动 TabBar/Indicatortrue
controllerToJumpTabBar 滑动时,联动 PageView 滚动true
onTapItemTabBar Item onTap 事件
heighttabbar 高度,当 direction 为 Axis.horizontal 时,请指定值
widthtabbar 宽度,当 direction 为 Axis.vertical 时,请指定值
ViewTabBarItem
API说明必选默认值
indextabar item index
childtabar item child
transformtabar item transform, 目前有 ColorsTransform / ScaleTransform
StandardIndicator
API说明必选默认值
topindicator 顶部
leftindicator 左侧
rightindicator 右侧
bottomindicator 底部
widthindicator 宽度
heightindicator 高度
radiusindicator border radius
colorindicator color
ColorsTransform
API说明必选默认值
builderwidget 构建
transformtransformer,嵌套使用 ScaleTransform
normalColortabbar 正常颜色
highlightColortabbar 高亮颜色
ScaleTransform
API说明必选默认值
builderwidget 构建
transformtransformer,嵌套使用 ColorsTransform
maxScaletabbar 最大可缩放值1.2

Gif 效果图

page_view-horizontal-pinned-1 page_view-horizontal-no-pinned-1 carousel_1

源码: TabBar + PageView (pinned)
  import 'package:flutter/material.dart';
  import 'package:view_tabbar/view_tabbar.dart';

  class HorizontalWithPinned extends StatelessWidget {
    HorizontalWithPinned({super.key});

    final pageController = PageController();
    final tabBarController = ViewTabBarController();

    @override
    Widget build(BuildContext context) {
      const tags = ['板块1', '板块2', '板块3', '板块4'];
      const duration = Duration(milliseconds: 300);

      return Column(
        mainAxisAlignment: MainAxisAlignment.start,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          // ViewTabBar
          ViewTabBar(
            pinned: true,
            itemCount: tags.length,
            direction: Axis.horizontal,
            pageController: pageController,
            tabBarController: tabBarController,
            animationDuration: duration, // 取消动画 -> Duration.zero
            builder: (context, index) {
              return ViewTabBarItem(
                index: index,
                transform: ScaleTransform(
                  maxScale: 1.2,
                  transform: ColorsTransform(
                    normalColor: const Color(0xff606266),
                    highlightColor: const Color(0xff436cff),
                    builder: (context, color) {
                      return Container(
                        alignment: Alignment.center,
                        padding: const EdgeInsets.only(
                          top: 8.0,
                          left: 10.0,
                          right: 10.0,
                          bottom: 8.0,
                        ),
                        child: Text(
                          tags[index],
                          style: TextStyle(
                            color: color,
                            fontWeight: FontWeight.w500,
                            fontSize: 14.0,
                          ),
                        ),
                      );
                    },
                  ),
                ),
              );
            },

            // StandardIndicator
            indicator: StandardIndicator(
              color: const Color(0xff436cff),
              width: 27.0,
              height: 2.0,
              bottom: 0,
            ),
          ),
  
          // PageView
          Expanded(
            flex: 1,
            child: PageView.builder(
              itemCount: tags.length,
              controller: pageController,
              scrollDirection: Axis.horizontal,
              itemBuilder: (context, index) {
                return Container(
                  padding: const EdgeInsets.only(
                    top: 16.0,
                    left: 16.0,
                    right: 16.0,
                    bottom: 16.0,
                  ),
                  child: Text(
                    '这里渲染显示 ${tags[index]} 的内容',
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w400,
                      color: Color(0xff606266),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      );
    }
  }
源码: Carousel (轮播图)
  import 'dart:async';
  import 'package:flutter/material.dart';
  import 'package:view_tabbar/view_tabbar.dart';

  class CarouselWithTarBar extends StatefulWidget {
    const CarouselWithTarBar({super.key});

    @override
    CarouselWithTarBarState createState() => CarouselWithTarBarState();
  }

  class CarouselWithTarBarState extends State<CarouselWithTarBar> {
    // 共 6 个 Card 轮播元素
    // 因需要实现无限轮播的效果
    // 需在第一个元素前添加最后一个元素
    // 且在最后一个元素前添加第一个元素
    // 如此下来则就有共计 8 个轮播元素


    // 默认显示第2个轮播元素 (即 6 个 Card 中第一个)
    final pageController = PageController(initialPage: 1);
    final tabBarController = ViewTabBarController();

    int _currentIndex = 1;
    Timer? _timer;

    // 定时轮播 - 每隔 3s
    void _setTimer() {
      _timer?.cancel();
      _timer = Timer.periodic(const Duration(seconds: 3), (_) {
        int page = _currentIndex + 1;

        pageController.animateToPage(
          page,
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeOut,
        );
      });
    }

    @override
    void initState() {
      super.initState();
      _setTimer();
    }

    @override
    Widget build(BuildContext context) {
      return Container(
        padding: const EdgeInsets.only(
          top: 10.0,
          bottom: 12.0,
        ),
        clipBehavior: Clip.antiAlias,
        decoration: ShapeDecoration(
          gradient: const LinearGradient(
            begin: Alignment(0.00, -1.00),
            end: Alignment(0, 1),
            stops: [0, 0.2, 1],
            colors: [
              Color(0xFFEEF3FF),
              Color(0xFFEEF3FF),
              Colors.white,
            ],
          ),
          shape: RoundedRectangleBorder(
            side: const BorderSide(width: 1.50, color: Colors.white),
            borderRadius: BorderRadius.circular(12.0),
          ),
        ),
        child: Column(
          children: [
            // 标题
            Container(
              height: 24.0,
              alignment: Alignment.centerLeft,
              margin: const EdgeInsets.only(top: 10.0),
              padding: const EdgeInsets.symmetric(horizontal: 20.0),
              child: const Text(
                "职业类型",
                style: TextStyle(
                  color: Color(0xFF101828),
                  fontSize: 18.0,
                  fontFamily: 'PingFang SC',
                  fontWeight: FontWeight.w500,
                  height: 1,
                ),
              ),
            ),

            // PageView
            Container(
              height: 72.0,
              margin: const EdgeInsets.only(
                top: 16.0,
                bottom: 20.0,
              ),
              padding: const EdgeInsets.only(
                left: 16.0,
                right: 10.0,
              ),
              child: NotificationListener(
                onNotification: (notification) {
                  if (notification is! ScrollNotification ||
                      notification.depth != 0) {
                    return false;
                  }

                  if (notification is ScrollUpdateNotification) {
                    // 关闭定时器
                    _timer?.cancel();
                  }

                  if (notification is ScrollStartNotification) {
                    if (notification.dragDetails != null) {
                      // 关闭定时器
                      _timer?.cancel();
                    }
                  }

                  if (notification is ScrollEndNotification) {
                    final page = pageController.page?.round();

                    // last, 处理 end 边界
                    if (page == 7) {
                      Future.delayed(const Duration(milliseconds: 10), () {
                        pageController.jumpToPage(1);
                        _setTimer();
                      });
                      return true;
                    }

                    // first, 处理 start 边界
                    if (page == 0) {
                      Future.delayed(const Duration(milliseconds: 10), () {
                        pageController.jumpToPage(3);
                        _setTimer();
                      });
                      return true;
                    }

                    // 延时启动定时器
                    Future.delayed(
                      const Duration(milliseconds: 20),
                      () {
                        setState(() {
                          _currentIndex = page ?? 1;
                          _setTimer();
                        });
                      },
                    );
                  }

                  return true;
                },
                child: PageView.builder(
                  itemCount: 8,
                  controller: pageController,
                  scrollDirection: Axis.horizontal,
                  itemBuilder: (context, index) {
                    // first, 处理 start 边界
                    if (index == 0) {
                      index = 6;
                    }

                    // last, 处理 end 边界
                    if (index == 7) {
                      index = 1;
                    }

                    return renderPageViewContent(
                      context,
                      index,
                    );
                  },
                ),
              ),
            ),

            // TarBar
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Expanded(
                  flex: 0,
                  child: ClipRect(
                    clipper: ViewTabBarClipper(),
                    child: ViewTabBar(
                      pinned: true,
                      height: 14.0,
                      width: 160.0,
                      direction: Axis.horizontal,
                      pageController: pageController,
                      tabBarController: tabBarController,
                      animationDuration: const Duration(milliseconds: 300),
                      indicator: StandardIndicator(
                        width: 15.0,
                        height: 4.0,
                        color: const Color(0xff436cff),
                        radius: const BorderRadius.all(Radius.circular(3.0)),
                        bottom: 5,
                      ),
                      itemCount: 8,
                      builder: (context, index) {
                        return ViewTabBarItem(
                          index: index,
                          child: Container(
                            width: 14.0,
                            height: 4.0,
                            margin: const EdgeInsets.only(
                              left: 2.0,
                              right: 2.0,
                            ),
                            decoration: const BoxDecoration(
                              borderRadius: BorderRadius.all(
                                Radius.circular(2),
                              ),
                              color: Color(0x66436cff),
                            ),
                          ),
                        );
                      },
                    ),
                  ),
                ),
              ],
            ),
          ],
        ),
      );
    }
  }

  // 截取 TabBar 容器大小
  class ViewTabBarClipper extends CustomClipper<Rect> {
    @override
    Rect getClip(Size size) {
      // 共 8 个 tab item (每个 tab -> 宽度: 20, 高度: 14)
      // 截取保留 第 2 - 7 tab 元素 -> Rect.fromLTWH(20, 0, 120, 14)
      return const Rect.fromLTWH(20, 0, 120, 14);
    }

    @override
    bool shouldReclip(covariant CustomClipper<Rect> oldClipper) {
      return false;
    }
  }

  // 渲染 PageView 内容
  Widget renderPageViewContent(context, index) {
    // PageView 内容 ....
  }

发布说明

配置 pubspec.yaml 信息

  name: view_tabbar # 必填
  description: '基于 TabBarController 和 PageController, 实现 TabBar 和 PageView 的联动' # 必填
  homepage: https://github.com/flutter-library-provider/ViewTabBar # 必填
  # publish_to: # 如果你想发布到私服,可以在这里指定。默认 pub.dev 官方库
  version: 1.2.7

  environment:
    sdk: '>=2.17.0 <4.0.0'
    flutter: '>=2.5.0'

  dependencies:
    flutter:
      sdk: flutter

  dev_dependencies:
    flutter_test:
      sdk: flutter
    flutter_lints: ^3.0.0
    
  # ...

发布前检查 --dry-run

提前检测下要上传的库有没有问题,有问题Flutter会提示warning,按提示解决即可

  flutter pub publish --dry-run

正式发布 --server

国内用户应该都有使用 flutter 提供的中国镜像,所以上传时要指明上传到 pub.dartlang.org

  flutter pub publish --server=https://pub.dartlang.org   

Verified Publisher

如果你不是已认证 verified publisher 用户,则不需要关心和处理。
当时发布我们是以个人的名义进行上传操作的,所以需要将其转移 Publisher 名下

Publisher.png

相关资源

  • https://github.com/flutter-library-provider/ViewTabBar

其他文章