前言
在移动端 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