
移动小样
2022/08/28阅读:82主题:默认主题
Flutter-自定义尺子
效果图

案例分析
1.效果功能分析
-
滑动选择刻度尺 -
支持中间选择刻度值 -
支持设置最大最小值 -
支持设置默认值 -
支持设置大刻度的子刻度数 -
支持设置步长 -
支持设置刻度尺、数字的颜色及大小 -
支持滑动选中回调 -
支持刻度尺回弹效果
2.功能拆解
-
自定义Widget(继承StatefulWidget)。 -
使用ListView实现水平滑动效果(3个子Widget,左右为空白,中间为刻度尺)。 -
绘制刻度尺Widget(刻度线、刻度值)。 -
监听滑动获取中间值并回调。 -
手指抬起滑动停止粘性回弹。
3.功能参数
-
默认值 -
最小值 -
最大值 -
步长 -
刻度尺的宽高 -
大刻度子子刻度数 -
单刻度宽度 -
刻度线颜色及宽度 -
刻度尺数值颜色及宽度 -
中间刻度线颜色 -
选择回调
4.功能代码实现
4.1 首先自定义Widget继承自StatefulWidget,
class RulerView extends StatefulWidget {
}
4.2 功能参数根据(3.功能参数)进行定义
class RulerView extends StatefulWidget {
//默认值
final int value;
//最小值
final int minValue;
//最大值
final int maxValue;
//步数 一个刻度的值
final int step;
//尺子的宽度
final int width;
//尺子的高度
final int height;
//每个大刻度的子刻度数
final int subScaleCountPerScale;
//每一刻度的宽度
final int subScaleWidth;
//左右空白间距宽度
double paddingItemWidth;
//刻度尺选择回调
final void Function(int) onSelectedChanged;
//刻度颜色
final Color scaleColor;
//指示器颜色
final Color indicatorColor;
//刻度文字颜色
final Color scaleTextColor;
//刻度文字的大小
final double scaleTextWidth;
//刻度线的大小
final double scaleWidth;
//计算总刻度数
int totalSubScaleCount;
RulerView({
Key key,
this.value = 10,
this.minValue = 0,
this.maxValue = 100,
this.step = 1,
this.width = 200,
this.height = 60,
this.subScaleCountPerScale = 10,
this.subScaleWidth = 8,
this.scaleColor = Colors.black,
this.scaleWidth = 2,
this.scaleTextColor = Colors.black,
this.scaleTextWidth = 15,
this.indicatorColor = Colors.red,
@required this.onSelectedChanged,
}) : super(key: key) {
}
...
...
}
4.3 需要对参数配置及默认值边界检查
//检查最大数-最小数必须是步数的倍数
if ((maxValue - minValue) % step != 0) {
throw Exception("(maxValue - minValue)必须是 step 的整数倍");
}
//默认值 不能低于最小值 或者大于最大值
if (value < minValue || value > maxValue) {
throw Exception(
"value 必须在minValue和maxValue范围内(minValue<=value<=maxValue)");
}
//总刻度数
totalSubScaleCount = (maxValue - minValue) ~/ step;
//检查总刻度数必须是大刻度子刻度数的倍数
if (totalSubScaleCount % subScaleCountPerScale != 0) {
throw Exception(
"(maxValue - minValue)~/step 必须是 subScaleCountPerScale 的整数倍");
}
//空白item的宽度
paddingItemWidth = width / 2;
4.4 真正的时刻到了
上面4.1,4.2,4.3其实都是准备工作,到现在我们再来看看这个尺子的效果: 其实在文章开头已经写了具体的实现思路,现在我们来具体分析下:
思路1 尺子的外貌
首先只看静止的尺子,有同学就会说,唉,不就是绘制一条横线,很多条竖线,还有一些数字呗,对,你说的对!没错,尺子的外貌就是考察我们自定义View的绘制,Flutter中的自定义View其实和Android基本差不多,具体下面会代码细说。
思路2 尺子的滑动
滑动呢?尺子是需要根据手指滑动而滑动,一说到滑动,就想到了事件分发,滑动冲突等等,上头。这里给同学想到一个简便方法,说起简便方法就想到我们上学时做题目,一个题目一个答案,解题过程会有很多种,往往有些学霸们会使用简便方法去解答,那么何为简便方法?
简便方法?
所谓简便方法就是利用之前已经验证的公式来快速解决这个问题。呦西,明白了,不就是投机取巧吗?我会我会。那么言归正传,找到已经支持滑动的控件不就是对应已经验证的公式吗,我太聪明了,说干就干!已经支持滑动的Widget,第一想到的就是ListView, 一个水平滑动的ListView,嘿嘿嘿,我想到了,看下图:

