Flutter自定义之刻度尺

效果图
ruler.gif
案例分析
1、效果功能分析
  • 滑动选择刻度尺
  • 支持中间选择刻度值
  • 支持设置最大最小值
  • 支持设置默认值
  • 支持设置大刻度的子刻度数
  • 支持设置步长
  • 支持设置刻度尺、数字的颜色及大小
  • 支持滑动选中回调
  • 支持刻度尺回弹效果
2、功能拆解
  • 自定义Widget(继承StatefulWidget)。
  • 使用ListView实现水平滑动效果(3个子Widget,左右为空白,中间为刻度尺)。
  • 绘制刻度尺Widget(刻度线、刻度值)。
  • 监听滑动获取中间值并回调。
  • 手指抬起滑动停止粘性回弹。
3、功能参数
  • 默认值
  • 最小值
  • 最大值
  • 步长
  • 刻度尺的宽高
  • 大刻度子子刻度数
  • 单刻度宽度
  • 刻度线颜色及宽度
  • 刻度尺数值颜色及宽度
  • 中间刻度线颜色
  • 选择回调
4、功能代码实现

小知识点:
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('滚动到头部');
  }
}
完整代码
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;
}

github:https://github.com/yixiaolunhui/my_flutter
好了,话不多说,一笑轮回~~~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容