移动小样

V1

2022/08/28阅读:16主题:默认主题

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(00 + 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(00);
    //每个大格子的宽度
    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(00 + 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(00);
    //每个大格子的宽度
    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;
}

到这里就结束了,希望对同学有所帮助,多多关注哦!

公众号:

分类:

后端

标签:

后端

作者介绍

移动小样
V1