Flutter|响应用户滑动的折线图插件

响应用户滑动的折线图插件。支持反向和自定义滑动精度。

项目地址

特性

展示折线图并改变它。

  • 基于Flutter实现
  • 支持多折线但只能同时改变其中一条
  • 支持反转Y轴
  • 支持自定义滑动精度
  • 支持黑暗模式以及绝大部分样式的自定义

预览

preview.gif

原理

简单来说就是通过CustomPainter绘制折线图再基于onVerticalDrag系列手势监听到拖拽信息并响应。

绘制部分

既然是折线图那么首先得画一个折线图,参考张老师的小册:)

为了美观绘制坐标系时留出了原点的位置,即在绘制轴线,X轴,Y轴和坐标点时需要减去原点的空间。

坐标点

我们开始能知道的是坐标点的显示值,那么首先就要将显示值转换成偏移值才能将坐标点绘制到正确的位置。

/// 显示值到Y轴偏移值的转换系数
double _getYAxisDisplayValue2OffsetValueFactor(double chartActualHeight) =>
      chartActualHeight / (_yAxisMaxValue - widget.min);

/// 显示值到Y轴偏移值
double _displayValue2YAxisOffsetValue(
  double displayValue, {
  required double chartActualHeight, // 图表真实高度 即图表高度减去原点高度
  required double yAxisDisplayValue2OffsetValueFactor,
}) =>
  widget.reversed
      ? (displayValue - widget.min) * yAxisDisplayValue2OffsetValueFactor
      : chartActualHeight -
          (displayValue - widget.min) * yAxisDisplayValue2OffsetValueFactor;

先通过图表真实高度除以滑动范围得到转换系数

由于设定的最小值(widget.min)不一定是0,所以displayValue需要先减去这个最小值,因为坐标偏移值始终从0开始;

未开启反转时,需要用图表真实高度(图表高度 – 原点高度)减去显示值乘系数得到偏移值;开启反转则不需要。

动画部分参考这篇文章做了优化0.1.0版本的动画很诡异。

手势部分

一开始用的onPan系列手势后续发现在结合PageView使用时容易产生手势竞技后经马老师提醒改用了onVerticalDarg

onVerticalDragDown: (DragDownDetails details) {
    _currentSlideCoordinateIndex =
        _hitTestCoordinate(details.localPosition); // 传入localPosition找到当前拖动的坐标点

    if (_currentSlideCoordinateIndex != null) {
      HapticFeedback.mediumImpact(); // 并嗡嗡嗡:>
    }
},
onVerticalDragStart: (DragStartDetails details) {
    _currentSlideCoordinateIndex ??=
        _hitTestCoordinate(details.localPosition); // 巩固一下

    widget.onChangeStart?.call(_coordinatesMap.values
        .map((Coordinates<Enum> coordinates) => coordinates.toOptions())
        .toList());
},
onVerticalDragUpdate: (DragUpdateDetails details) {
    if (_currentSlideCoordinateIndex != null) {
      final double displayValue = _getYAxisDisplayValueBySlidePrecision(
        details.localPosition.dy,
        chartActualHeight: chartActualHeight,
        minOffsetValueForSlidingAreaOnYAxis:
            minOffsetValueOnYAxisSlidingArea,
        maxOffsetValueForSlidingAreaOnYAxis:
            maxOffsetValueOnYAxisSlidingArea,
      ); // 获取当前位置的显示值

      _coordinatesMap[widget.slidableCoordinateType]!
              .value[_currentSlideCoordinateIndex!] =
          _slidableCoordinates!.value[_currentSlideCoordinateIndex!]
              .copyWith(value: displayValue); // 修改拖动坐标的显示值

      widget.onChange!.call(_coordinatesMap.values
          .map(
            (Coordinates<Enum> coordinates) => coordinates.toOptions(),
          )
          .toList()); // 传递给onChange
    }
},
onVerticalDragEnd: (DragEndDetails details) {
    _currentSlideCoordinateIndex = null; // 结束时重置

    widget.onChangeEnd?.call(_coordinatesMap.values
        .map((Coordinates<Enum> coordinates) => coordinates.toOptions())
        .toList());
},
onVerticalDragCancel: () {
    _currentSlideCoordinateIndex = null;
},

