背景
公司最近引入了 Flutter 技術棧,Flutter 是谷歌的移動 UI 框架,可以快速在 iOS 和 Android 上構建高質量的原生使用者介面。然而由於 Flutter 還在早期發展階段沒有,生態建設還不夠完善。比如專案中需要用到圖表 UI 元件,經過一番調研,Google/charts 功能最強大,樣式最豐富(詳見 online gallery),於是引入到專案中。但是 charts 只實現了直線折線圖,所以只能 fork charts 專案自己實現平滑曲線效果。
基礎使用
- Goole/charts 這個圖表庫很強大,但是文件不太友好,只有 online gallery 上有純示例程式碼,幾乎沒有 Api 說明。
- 可行性分析的 Demo 效果
- 仔細研究優化後的效果
- 具體使用程式碼及註釋
return Container(
height: 150.0,
child: charts.LineChart(
_createChartData(), // 折線圖的點的資料列表
animate: true, // 動畫
defaultRenderer: charts.LineRendererConfig( // 折線圖繪製的配置
includeArea: true,
includePoints: true,
includeLine: true,
stacked: false,
),
domainAxis: charts.NumericAxisSpec( // 主軸的配置
tickFormatterSpec: DomainFormatterSpec(widget.dateRange), // tick 值的格式化,這裡把 num 轉換成 String
renderSpec: charts.SmallTickRendererSpec( // 主軸繪製的配置
tickLengthPx: 0, // 刻度標識突出的長度
labelOffsetFromAxisPx: 12, // 刻度文字距離軸線的位移
labelStyle: charts.TextStyleSpec( // 刻度文字的樣式
color: ChartUtil.getChartColor(HColors.lightGrey),
fontSize: HFontSizes.smaller.toInt(),
),
axisLineStyle: charts.LineStyleSpec( // 軸線的樣式
color: ChartUtil.getChartColor(ChartUtil.lightBlue),
),
),
tickProviderSpec: charts.BasicNumericTickProviderSpec( // 軸線刻度配置
dataIsInWholeNumbers: false,
desiredTickCount: widget.data.length, // 期望顯示幾個刻度
),
),
primaryMeasureAxis: charts.NumericAxisSpec( // 交叉軸的配置,引數參考主軸配置
showAxisLine: false, // 顯示軸線
tickFormatterSpec: MeasureFormatterSpec(),
tickProviderSpec: charts.BasicNumericTickProviderSpec(
dataIsInWholeNumbers: false,
desiredTickCount: 4,
),
renderSpec: charts.GridlineRendererSpec( // 交叉軸刻度水平線
tickLengthPx: 0,
labelOffsetFromAxisPx: 12,
labelStyle: charts.TextStyleSpec(
color: ChartUtil.getChartColor(HColors.lightGrey),
fontSize: HFontSizes.smaller.toInt(),
),
lineStyle: charts.LineStyleSpec(
color: ChartUtil.getChartColor(ChartUtil.lightBlue),
),
axisLineStyle: charts.LineStyleSpec(
color: ChartUtil.getChartColor(ChartUtil.lightBlue),
),
),
),
selectionModels: [ // 設定點選選中事件
charts.SelectionModelConfig(
type: charts.SelectionModelType.info,
listener: _onSelectionChanged,
)
],
behaviors: [
charts.InitialSelection(selectedDataConfig: [ // 設定預設選中
charts.SeriesDatumConfig<num>('LineChart', _index)
]),
],
),
);
複製程式碼
平滑曲線效果實現
雖然基礎使用實現的折線圖效果已經很不錯了,但 UI 設計是平滑曲線效果,工程師也贊同曲線效果更優雅的觀點,所以決定挑戰自我,自己實現平滑曲線效果。 通過一層層原始碼分析,最終發現繪製折線圖折線的實現位置,改寫該實現即可實現平滑曲線效果
line_chart.dart
defaultRenderer: charts.LineRendererConfig( // 折線圖繪製的配置
includeArea: true,
includePoints: true,
includeLine: true,
stacked: false,
),
複製程式碼
line_renderer.dart
if (config.includeLine) {
...
canvas.drawLine(
clipBounds: _getClipBoundsForExtent(line.positionExtent),
dashPattern: line.dashPattern,
points: line.points,
stroke: line.color,
strokeWidthPx: line.strokeWidthPx,
roundEndCaps: line.roundEndCaps);
}
});
}
});
複製程式碼
chart_canvas.dart
@override
void drawLine(
...
_linePainter.draw(
canvas: canvas,
paint: _paint,
points: points,
clipBounds: clipBounds,
fill: fill,
stroke: stroke,
roundEndCaps: roundEndCaps,
strokeWidthPx: strokeWidthPx,
dashPattern: dashPattern);
}
複製程式碼
既然找到了具體繪製折線的入口,剩下的就是如何根據給出的資料集合,繪製出平滑的曲線,而且曲線的範圍不能超出資料集合的範圍。前前後後嘗試了三種繪製曲線的演算法,前兩種都由於超出資料集合範圍而棄用了,最後的曲線效果採用的第三種演算法繪製的。
樣條插值是一種工業設計中常用的、得到平滑曲線的一種插值方法,三次樣條又是其中用的較為廣泛的一種。演算法參考 Java 三次樣條插值,程式碼實現如下: interpolation.dart
class Interpolation {
int n;
List<num> xs;
List<num> ys;
bool spInitialized;
List<num> spY2s;
Interpolation(List<num> _xs, List<num> _ys) {
this.n = _xs.length;
this.xs = _xs;
this.ys = _ys;
this.spInitialized = false;
}
num spline(num x) {
if (!this.spInitialized) {
// Assume Natural Spline Interpolation
num p, qn, sig, un;
List<num> us;
us = new List<num>(n - 1);
spY2s = new List<num>(n);
us[0] = spY2s[0] = 0.0;
for (int i = 1; i <= n - 2; i++) {
sig = (xs[i] - xs[i - 1]) / (xs[i + 1] - xs[i - 1]);
p = sig * spY2s[i - 1] + 2.0;
spY2s[i] = (sig - 1.0) / p;
us[i] = (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]) -
(ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1]);
us[i] = (6.0 * us[i] / (xs[i + 1] - xs[i - 1]) - sig * us[i - 1]) / p;
}
qn = un = 0.0;
spY2s[n - 1] = (un - qn * us[n - 2]) / (qn * spY2s[n - 2] + 1.0);
for (int k = n - 2; k >= 0; k--) {
spY2s[k] = spY2s[k] * spY2s[k + 1] + us[k];
}
this.spInitialized = true;
}
int klo, khi, k;
num h, b, a;
klo = 0;
khi = n - 1;
while (khi - klo > 1) {
k = (khi + klo) >> 1;
if (xs[k] > x)
khi = k;
else
klo = k;
}
h = xs[khi] - xs[klo];
if (h == 0.0) {
throw new Exception('h==0.0');
}
a = (xs[khi] - x) / h;
b = (x - xs[klo]) / h;
return a * ys[klo] +
b * ys[khi] +
((a * a * a - a) * spY2s[klo] + (b * b * b - b) * spY2s[khi]) *
(h * h) /
6.0;
}
}
複製程式碼
line_painter.dart
/// Draws smooth lines between each point.
void _drawSmoothLine(Canvas canvas, Paint paint, List<Point> points) {
var interval = 0.1;
var interpolationPoints = List<Point>();
for (int k = 0; k < points.length; k++) {
if ((k + 1) < points.length) {
num temp = 0;
while (temp < points[k + 1].x) {
temp = temp + interval;
interpolationPoints.add(Point(temp, 0.0));
}
}
}
var tempX = points.map((item) => item.x).toList();
var tempY = points.map((item) => item.y).toList();
var ip = Interpolation(tempX, tempY);
for (int j = 0; j < interpolationPoints.length; j++) {
interpolationPoints[j] =
Point(interpolationPoints[j].x, ip.spline(interpolationPoints[j].x));
}
interpolationPoints.addAll(points);
interpolationPoints.sort((a, b) {
if (a.x == b.x)
return 0;
else if (a.x < b.x)
return -1;
else
return 1;
});
final path = new Path();
path.moveTo(interpolationPoints[0].x.toDouble(), interpolationPoints[0].y.toDouble());
for (int i = 1; i < interpolationPoints.length; i++) {
path.lineTo(interpolationPoints[i].x.toDouble(), interpolationPoints[i].y.toDouble());
}
canvas.drawPath(path, paint);
}
複製程式碼
最終效果圖
看起來效果還是挺完美的,但是其實有個致命問題,曲線的頂點可能會超出折線圖資料的範圍
三次貝塞爾曲線就是這樣的一條曲線,它是依據四個位置任意的點座標繪製出的一條光滑曲線,其難點是兩個控制點的計算,演算法參考 貝塞爾曲線平滑擬合折線段,程式碼實現如下: line_painter.dart
/// Draws smooth lines between each point.
void _drawSmoothLine(Canvas canvas, Paint paint, List<Point> points) {
var targetPoints = List<Point>();
targetPoints.add(points[0]);
targetPoints.addAll(points);
targetPoints.add(points[points.length - 1]);
final path = new Path();
for (int i = 1; i < targetPoints.length - 2; i++) {
path.moveTo(
targetPoints[i].x.toDouble(), targetPoints[i].y.toDouble());
var controllerPoint1 = Point(
targetPoints[i].x + (targetPoints[i + 1].x - targetPoints[i - 1].x) / 4,
targetPoints[i].y + (targetPoints[i + 1].y - targetPoints[i - 1].y) / 4,
);
var controllerPoint2 = Point(
targetPoints[i + 1].x - (targetPoints[i + 2].x - targetPoints[i].x) / 4,
targetPoints[i + 1].y - (targetPoints[i + 2].y - targetPoints[i].y) / 4,
);
path.cubicTo(
controllerPoint1.x, controllerPoint1.y, controllerPoint2.x,
controllerPoint2.y, targetPoints[i + 1].x, targetPoints[i + 1].y);
}
canvas.drawPath(path, paint);
}
複製程式碼
平滑曲線效果也是可以實現的,但是依然存在頂點越界的問題
- 貝塞爾曲線(MonotoneX)
因為之前 RN 專案用到了 victory-native / victory-chart,通過原始碼和文件發現它的曲線效果實現是依賴了 d3-shap 的 d3.curveMonotoneX,演算法參考 monotone.js,實現程式碼如下:
注:由於演算法需要當前點和前兩個點才能畫出一段曲線,所以在折線點資料集合最後人為新增了一個點,否則畫出來的曲線會缺少最後一段
line_painter.dart
/// Draws smooth lines between each point.
void _drawSmoothLine(Canvas canvas, Paint paint, List<Point> points) {
var targetPoints = List<Point>();
targetPoints.addAll(points);
targetPoints.add(Point(
points[points.length - 1].x * 2, points[points.length - 1].y * 2));
var x0,
y0,
x1,
y1,
t0,
path = Path();
for (int i = 0; i < targetPoints.length; i++) {
var t1;
var x = targetPoints[i].x;
var y = targetPoints[i].y;
if (x == x1 && y == y1) return;
switch (i) {
case 0:
path.moveTo(x, y);
break;
case 1:
break;
case 2:
t1 = MonotoneX.slope3(x0, y0, x1, y1, x, y);
MonotoneX.point(
path,
x0,
y0,
x1,
y1,
MonotoneX.slope2(x0, y0, x1, y1, t1),
t1);
break;
default:
t1 = MonotoneX.slope3(x0, y0, x1, y1, x, y);
MonotoneX.point(
path,
x0,
y0,
x1,
y1,
t0,
t1);
}
x0 = x1;
y0 = y1;
x1 = x;
y1 = y;
t0 = t1;
}
canvas.drawPath(path, paint);
}
複製程式碼
最終效果圖,頂點都是折線圖資料集合裡的點,完美!
- 原始碼
詳見 GitHub dev 分支 github.com/123lxw123/c…
本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。@123lxw123