Flutter小小實踐——KLine 繪製篇(二)

快樂的小青蛙發表於2020-07-16

效果圖

Flutter小小實踐——KLine 繪製篇(二)


效果圖拆解

  1. 圖中包含上下兩個部分,上部分是蠟燭圖,下部分是成交量
  2. 兩個圖中分為兩個部分,背景的柵格和內容部分,並且有一定的關聯性
  3. 蠟燭圖的成交量的X軸是時間線,並且同一個位置的時間相同,也就是垂直方向上時間是同步的。
  4. Y軸代表數字範圍。
  5. 蠟燭圖和成交量有很多的相似處,因此程式碼結構上可以複用。

抽取繪圖Widget

class XRenderWidget<T extends ChangeNotifier> extends LeafRenderObjectWidget {
  BaseRender baseRender;
  XRenderWidget({Key key, this.baseRender}) : super(key: key);
  @override
  RenderObject createRenderObject(Object context) {
    try {
      Provider.of<T>(context);
    } catch (Exception) {
      // ignore
    }
    return XRenderBox(baseRender: baseRender);
  }

  @override
  void updateRenderObject(BuildContext context, XRenderBox renderObject) {
    super.updateRenderObject(context, renderObject);
    print("$baseRender updateRenderObject");
    renderObject.updateRender();
  }
}
複製程式碼

XRenderWidget 做了兩件事

  1. 建立RenderObject
  2. 更新RenderObject

在rebuild之後,updateRenderObject會被執行,和StatefulWidget類似,重複利用RenderObject來渲染UI,提高利用率

抽取繪圖RenderBox

class XRenderBox extends RenderBox {
  BaseRender baseRender;

  XRenderBox({this.baseRender});

  @override
  void performLayout() {
    super.performLayout();
    baseRender?.onPerformLayout(size);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    super.paint(context, offset);
    baseRender?.onPaint(context, offset);
  }

  @override
  bool get sizedByParent => true;

  @override
  bool hitTestSelf(Offset position) => true;

  void updateRender() {
    baseRender?.updateRender();
    markNeedsPaint();
  }
}
複製程式碼

XRenderBox 很簡單,靜態代理了一些核心函式

BaseRender幹了什麼事?

我們先想想開始的效果都拆解了什麼。

沒錯,大概是做了這些事

  1. 儲存了RenderObject的Size
  Size size;
複製程式碼
  1. 效果圖中蠟燭圖底部還有柵格,因此還得擁有UI層次處理的能力
  List<T> _aboveChildren = [];
  List<T> _underChildren = [];
  BaseMaxMinRender parent;

  void addChild(T child, {int elevation = 0}) {
    if (elevation < 0) {
      _underChildren.add(child);
    } else {
      _aboveChildren.add(child);
    }
  }

  void delChild(T child) {
    _aboveChildren.remove(child);
    _underChildren.remove(child);
  }

複製程式碼
  1. Widget的上邊界和下邊界分別是最大值和最小值,而flutter中的座標的原點是左上角,向右是X的正反向,向下是Y的正反向,與繪製圖是座標系是不一樣的,因此需要擁有座標系變換的能力。
  /// 該render自己這層的matrix4
  vm.Matrix4 _matrix4 = vm.Matrix4.identity();
  /// 圖層疊加後的matrix4
  vm.Matrix4 get matrix4 => parent?.matrix4 ?? _matrix4;

  void _calcMaxMin() {
    
    MaxMin newMaxMin;
    /// 該層的最大最小值
    MaxMin cur = calcOwnMaxMin();
    /// children的最大最小值
    MaxMin children = _childrenMaxMin();
    /// 沒有children,或者children無需計算最大最小則是null
    if (children == null) {
      newMaxMin = cur;
    } else {
      // 把自己的最大值和最小值與children合併成新的最大最小值
      newMaxMin = cur.merge(children);
    }
    if (newMaxMin != maxMin) {
      if (maxMin == null || maxMin.isZero()) {
        /// maxMin為初始化則直接賦值
        maxMin = newMaxMin;
      } else {
        /// 最大值也最小值發生了變化,則平滑改變最大最小,體驗會流程很多
        _smoothChangeMaxMin(newMaxMin);
      }
    }
  }

  /// 通過MaxMin的值,變換成K線圖的正交座標系,變換系數儲存在matrix4中,方便資料變化
  void _transformMatrix() {
    if (_maxMin != null) {
      _matrix4.setIdentity();
      /// 計算Y軸的縮放值
      var scaleY = (height - edgeInsets.bottom - edgeInsets.top) / _maxMin.delta;
      /// 設定矩陣的在X軸的偏移量,因為圖中的最小值並不都是0開始,因此需要在X軸上移動相應的距離
      _matrix4.setTranslation(vm.Vector3(0, height - edgeInsets.bottom + _maxMin.min * scaleY, 0.0));
      /// 設定矩陣的對角線值 對角線的值分別是x,y,z的縮放值。1表示不縮放,-scaleY表示Y軸的值都要與-scaleY相乘,因此相當於是縮放了scaleY,並且反轉的Y軸的反向。
      _matrix4.setDiagonal(vm.Vector4(1, -scaleY, 1, 1));
    } else {
      _matrix4.setIdentity();
    }
  }
