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

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

前言

上篇已經完成了K線的基礎繪製工作。但是還有很多的工作需要完善

今天來聊聊手勢的處理。

k線的手勢還真是一個K線開發的一個難點之一,筆者也是用了不少精力才處理完成。

主要有以下手3種勢需要處理

  • 水平滑動
  • 縮放
  • 長按

有的同學可能對手勢還不是很清楚,因此在這裡還是對上面的三種手勢分別來聊一聊。

不得不說,目前很多的金融軟體裡的K線體驗是很糟糕的,滑動,縮放抖動的的很厲害,一點也不平滑,體驗真的是還需要提升啊。

這裡就奔著達到自然絲滑滑動,縮放的目標處理手勢

先看看效果

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



效果解析

效果圖是用線上的視訊轉的gif圖,掉幀比較嚴重,實際效果是很流暢的,感興趣的可以在文章末尾下載體驗

k線首次展示時是滑動到最後的,螢幕上的endIndex就是資料的最後一條資料的索引, startIndex是endIndex減去顯示條數

手勢Widget

flutter 已經提供了一些很方便的手勢處理類,筆者使用的就是flutter提供的GestureDetector類來處理回撥的,簡單看看,可以發現GestureDetector中包裝了RawGestureDetector,RawGestureDetector中使用的是Listener來監聽觸控事件的。
如果需要自定義手勢的話可以使用Listener來處理個性化的識別
筆者使用這裡需要的是滑動識別,縮放識別,長按識別,所以使用GestureDetector就可以了,GestureDetector中這些手勢都已經是現成的了,非常的方便
Flutter的基礎模組還是挺齊全的.

手勢的回撥函式監聽程式碼如下。

  Widget _wrapperGesture(Widget widget) {
    return GestureDetector(
        onTapDown: _controllerModel.onTapDownGesture,
        onTapUp: _controllerModel.onTapUpGesture,
        onTapCancel: _controllerModel.onTapCancelGesture,
        onHorizontalDragStart: _controllerModel.onHorizontalDragStartGesture,
        onHorizontalDragDown: _controllerModel.onHorizontalDragDownGesture,
        onHorizontalDragUpdate: _controllerModel.onHorizontalDragUpdateGesture,
        onHorizontalDragEnd: _controllerModel.onHorizontalDragEndGesture,
        onLongPress: _controllerModel.onLongPressGesture,
        onLongPressStart: _controllerModel.onLongPressStartGesture,
        onLongPressMoveUpdate: _controllerModel.onLongPressMoveUpdateGesture,
        onLongPressUp: _controllerModel.onLongPressUpGesture,
        onLongPressEnd: _controllerModel.onLongPressEndGesture,
        onScaleStart: _controllerModel.onScaleStartGesture,
        onScaleUpdate: _controllerModel.onScaleUpdateGesture,
        onScaleEnd: _controllerModel.onScaleEndGesture,
        child: widget);
  }
複製程式碼

水平滑動

K線的水平滑動和scrollview是類似的
水平滑動,我們可以想象成是電影底片,很長的膠片,不停的轉動,然後在鏡頭處發光,把影像射到幕布上,電影的每一張影像就是這裡的每一幀畫面,膠片的長度就是K線裡可滑動的長度。



有個這些基本的想法,就可以開始著手滑動事件的處理了。
/// 水平滾動執行流程
/// 1、_onHorizontalDragStart
/// 2、_onHorizontalDragDown
/// 3、_onHorizontalDragUpdate
/// 4、_onHorizontalDragEnd
複製程式碼

具體的處理方法如下

 /// 設定當前的k線是滑動操作
 void onHorizontalDragStartGesture(DragStartDetails details) {
    klineOp = KlineOp.Scroll;
  }

  /// 暫時沒處理
  void onHorizontalDragDownGesture(DragDownDetails details) {}
  /// 手勢的更新,有人就是move事件觸發就用執行這個函式
  void onHorizontalDragUpdateGesture(DragUpdateDetails details) {
    _addHorizontalOffset(details.delta);
  }
  /// 當水平滑動結束時
  void onHorizontalDragEndGesture(DragEndDetails details) {
    klineOp = KlineOp.None;
    ...
  }
  /// 滑動時更新k線的scrollOffset,預設是0也就是在最右側時
  /// 這個需要注意的是我們常用的ScrollView的在最左邊時offset是0,
  /// 這是K線和ScrollView的一個小小區別吧,當然K線也可以把最scrollOffset為0是是最左邊,不過再轉換一次就好了
  void _addHorizontalOffset(Offset offset) {
    /// 當前的偏移量加上兩個touchEvent的offset值,
    scrollOffset += offset;
    /// 計算新的k線開始index和結束index
    /// 也就是定義電影膠片播放的位置
    /// 如果發生了變化就會通知更新,同步到UI的render去繪製新的畫面
    var change = _computeIndex();
    if (change) {
      notifyListeners();
    }
  }
