Flutter 實現平滑曲線折線圖

升級之路發表於2018-12-15

背景

公司最近引入了 Flutter 技術棧,Flutter 是谷歌的移動 UI 框架,可以快速在 iOS 和 Android 上構建高質量的原生使用者介面。然而由於 Flutter 還在早期發展階段沒有,生態建設還不夠完善。比如專案中需要用到圖表 UI 元件,經過一番調研,Google/charts 功能最強大,樣式最豐富(詳見 online gallery),於是引入到專案中。但是 charts 只實現了直線折線圖,所以只能 fork charts 專案自己實現平滑曲線效果。

Flutter 實現平滑曲線折線圖

基礎使用

  • Goole/charts 這個圖表庫很強大,但是文件不太友好,只有 online gallery 上有純示例程式碼,幾乎沒有 Api 說明。

Flutter 實現平滑曲線折線圖

Flutter 實現平滑曲線折線圖

  • 可行性分析的 Demo 效果

Flutter 實現平滑曲線折線圖

  • 仔細研究優化後的效果

Flutter 實現平滑曲線折線圖

  • 具體使用程式碼及註釋
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

相關文章