複製程式碼

繪製蠟燭圖

render有了層次處理,座標變換的能力之後,就可以方便的繪製影像了。

class CandleRender extends BaseKLineRender {
  Paint _klinePaint = Paint();
  /// 螢幕顯示區域的蠟燭芯資料
  List<double> wickData = [];
  /// 螢幕顯示區域的蠟燭資料
  List<double> candleData = [];

  CandleRender(ControllerModel controller) : super(controller);

  Color _itemColor(int i) => controller.getColorRelativeStartIndex(i);

  @override
  void fillOwnData() {
    super.fillOwnData();
    wickData.clear();
    candleData.clear();
    /// 遍歷需要顯示在螢幕部分的資料
    forEachData((i) {
      double x = controller.getXByIndex(i);
      /// 三個資料表示一個點(x,y,z),這裡的z是0
      /// 新增蠟燭芯的線段資料
      wickData..add(x)..add(klineData[i].high)..add(0)..add(x)..add(klineData[i].low)..add(0);
      /// 新增蠟燭體的線段資料
      candleData..add(x)..add(klineData[i].open)..add(0)..add(x)..add(klineData[i].close)..add(0);
    });
  }

  @override
  void transformData() {
    /// 把上面新增的資料,經過座標變換,轉成螢幕上的資料。
    /// 上面在新增的源資料(x,y,z),其中x已經是螢幕上的畫素值了,但是y是價格,y也要做一定的縮放和平移
    matrix4.applyToVector3Array(wickData);
    matrix4.applyToVector3Array(candleData);
  }

    /// 計算自身的這一層的最大最小值。
  @override
  MaxMin calcOwnMaxMin() {
    double max = -double.maxFinite;
    double min = double.maxFinite;
    for (int i = controller.startIndex; i <= controller.endIndex; i++) {
      if (i == controller.startIndex) {
        min = klineData[i].low;
        max = klineData[i].high;
      } else {
        max = math.max(max, klineData[i].high);
        min = math.min(min, klineData[i].low);
      }
    }
    return MaxMin(min: min, max: max);
  }

  @override
  void onRealPaint(Canvas canvas) {
      super.onRealPaint(canvas);
    _klinePaint.strokeWidth = 1;
    /// 蠟燭芯的畫筆大小是1就可以了
    drawLines(canvas, wickData, controller.needDrawCount(), _klinePaint, color: _itemColor);
    _klinePaint.strokeWidth = controller.candleWidth - 1;
    /// 蠟燭體的畫筆大小是, 蠟燭所佔據的寬度 - 1, 這樣蠟燭直接就有個1的空隙。比較美觀點 
    drawLines(canvas, candleData, controller.needDrawCount(), _klinePaint, color: _itemColor);
  }
}
複製程式碼

繪製成交量

有個蠟燭的繪製,成交量的繪製就再交單不過了
同樣的填充資料,計算最大最小值,轉換資料,繪製資料。

class VolumeRender extends BaseKLineRender {
  Paint _klinePaint = Paint();
  List<double> _volData = [];

  VolumeRender(ControllerModel controller) : super(controller);

  Color _itemColor(int i) => controller.getColorRelativeStartIndex(i);

  @override
  void fillOwnData() {
    super.fillOwnData();
    _volData.clear();
    forEachData((i) {
      double x = controller.getXByIndex(i);
      _volData..add(x)..add(klineData[i].amount)..add(0)..add(x)..add(0)..add(0);
    });
  }

  @override
  void transformData() {
    matrix4.applyToVector3Array(_volData);
  }

  @override
  MaxMin calcOwnMaxMin() {
    double min = 0;
    double max = 0;
    forEachData((i) {
      max = math.max(max, klineData[i].amount);
    });
    return MaxMin(min: min, max: max);
  }

  