複製程式碼

除此之外,我們在使用ScrollView的時候,ScrollView在迅速滑動時(fling),scrollview在手指離開了螢幕任然會向前滑動一段距離。

我們來想先如何來實現這個功能呢?
在初中物理裡面學過經典的牛頓力學,還記得公式嗎?
比如:

  • F=ma 比如重力G=mg, 質量*重力加速度9.8
  • S=V0t+1/2at^2 在t時間類運動的距離

在onHorizontalDragEndGesture時,系統會給我們一個結束時的速度. 模擬正是環境的話,速度應該越來也小。直到停下來,可能是自然停下來的,也可能是撞牆停下來。

使用Tween動畫來過度Fling中Offset的變化,從而達到一個平滑的丟擲效果

 var velocity = details.velocity;

   // 加速度 t=(v1-v0)/a,這個值是嘗試多次後,感覺效果還可以。
   var a = 200;
   // 需要滑動的時間, 先轉成dp的速度
   var t = velocity.pixelsPerSecond.dx / devicePixelRatio / a;

   var normalVelocity = velocity.pixelsPerSecond.dx / screenWidth;
   bool flingToLeft = normalVelocity < 0;

   double maxOffsetX = getMaxOffsetX();
   double offsetX = getScrollOffsetX();

   // S = VoT+0.5*a*t^2
   double predictDelta = 0.5 * a * t * t;
   double begin = offsetX;
   double end = flingToLeft ? max(0, offsetX - predictDelta) : min(maxOffsetX, offsetX + predictDelta);

   _scrollAnimation = scrollController.drive(Tween<double>(begin: begin, end: end));
   _scrollAnimation.removeListener(_handleMoveListener);
   _scrollAnimation.addListener(_handleMoveListener);
   scrollController.reset();
   scrollController.duration = Duration(milliseconds: (t * 1000).toInt());
   scrollController.fling(velocity: normalVelocity.abs());

複製程式碼

縮放動畫

縮放時,其實就是縮放每一條蠟燭的寬度,處理好一條蠟燭圖的寬度就相對也全部都處理好了。

這個需要主力幾點:

  • 縮放是平滑的
  • 縮放的中心點對應的k線,應該一直都是這一條
  • 縮放有縮放的最大值和最小值,不能無限縮放
  /// 開始縮放時標記當前操作型別
  /// 儲存縮放前的蠟燭寬度
  /// _orgCandleWidthScaleGap 待會載說
  void onScaleStartGesture(ScaleStartDetails details) {
    klineOp = KlineOp.Scale;
    _orgCandleWidthBeforeScale = candleWidth;
    _orgCandleW
    idthScaleGap = null;
  }
  /// 縮放結束
  void onScaleEndGesture(ScaleEndDetails details) {
    klineOp = KlineOp.None;
    _orgCandleWidthBeforeScale = null;
    _orgCandleWidthScaleGap = null;
  }
  /// 縮放更新
  void onScaleUpdateGesture(ScaleUpdateDetails details) {
    /// 第一次更新是給_orgCandleWidthScaleGap賦值
    /// 為啥要在第一次賦值?
    /// details.horizontalScale的值相對於兩指開始距離的倍數。
    /// 由於識別縮放是有一個閾值了,必須兩個手指move的距離超過閾值才能觸發縮放
    /// 所以在第一次觸發時,horizontalScale的值會離1比較遠,
    /// 這時如果原始的horizontalScale就是突然抖動一下
    /// 而且這個閾值可能導致縮放的識別比較慢,而誤識別成別的手勢。
    _orgCandleWidthScaleGap ??= 1 - details.horizontalScale;
    details.localFocalPoint.dx;
    double horizontalScale = details.horizontalScale + _orgCandleWidthScaleGap;
    double expectCandleWidth = _orgCandleWidthBeforeScale * horizontalScale;
    double expectDisplayCount = boxWidth / expectCandleWidth;
    if (expectDisplayCount < configModel.kLineConfig.minDisplayCount) {
      return;
    }
    if (expectDisplayCount > configModel.kLineConfig.maxDisplayCount) {
      return;
    }

    if (expectDisplayCount > configModel.kLineConfig.maxDisplayCount) {
      // 修正縮小的邊界
      expectCandleWidth = boxWidth / configModel.kLineConfig.maxDisplayCount;
    } else if (expectDisplayCount < configModel.kLineConfig.minDisplayCount) {
      // 修正放大的邊界
      expectCandleWidth = boxWidth / configModel.kLineConfig.minDisplayCount;
    }
    // 縮放的中心點
    double focalX = details.focalPoint.dx;
    _reCalcScaleAxis(focalX, expectCandleWidth);
  }

