开发 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

其他文章

相关推荐
一颗松鼠3 分钟前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
小远yyds23 分钟前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
吕彬-前端1 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱1 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai1 小时前
uniapp
前端·javascript·vue.js·uni-app
bysking2 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓3 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4113 小时前
无网络安装ionic和运行
前端·npm
理想不理想v3 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试