Flutter|响应用户滑动的折线图插件
响应用户滑动的折线图插件。支持反向和自定义滑动精度。
特性
展示折线图并改变它。
- 基于Flutter实现
- 支持多折线但只能同时改变其中一条
- 支持反转Y轴
- 支持自定义滑动精度
- 支持黑暗模式以及绝大部分样式的自定义
预览
![]() |
---|
原理
简单来说就是通过
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版本,放了好几个月;
后面有一些新的想法加上想要发一篇文章说一下原理就开始着手重构,一点点抽丝剥茧心平气和把逻辑捋清楚,然后磨磨蹭蹭也总算把文章写出来了,现在回头看其实也没有那么难,还是要沉下心来好好想清楚各种参数的含义和作用,不要急躁。
感谢
转载自:https://juejin.cn/post/7173232763514912798