案例分析
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; }