  @override
  void onRealPaint(Canvas canvas) {
    super.onRealPaint(canvas);
    _klinePaint.strokeWidth = controller.candleWidth - 1;
    drawLines(canvas, _volData, controller.needDrawCount(), _klinePaint, color: _itemColor);
  }
}

複製程式碼

繪製網格

蠟燭和成交量都有網格,並且是在最下面的圖層

class GridLineRender extends _BaseGridLineRender {
  Paint _paint = Paint();

  GridLineConfig gridLineConfig;

  GridLineRender(this.gridLineConfig, controller) : super(controller);

  @override
  void onRealPaint(Canvas canvas) {
    super.onRealPaint(canvas);
    /// 座標變換的逆變換,用途是更加螢幕上的座標算出對應的價格。
    /// 這樣網路線上對應的價格就很方便的得知了。
    Matrix4 m = matrix4.clone()..invert();
    /// 根據配置繪製水平線
    for (int i = 0; i < gridLineConfig.horizontalCount; i++) {
      double y = height / (gridLineConfig.horizontalCount - 1) * i;
      _paint.strokeWidth = gridLineConfig.horizontalStrokeWidth;
      _paint.color = gridLineConfig.horizontalColor;
      canvas.drawLine(Offset(0, y), Offset(width, y), _paint);
      if (isNotEmpty(klineData) && totalMaxMin != null) {
        List<double> yy = [0, y, 0];
        m.applyToVector3Array(yy);
        if (i == 0) {
          drawText(canvas, "${format != null ? format(yy[1]) : yy[1]}", Offset(width, y), align: TextAlign.end);
        } else if (i == gridLineConfig.horizontalCount - 1) {
          drawText(canvas, "${format != null ? format(yy[1]) : yy[1]}", Offset(width, y - 12), align: TextAlign.end);
        } else {
          drawText(canvas, "${format != null ? format(yy[1]) : yy[1]}", Offset(width, y - 12), align: TextAlign.end);
        }
      }
    }
    /// 根據配置繪製垂直線
    for (int i = 0; i < gridLineConfig.verticalCount; i++) {
      double x = width / (gridLineConfig.verticalCount - 1) * i;
      _paint.strokeWidth = gridLineConfig.verticalStrokeWidth;
      _paint.color = gridLineConfig.verticalColor;
      canvas.drawLine(Offset(x, 0), Offset(x, height), _paint);
    }
  }
}

class GridLineConfig {
  int verticalCount = 6;
  Color verticalColor = Colors.grey[300];
  double verticalStrokeWidth = 0.5;

  int horizontalCount = 3;
  Color horizontalColor = Colors.grey[300];
  double horizontalStrokeWidth = 0.5;
}

複製程式碼

Render加到Widget中

/// 成交量render
volumeRender = VolumeRender(_controllerModel);
/// 蠟燭圖render
candleRender = CandleRender(_controllerModel);
/// 蠟燭圖新增個子render繪製底層網格
candleRender.addChild(
    GridLineRender(GridLineConfig()
        ..horizontalCount = 5, _controllerModel)
        ..format = (double val) => formatNumber(val, 2),
    elevation: -1);
/// 成交量新增子render,繪製底層網格
volumeRender.addChild(
    GridLineRender(GridLineConfig(), _controllerModel)
        ..format = (double val) => formatNumber(val, 2),
    elevation: -1);


return MultiProvider(
        providers: [
          ChangeNotifierProvider<DataModel>(create: (_) => _dataModel),
          ChangeNotifierProvider<ConfigModel>(create: (_) => _configModel),
          ChangeNotifierProvider<ControllerModel>(create: (_) => _controllerModel),
          ChangeNotifierProvider<KLineHighlightModel>(create: (_) => _hightLightModel),
        ],
        child: _wrapperGesture(
          Consumer<ControllerModel>(builder: (context, controllerModel, child) {
            _logger.debug("Consumer KLineControllerModel");
            return Column(
                  children: <Widget>[
                    xRenderWidget<DataModel>(candleRender, height: 200),
                    xRenderWidget<DataModel>(volumeRender, height: 100),
                  ],
                );
          }),
        ));
複製程式碼

總結

現在完成了蠟燭圖的繪製和成交量的繪製,算是有個初步的樣子了。文中有些程式碼比較醜,等寫完在重構整理整理。

下一節聊聊手勢處理


相關文章