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

前言

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

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

前期准备

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

创建插件项目

bash 复制代码
  flutter create --org com.example --template=plugin view_tabbar
bash 复制代码
  ├── 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 说明 必选 默认值
pinned tabbar 固定 false
builder widget 构建
itemCount tabbar 数量
direction tabbar 方向 Axis.horizontal
indicator tabbar 指示器
pageController PageView controller PageController
tabBarController ViewTabBar controller ViewTabBarController
animationDuration 动画时长,Duration.zero -> 禁用动画 Duration(milliseconds: 300)
controllerToScroll PageView 滚动时,联动 TabBar/Indicator true
controllerToJump TabBar 滑动时,联动 PageView 滚动 true
onTapItem TabBar Item onTap 事件
height tabbar 高度,当 direction 为 Axis.horizontal 时,请指定值
width tabbar 宽度,当 direction 为 Axis.vertical 时,请指定值

ViewTabBarItem

API 说明 必选 默认值
index tabar item index
child tabar item child
transform tabar item transform, 目前有 ColorsTransform / ScaleTransform

StandardIndicator

API 说明 必选 默认值
top indicator 顶部
left indicator 左侧
right indicator 右侧
bottom indicator 底部
width indicator 宽度
height indicator 高度
radius indicator border radius
color indicator color

ColorsTransform

API 说明 必选 默认值
builder widget 构建
transform transformer,嵌套使用 ScaleTransform
normalColor tabbar 正常颜色
highlightColor tabbar 高亮颜色

ScaleTransform

API 说明 必选 默认值
builder widget 构建
transform transformer,嵌套使用 ColorsTransform
maxScale tabbar 最大可缩放值 1.2

Gif 效果图

源码: TabBar + PageView (pinned)

dart 复制代码
  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 (轮播图)

dart 复制代码
  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 信息

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,按提示解决即可

bash 复制代码
  flutter pub publish --dry-run

正式发布 --server

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

bash 复制代码
  flutter pub publish --server=https://pub.dartlang.org   

Verified Publisher

如果你不是已认证 verified publisher 用户,则不需要关心和处理。

当时发布我们是以个人的名义进行上传操作的,所以需要将其转移 Publisher 名下

相关资源

  • https://github.com/flutter-library-provider/ViewTabBar
  • https://pub.dev/packages/view_tabbar

其他文章

相关推荐
F2E_Zhangmo21 分钟前
vue长列表,虚拟滚动
前端·javascript·vue.js
安冬的码畜日常33 分钟前
【D3.js in Action 3 精译】1.2.2 可缩放矢量图形(三)
开发语言·前端·javascript·信息可视化·数据可视化
LiamHong_1 小时前
简单了解 HTTP 请求方法
前端·后端·http·学习方法·web
蓝胖子的多啦A梦1 小时前
【问题已解决】Vue管理后台,点击登录按钮,会发起两次网络请求(竟然是vscode Compile Hero编译插件导致的)
前端·vue.js·vscode
Lsx-codeShare1 小时前
Nginx Http缓存的必要性!启发式缓存有什么弊端?
前端·javascript·nginx·http·缓存·webpack
南七小僧1 小时前
Android Studio 解决AAPT: error: file failed to compile
前端·javascript·vue.js
陈琦鹏1 小时前
zoom缩放问题(关于ElementPlus、Echarts、Vue3draggable等组件偏移问题)
前端·javascript·echarts
盏茶作酒2 小时前
解决expand-change第一次展开无数据显示与实现
开发语言·前端·javascript
Wang's Blog2 小时前
Webpack: 开发 PWA、Node、Electron 应用
前端·webpack·electron
Code blocks2 小时前
小试牛刀-区块链代币锁仓(Web页面)
前端·区块链·智能合约