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

其他文章

相关推荐
陈随易6 小时前
bm2,Node.js 与 Bun 项目部署新选择
前端·后端·程序员
福兮说7 小时前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
传人once9 小时前
多图片上传预览功能如何写
前端·css·html·jquery·html5
传奇开心果编程9 小时前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
Eric.469 小时前
Wan‑Animate+MiniMax-H3 本地部署 AI 漫剧流水线:8G 显存动作迁移与动态分镜工程实战
前端·人工智能·comfyui·ai漫剧
库拉镜像AI牛牛11 小时前
短剧内容自动化生产:知漫剧工作室落地教程
大数据·服务器·前端·人工智能·语音识别
律宏阔11 小时前
Flutter 列表图片内存优化:CachedNetworkImage 与 ClipRRect 的取舍
前端·flutter
行者全栈架构师11 小时前
【鸿蒙心迹】相册文搜图工程化落地:HarmonyOS 7.0 端侧 AI 检索全链路实战与 5 个踩坑解法
前端
律宏阔11 小时前
Dart 3 Record:解决 Future.wait 异构返回值的类型问题
前端·flutter
Seraphina3611 小时前
DVWA(XSS Reflected/Stored High,CSRF Low)
前端·xss·csrf