Coordinate的offset会在build时赋值,通过Coordinate对象就能很方便地进行绘制和响应滑动;

得益于Flutter对手势的封装我们只需要在对应的时机去计算和响应就可以完成拖动了。

显示值的计算

double _keepBoundsRoundToDouble(
  double min,
  double max, {
  required double value,
}) {
  if (value > min && value < max) {
    value = value.roundToDouble();
  }

  return value.clamp(min, max);
}

/// 根据滑动精度,获取当前位置在y轴上显示的值
double _getYAxisDisplayValueBySlidePrecision(
  double dy, {
  required double chartActualHeight,
  required double minOffsetValueForSlidingAreaOnYAxis,
  required double maxOffsetValueForSlidingAreaOnYAxis,
}) {
  final double dyLogicRowsNumberOnSlidingArea = _keepBoundsRoundToDouble(
    _minLogicRowsNumberOnSlidingArea,
    _maxLogicRowsNumberOnSlidingArea,
    value: (dy.clamp(minOffsetValueForSlidingAreaOnYAxis,
              maxOffsetValueForSlidingAreaOnYAxis) /
          (maxOffsetValueForSlidingAreaOnYAxis -
              minOffsetValueForSlidingAreaOnYAxis)) *
      (_maxLogicRowsNumberOnSlidingArea - _minLogicRowsNumberOnSlidingArea),
  );

  late double result;

  if (widget.reversed) {
    result = dyLogicRowsNumberOnSlidingArea * _slidePrecision + widget.min;
  } else {
    result =
      _yAxisMaxValue - dyLogicRowsNumberOnSlidingArea * _slidePrecision;
  }

  return double.parse(
    result.toStringAsFixed(
      2,
    ), // 保留2位小数并四舍五入可以抹平一些double类型计算上的误差
  );
}

计算时引入了一个逻辑行数的“概念”;

最开始是参照Slider的行为只做了按行滑动的行为,即每次滑动的最小值就是divisions,后来重新整理计算逻辑的时候增加了slidePrecision,可以做到更精细的滑动控制(由于double类型计算精度的问题加上对移动端操作的考量,滑动精度必须是0.01的倍数);

当divisions为1,min为0且max为10时我们能得到一个Y轴是10行的坐标系,此时行数是10;

那么逻辑行数起始就是在10行的基础上再拆分,例如设置slidePrecision为0.01,此时逻辑行数应为行数 * divisions / slidePrecision,即1000。

// 1.防止越界
dy.clamp(minOffsetValueForSlidingAreaOnYAxis, maxOffsetValueForSlidingAreaOnYAxis)
// 2.得到百分比 即此时坐标点位置占总滑动空间的百分比
dy / (maxOffsetValueForSlidingAreaOnYAxis - minOffsetValueForSlidingAreaOnYAxis)
// 3.百分比乘以总滑动空间的逻辑行数 得到此时坐标点位置占据的逻辑行数
dy * (_maxLogicRowsNumberOnSlidingArea - _minLogicRowsNumberOnSlidingArea)
// 4.第3步得到的行数不一定是整数,需要通过_keepBoundsRoundToDouble保留最小值和最大值的边界再进行一次四舍五入
_keepBoundsRoundToDouble(_minLogicRowsNumberOnSlidingArea, _maxLogicRowsNumberOnSlidingArea, value: dy)

步进其实是在滑动进行到当前逻辑行超过50%左右跳过去的,在逻辑行数较小的时候会明显一些,逻辑行数足够时还是比较跟手的;

手势中拿到的localPosition以左上角为原点的offset,所以当开启反转时需要加上不一定为0的最小值,未开启反转时需要用最大值减去当前计算结果。

使用

import 'package:slidable_line_chart/slidable_line_chart.dart';

需要先定义一个Enum类型来标识一组坐标的类型

CoordinatesOptions的参数说明:

参数名 类型 描述 默认值
type Enum? 坐标点配置项的类型 null
values List<double> 坐标系中显示的每个坐标点的值 none
radius double 坐标点的半径 none
zoomedFactor double 触摸区域的放大系数 none

SlidableLineChart的参数说明:

参数名 类型 描述 默认值
slidableCoordinateType Enum? 用户可以滑动的坐标类型 null
coordinatesOptionsList List<CoordinatesOptions<Enum>> 包含坐标配置信息的数组 none
xAxis List<String> 显示在X轴上的文本值 none
min int 用户可以滑动的最小值 none
max int 用户可以滑动的最大值 none
coordinateSystemOrigin Offset 坐标原点的偏移值 const Offset(6.0, 6.0)
divisions int Y轴的分割值 1
slidePrecision double? 用户每次滑动的最小值 null
reversed bool 是否反转坐标系 false
onlyRenderEvenAxisLabel bool 是否只渲染偶数项的Y轴文本 true
enableInitializationAnimation bool 坐标系是否在初始化时触发动画 true
initializationAnimationDuration Duration 初始化动画的时间 const Duration(seconds: 1)
onDrawCheckOrClose OnDrawCheckOrClose? 用户每次滑动时触发,返回值决定指示器的类型 null
onChange CoordinatesOptionsChanged<Enum> 用户每次滑动时触发 null
onChangeStart CoordinatesOptionsChanged<Enum> 用户开始滑动时触发 null
onChangeEnd CoordinatesOptionsChanged<Enum> 用户停止滑动时触发 null

CoordinatesStyle的参数说明:

参数名 类型 描述 默认值
type Enum? 坐标样式的类型 null
pointColor Color? 坐标点的颜色 none
tapAreaColor Color? 坐标点可滑动时触摸区域的颜色 none
lineColor Color? 坐标线的颜色 none
fillAreaColor Color? 填充区域的颜色 none

SlidableLineChartThemeData的参数说明:

参数名 类型 描述 默认值
coordinatesStyleList List<CoordinatesStyle<Enum>>? 全部坐标样式的数组 null
axisLabelStyle TextStyle? 坐标系的轴标签样式 null
axisLineColor Color? 坐标系的轴线颜色 null
axisLineWidth double? 坐标系的轴线宽度 null
gridLineColor Color? 坐标系的网格线颜色 null
gridLineWidth double? 坐标系的网格线宽度 null
defaultCoordinatePointColor Color? 默认的坐标点颜色 null
showTapArea bool? 是否显示用户的触摸区域 null
defaultTapAreaColor Color? 默认的可滑动坐标点触摸区域颜色 null
defaultLineColor Color? 默认的坐标线颜色 null
lineWidth double? 默认的坐标线宽度 null
defaultFillAreaColor Color? 默认的坐标线宽度 null
displayValueTextStyle TextStyle? 坐标系的显示值文本样式 null
displayValueMarginBottom double? 坐标系显示值的底部边距 null
indicatorMarginTop double? 指示器的顶部边距 null
indicatorRadius double? 指示器的半径 null
checkBackgroundColor Color? 通过指示器的背景颜色 null
closeBackgroundColor Color? 未通过指示器的背景颜色 null
checkColor Color? 通过符号的颜色 null
closeColor Color? 未通过符号的颜色 null

总结

一开始抱着试一试的心态,因为之前做过一次编辑相片添加文字并拖动的需求,当时需求完成的马马虎虎,拖动有很大的偏差问题,所以想试试再折腾一下;

做着做着就又回到了当时拖字时候的苍蝇乱转,到了中期因为反转和按行拖动一些问题的累加,导致曾一度开始硬凑数字,各种玄学,后来跌跌撞撞发了第一个release版本,放了好几个月;

后面有一些新的想法加上想要发一篇文章说一下原理就开始着手重构,一点点抽丝剥茧心平气和把逻辑捋清楚,然后磨磨蹭蹭也总算把文章写出来了,现在回头看其实也没有那么难,还是要沉下心来好好想清楚各种参数的含义和作用,不要急躁。

感谢

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容