複製程式碼

scale閾值較大可能導致縮放的識別比較慢,而誤識別成別的手勢的問題?

方案一:修改原始碼

閱讀ScaleGestureRecognizer原始碼

void _advanceStateMachine(bool shouldStartIfAccepted) {
    if (_state == _ScaleState.ready)
      _state = _ScaleState.possible;

    if (_state == _ScaleState.possible) {
      final double spanDelta = (_currentSpan - _initialSpan).abs();
      final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance;
      /// 這裡就是縮放觸發的閾值kScaleSlop,kPanSlop
      /// 通過原始碼檢視kScaleSlop是個常量, 修改這個常量值。
      if (spanDelta > kScaleSlop || focalPointDelta > kPanSlop)
        resolve(GestureDisposition.accepted);
    } else if (_state.index >= _ScaleState.accepted.index) {
      resolve(GestureDisposition.accepted);
    }

    if (_state == _ScaleState.accepted && shouldStartIfAccepted) {
      _state = _ScaleState.started;
      _dispatchOnStartCallbackIfNeeded();
    }

    if (_state == _ScaleState.started && onUpdate != null)
      invokeCallback<void>('onUpdate', () {
        onUpdate(ScaleUpdateDetails(
          scale: _scaleFactor,
          horizontalScale: _horizontalScaleFactor,
          verticalScale: _verticalScaleFactor,
          focalPoint: _currentFocalPoint,
          localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint),
          rotation: _computeRotationFactor(),
        ));
      });
  }

複製程式碼
/// The distance a touch has to travel for the framework to be confident that
/// the gesture is a scroll gesture, or, inversely, the maximum distance that a
/// touch can travel before the framework becomes confident that it is not a
/// tap.
///
/// A total delta less than or equal to [kTouchSlop] is not considered to be a
/// drag, whereas if the delta is greater than [kTouchSlop] it is considered to
/// be a drag.
// This value was empirically derived. We started at 8.0 and increased it to
// 18.0 after getting complaints that it was too difficult to hit targets.
const double kTouchSlop = 18.0; // Logical pixels

/// The distance a touch has to travel for the framework to be confident that
/// the gesture is a paging gesture. (Currently not used, because paging uses a
/// regular drag gesture, which uses kTouchSlop.)
// TODO(ianh): Create variants of HorizontalDragGestureRecognizer et al for
// paging, which use this constant.
const double kPagingTouchSlop = kTouchSlop * 2.0; // Logical pixels

/// The distance a touch has to travel for the framework to be confident that
/// the gesture is a panning gesture.
const double kPanSlop = kTouchSlop * 2.0; // Logical pixels

/// The distance a touch has to travel for the framework to be confident that
/// the gesture is a scale gesture.
const double kScaleSlop = kTouchSlop;
複製程式碼

方案二:

由於方案一需要修原始碼,別的f同學的Flutter SDK也得修改才可,所以,想到的最快方法就是複製一份手勢識別的程式碼到專案中然後修改相應的常量即可。

方案三:

直接是Listener來自定義手勢,這樣對縮放特殊處理,比如當有兩個手指時,就觸犯scale,而不是需要滑動一段距離才觸發,這樣就可針對K線的場景特殊優化縮放了。

長按的手勢處理

長按相比滑動和縮放要簡單很多了,

長按時計算好對應的長按K線資料的pressedIndex就好了,然後通知可以處理長按更新的render去處理就好了。

且看程式碼

void onLongPressStartGesture(LongPressStartDetails details) {
    klineOp = KlineOp.LongPress;
    _handleLongPress(details.localPosition.dx);
  }

  void onLongPressMoveUpdateGesture(LongPressMoveUpdateDetails details) {
    _handleLongPress(details.localPosition.dx);
  }

  void _handleLongPress(double x) {
    int pressedIndex = getIndexByX(x).toInt();
    if (this.pressedIndex != pressedIndex) {
      this.pressedIndex = pressedIndex;
    }
    hightLightModel.notify();
  }

  void onLongPressUpGesture() {}

  void onLongPressEndGesture(LongPressEndDetails details) {
    klineOp = KlineOp.None;
    pressedIndex = INVALID;
    hightLightModel.notify();
  }

複製程式碼

總結

優點:手勢處理這塊筆者還是比較滿意的,相對同類產品還算就很流暢的了。 不足之處: 縮放的識別還得重構一下,因為系統的縮放識別閾值太大了,有時不能觸發縮放。

想下載體驗的歡迎下載

連結: https://pan.baidu.com/s/15JZToKwuELN2RoemNEws1A 
提取碼: trej 複製這段內容後開啟百度網盤手機App,操作更方便哦複製程式碼

下節聊聊在K線上畫線,此功能還在編寫中。


相關文章