我们可以把尺子布局作为ListView的一个Item去看,然后尺子左右距离通过空白Item占位就可以了,那么空白占位是多少呢?根据效果图可知,尺子往右滑动到最后,尺子的最左边是停留在屏幕宽度中心的,所以说空白占位Item的宽度就是屏幕的宽度,哦了,撸代码
ListView.builder(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.all(0),
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: 3,
itemBuilder: (BuildContext context, int index) {
//2边的空白占位控件
if (index == 0 || index == 2) {
return Container(
width: widget.paddingItemWidth,
height: 0,
);
} else {
//刻度尺
return Container(
child: RealRulerView(
subGridCount: widget.totalSubScaleCount,
subScaleWidth: widget.subScaleWidth,
step: widget.step,
minValue: widget.minValue,
height: widget.height,
scaleColor: widget.scaleColor,
scaleWidth: widget.scaleWidth,
scaleTextWidth: widget.scaleTextWidth,
scaleTextColor: widget.scaleTextColor,
subScaleCountPerScale: widget.subScaleCountPerScale,
),
);
}
},
),
到这里滑动的功能就解决了,下面就是要实现这个尺子的绘制,绘制尺子和Android基本一样,主要考察Flutter的CustomPaint,CustomPainter,canvas等相关的API使用,我这里直接给完整代码,主要注释已经在代码当中。
///真实刻度尺View
class RealRulerView extends StatelessWidget {
const RealRulerView({
Key key,
this.subGridCount,
this.subScaleWidth,
this.minValue,
this.height,
this.step,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
}) : super(key: key);
//刻度总数
final int subGridCount;
//每个刻度的宽度
final int subScaleWidth;
//刻度尺的高度
final int height;
//刻度尺最小值
final int minValue;
//每个大刻度的小刻度数
final int subScaleCountPerScale;
//步长 一刻度的值
final int step;
//刻度尺颜色
final Color scaleColor;
//刻度尺宽度
final double scaleTextWidth;
//刻度线宽度
final double scaleWidth;
//数字颜色
final Color scaleTextColor;
@override
Widget build(BuildContext context) {
double rulerWidth = (subScaleWidth * subGridCount).toDouble();
double rulerHeight = this.height.toDouble();
return CustomPaint(
size: Size(rulerWidth, rulerHeight),
painter: RulerViewPainter(
this.subScaleWidth,
this.step,
this.minValue,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
),
);
}
}
class RulerViewPainter extends CustomPainter {
final int subScaleWidth;
final int step;
final int minValue;
final Color scaleColor;
final Color scaleTextColor;
final double scaleTextWidth;
final int subScaleCountPerScale;
final double scaleWidth;
Paint linePaint;
TextPainter textPainter;
RulerViewPainter(
this.subScaleWidth,
this.step,
this.minValue,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
) {
//刻度尺
linePaint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = scaleWidth
..color = scaleColor;
//数字
textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
}
@override
void paint(Canvas canvas, Size size) {
//绘制线
drawLine(canvas, size);
//绘制数字
drawNum(canvas, size);
}
///绘制线
void drawLine(Canvas canvas, Size size) {
//绘制横线
canvas.drawLine(
Offset(0, 0 + scaleWidth / 2),
Offset(size.width, 0 + scaleWidth / 2),
linePaint,
);
//第几个小格子
int index = 0;
//绘制竖线
for (double x = 0; x <= size.width; x += subScaleWidth) {
if (index % subScaleCountPerScale == 0) {
canvas.drawLine(
Offset(x, 0), Offset(x, size.height * 3 / 8), linePaint);
} else {
canvas.drawLine(Offset(x, 0), Offset(x, size.height / 4), linePaint);
}
index++;
}
}
///绘制数字
void drawNum(Canvas canvas, Size size) {
canvas.save();
//坐标移动(0,0)点
canvas.translate(0, 0);
//每个大格子的宽度
double offsetX = (subScaleWidth * subScaleCountPerScale).toDouble();
int index = 0;
//绘制数字
for (double x = 0; x <= size.width; x += offsetX) {
textPainter.text = TextSpan(
text: "${minValue + index * step * subScaleCountPerScale}",
style: TextStyle(color: scaleTextColor, fontSize: scaleTextWidth),
);
textPainter.layout();
textPainter.paint(
canvas,
new Offset(
-textPainter.width / 2,
size.height - textPainter.height,
),
);
index++;
canvas.translate(offsetX, 0);
}
canvas.restore();
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
到这里,尺子的效果和滑动已经完毕了,但是治标不治本啊,我虽然滑动了尺子,滑动多少啊,我不知道啊?对哦,我要知道我滑动多少了啊,怎么办呢?

图中尺子滑动的距离和ListView滑动的距离是一直的,所以是相等的,所以只要我们监听ListView滑动,然后通过滑动的距离和刻度的起点值、每个刻度值,就可以计算出滑动了多少刻度值。
滑动刻度值=起始值+滑动距离/单个刻度距离*单个刻度值
监听ListView滑动多少,这里可以有个小知识点: NotificationListener:
if (notification is ScrollStartNotification) {
print('滚动开始');
}
if (notification is ScrollUpdateNotification) {
print('滚动中');
}
if (notification is ScrollEndNotification) {
print('停止滚动');
if (_scrollController.position.extentAfter == 0) {
print('滚动到底部');
}
if (_scrollController.position.extentBefore == 0) {
print('滚动到头部');
}
}
具体计算代码
bool _onNotification(Notification notification) {
//ScrollNotification是基类 (ScrollStartNotification/ScrollUpdateNotification/ScrollEndNotification)
if (notification is ScrollNotification) {
//距离widget中间最近的刻度值
int centerValue = widget.minValue +
//notification.metrics.pixels水平滚动的偏移量
//先计算出滚动偏移量是滚动了多少个刻度,然后取整,在乘以每个刻度的刻度值就是当前选中的值
(notification.metrics.pixels / widget.subScaleWidth).round() *
widget.step;
// 选中值回调
if (widget.onSelectedChanged != null) {
widget.onSelectedChanged(centerValue);
}
...
}
return true; //停止通知
}
当我们停止滑动,弹起手指,需要尺子回弹到最近刻度值上,因为每个刻度之前是有距离的,当我们滑动时是可能滑动到刻度之间位置,这时候抬手,我们是知道当前滑动距离是多少的,但是多出来或少出来一段距离,简单的说就是:
滑动的距离%单个刻度值≠0
所以其实我们只要取整就可以了,也就是四舍五入。好了基本的实现就到这里了,下面贴出完整代码。
完整代码
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
///自定义尺子
class RulerView extends StatefulWidget {
//默认值
final int value;
//最小值
final int minValue;
//最大值
final int maxValue;
//步数 一个刻度的值
final int step;
//尺子的宽度
final int width;
//尺子的高度
final int height;
//每个大刻度的子刻度数
final int subScaleCountPerScale;
//每一刻度的宽度
final int subScaleWidth;
//左右空白间距宽度
double paddingItemWidth;
//刻度尺选择回调
final void Function(int) onSelectedChanged;
//刻度颜色
final Color scaleColor;
//指示器颜色
final Color indicatorColor;
//刻度文字颜色
final Color scaleTextColor;
//刻度文字的大小
final double scaleTextWidth;
//刻度线的大小
final double scaleWidth;
//计算总刻度数
int totalSubScaleCount;
RulerView({
Key key,
this.value = 10,
this.minValue = 0,
this.maxValue = 100,
this.step = 1,
this.width = 200,
this.height = 60,
this.subScaleCountPerScale = 10,
this.subScaleWidth = 8,
this.scaleColor = Colors.black,
this.scaleWidth = 2,
this.scaleTextColor = Colors.black,
this.scaleTextWidth = 15,
this.indicatorColor = Colors.red,
@required this.onSelectedChanged,
}) : super(key: key) {
//检查最大数-最小数必须是步数的倍数
if ((maxValue - minValue) % step != 0) {
throw Exception("(maxValue - minValue)必须是 step 的整数倍");
}
//默认值 不能低于最小值 或者大于最大值
if (value < minValue || value > maxValue) {
throw Exception(
"value 必须在minValue和maxValue范围内(minValue<=value<=maxValue)");
}
//总刻度数
totalSubScaleCount = (maxValue - minValue) ~/ step;
//检查总刻度数必须是大刻度子刻度数的倍数
if (totalSubScaleCount % subScaleCountPerScale != 0) {
throw Exception(
"(maxValue - minValue)~/step 必须是 subScaleCountPerScale 的整数倍");
}
//空白item的宽度
paddingItemWidth = width / 2;
}
@override
State<StatefulWidget> createState() {
return RulerState();
}
}
class RulerState extends State<RulerView> {
ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController(
//初始位置
initialScrollOffset:
// ((默认值-最小值)/步长 )=第几个刻度,再乘以每个刻度的宽度就是初始位置
(widget.value - widget.minValue) / widget.step * widget.subScaleWidth,
);
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.width.toDouble(),
height: widget.height.toDouble(),
child: Stack(
alignment: Alignment.topCenter,
children: <Widget>[
NotificationListener(
onNotification: _onNotification,
child: ListView.builder(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.all(0),
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: 3,
itemBuilder: (BuildContext context, int index) {
//2边的空白控件
if (index == 0 || index == 2) {
return Container(
width: widget.paddingItemWidth,
height: 0,
);
} else {
//刻度尺
return Container(
child: RealRulerView(
subGridCount: widget.totalSubScaleCount,
subScaleWidth: widget.subScaleWidth,
step: widget.step,
minValue: widget.minValue,
height: widget.height,
scaleColor: widget.scaleColor,
scaleWidth: widget.scaleWidth,
scaleTextWidth: widget.scaleTextWidth,
scaleTextColor: widget.scaleTextColor,
subScaleCountPerScale: widget.subScaleCountPerScale,
),
);
}
},
),
),
//指示器
Container(
width: 2,
height: widget.height / 2,
color: widget.indicatorColor,
),
],
),
);
}
///监听刻度尺滚动通知
bool _onNotification(Notification notification) {
//ScrollNotification是基类 (ScrollStartNotification/ScrollUpdateNotification/ScrollEndNotification)
if (notification is ScrollNotification) {
print("-------metrics.pixels-------${notification.metrics.pixels}");
//距离widget中间最近的刻度值
int centerValue = widget.minValue +
//notification.metrics.pixels水平滚动的偏移量
//先计算出滚动偏移量是滚动了多少个刻度,然后取整,在乘以每个刻度的刻度值就是当前选中的值
(notification.metrics.pixels / widget.subScaleWidth).round() *
widget.step;
// 选中值回调
if (widget.onSelectedChanged != null) {
widget.onSelectedChanged(centerValue);
}
//如果是否滚动停止,停止则滚动到centerValue
if (_scrollingStopped(notification, _scrollController)) {
select(centerValue);
}
}
return true; //停止通知
}
///判断是否滚动停止
bool _scrollingStopped(
Notification notification,
ScrollController scrollController,
) {
return
//停止滚动
notification is UserScrollNotification
//没有滚动正在进行
&&
notification.direction == ScrollDirection.idle &&
scrollController.position.activity is! HoldScrollActivity;
}
///选中值
void select(int centerValue) {
//根据(中间值-最小值)/步长=第几个刻度,然后第几个刻度乘以每个刻度的宽度就是移动的宽度
double x =
(centerValue - widget.minValue) / widget.step * widget.subScaleWidth;
_scrollController.animateTo(x,
duration: Duration(milliseconds: 200), curve: Curves.decelerate);
}
}
///真实刻度尺View
class RealRulerView extends StatelessWidget {
const RealRulerView({
Key key,
this.subGridCount,
this.subScaleWidth,
this.minValue,
this.height,
this.step,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
}) : super(key: key);
//刻度总数
final int subGridCount;
//每个刻度的宽度
final int subScaleWidth;
//刻度尺的高度
final int height;
//刻度尺最小值
final int minValue;
//每个大刻度的小刻度数
final int subScaleCountPerScale;
//步长 一刻度的值
final int step;
//刻度尺颜色
final Color scaleColor;
//刻度尺宽度
final double scaleTextWidth;
//刻度线宽度
final double scaleWidth;
//数字颜色
final Color scaleTextColor;
@override
Widget build(BuildContext context) {
double rulerWidth = (subScaleWidth * subGridCount).toDouble();
double rulerHeight = this.height.toDouble();
return CustomPaint(
size: Size(rulerWidth, rulerHeight),
painter: RulerViewPainter(
this.subScaleWidth,
this.step,
this.minValue,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
),
);
}
}
class RulerViewPainter extends CustomPainter {
final int subScaleWidth;
final int step;
final int minValue;
final Color scaleColor;
final Color scaleTextColor;
final double scaleTextWidth;
final int subScaleCountPerScale;
final double scaleWidth;
Paint linePaint;
TextPainter textPainter;
RulerViewPainter(
this.subScaleWidth,
this.step,
this.minValue,
this.scaleColor,
this.scaleWidth,
this.scaleTextColor,
this.scaleTextWidth,
this.subScaleCountPerScale,
) {
//刻度尺
linePaint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = scaleWidth
..color = scaleColor;
//数字
textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
}
@override
void paint(Canvas canvas, Size size) {
//绘制线
drawLine(canvas, size);
//绘制数字
drawNum(canvas, size);
}
///绘制线
void drawLine(Canvas canvas, Size size) {
//绘制横线
canvas.drawLine(
Offset(0, 0 + scaleWidth / 2),
Offset(size.width, 0 + scaleWidth / 2),
linePaint,
);
//第几个小格子
int index = 0;
//绘制竖线
for (double x = 0; x <= size.width; x += subScaleWidth) {
if (index % subScaleCountPerScale == 0) {
canvas.drawLine(
Offset(x, 0), Offset(x, size.height * 3 / 8), linePaint);
} else {
canvas.drawLine(Offset(x, 0), Offset(x, size.height / 4), linePaint);
}
index++;
}
}
///绘制数字
void drawNum(Canvas canvas, Size size) {
canvas.save();
//坐标移动(0,0)点
canvas.translate(0, 0);
//每个大格子的宽度
double offsetX = (subScaleWidth * subScaleCountPerScale).toDouble();
int index = 0;
//绘制数字
for (double x = 0; x <= size.width; x += offsetX) {
textPainter.text = TextSpan(
text: "${minValue + index * step * subScaleCountPerScale}",
style: TextStyle(color: scaleTextColor, fontSize: scaleTextWidth),
);
textPainter.layout();
textPainter.paint(
canvas,
new Offset(
-textPainter.width / 2,
size.height - textPainter.height,
),
);
index++;
canvas.translate(offsetX, 0);
}
canvas.restore();
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
到这里就结束了,希望对同学有所帮助,多多关注哦!
公众号:

作者介绍
