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

lxf2023-12-23 21:20:01

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

项目地址

特性

展示折线图并改变它。

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

预览

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

原理

简单来说就是通过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的参数说明:

参数名类型描述默认值
typeEnum?坐标点配置项的类型null
valuesList<double>坐标系中显示的每个坐标点的值none
radiusdouble坐标点的半径none
zoomedFactordouble触摸区域的放大系数none

SlidableLineChart的参数说明:

参数名类型描述默认值
slidableCoordinateTypeEnum?用户可以滑动的坐标类型null
coordinatesOptionsListList<CoordinatesOptions<Enum>>包含坐标配置信息的数组none
xAxisList<String>显示在X轴上的文本值none
minint用户可以滑动的最小值none
maxint用户可以滑动的最大值none
coordinateSystemOriginOffset坐标原点的偏移值const Offset(6.0, 6.0)
divisionsintY轴的分割值1
slidePrecisiondouble?用户每次滑动的最小值null
reversedbool是否反转坐标系false
onlyRenderEvenAxisLabelbool是否只渲染偶数项的Y轴文本true
enableInitializationAnimationbool坐标系是否在初始化时触发动画true
initializationAnimationDurationDuration初始化动画的时间const Duration(seconds: 1)
onDrawCheckOrCloseOnDrawCheckOrClose?用户每次滑动时触发,返回值决定指示器的类型null
onChangeCoordinatesOptionsChanged<Enum>用户每次滑动时触发null
onChangeStartCoordinatesOptionsChanged<Enum>用户开始滑动时触发null
onChangeEndCoordinatesOptionsChanged<Enum>用户停止滑动时触发null

CoordinatesStyle的参数说明:

参数名类型描述默认值
typeEnum?坐标样式的类型null
pointColorColor?坐标点的颜色none
tapAreaColorColor?坐标点可滑动时触摸区域的颜色none
lineColorColor?坐标线的颜色none
fillAreaColorColor?填充区域的颜色none

SlidableLineChartThemeData的参数说明:

参数名类型描述默认值
coordinatesStyleListList<CoordinatesStyle<Enum>>?全部坐标样式的数组null
axisLabelStyleTextStyle?坐标系的轴标签样式null
axisLineColorColor?坐标系的轴线颜色null
axisLineWidthdouble?坐标系的轴线宽度null
gridLineColorColor?坐标系的网格线颜色null
gridLineWidthdouble?坐标系的网格线宽度null
showTapAreabool?是否显示用户的触摸区域null
lineWidthdouble?默认的坐标线宽度null
displayValueTextStyleTextStyle?坐标系的显示值文本样式null
displayValueMarginBottomdouble?坐标系显示值的底部边距null
indicatorMarginTopdouble?指示器的顶部边距null
indicatorRadiusdouble?指示器的半径null
checkBackgroundColorColor?通过指示器的背景颜色null
closeBackgroundColorColor?未通过指示器的背景颜色null
checkColorColor?通过符号的颜色null
closeColorColor?未通过符号的颜色null
smoothdouble?坐标系折线的平滑程度null

总结

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

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

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

感谢

  • 马老师提供的需求和思路
  • 张老师的小册
  • 社区大佬的文章
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!