Flutter視訊編輯軌道 | 自定義View實現UI互動效果 | 觸控事件處理

阿鍾發表於2021-08-12

本篇文章主要是說明一下實現的思路, 故文中程式碼為部分程式碼完整原始碼點選此處檢視

一、首先先來看下需要實現的互動效果

Flutter視訊編輯軌道 | 自定義View實現UI互動效果 | 觸控事件處理 Flutter視訊編輯軌道 | 自定義View實現UI互動效果 | 觸控事件處理

二、涉及的功能點

  • 軌道最大展示的時長,這裡是3分鐘(3分鐘可自由配置)
  • 視訊擷取最短時長,這裡是3秒鐘(3秒鐘可自由配置)
  • 當視訊時長大於3分鐘時,軌道底部可以滾動
  • 觸控事件處理
  • 軌道底部幀圖片滑動時,實時計算擷取的時間段
  • 時間線動畫

三、首先需要對這個View進行拆解,這樣有利於接下來的自定義View

Flutter視訊編輯軌道 | 自定義View實現UI互動效果 | 觸控事件處理

共可拆分如下部分:

  • 左邊拖拽耳朵
  • 右邊拖拽耳朵
  • 中間紅色矩形選中部分
  • 未選中部分的陰影
  • 時間線

四、通過繼承CustomPainter使用CustomPaint來完成效果繪製

元件結構如下:

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 48,
      width: double.infinity,
      child: LayoutBuilder(
        builder: (context, constraints) {
          ///在這裡獲取畫布的大小
          _initView(constraints);
          return GestureDetector(
            onHorizontalDragDown: (down) {},
            onHorizontalDragUpdate: (move) {},
            onHorizontalDragEnd: (up) {},
            child: Stack(
              children: [
                Positioned(
                  left: earSize.width,
                  right: earSize.width,
                  child: SingleChildScrollView(
                    scrollDirection: Axis.horizontal,
                    child: _getImageChild(),
                   ),
                ),
                CustomPaint(
                  size: viewSize,
                  painter: VideoTrackPainter(),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  void _initView(BoxConstraints constraints) {
    if (rightEarOffset == null) {
      ///畫布大小
      viewSize = Size(constraints.maxWidth, constraints.maxHeight);
      ///中間軌道大小
      trackSize = Size(viewSize.width - earSize.width * 2, viewSize.height);
    }
  }
複製程式碼
  • 通過LayoutBuilder元件獲取本元件可以使用的大小,然後將這個大小賦予給CustomPaint;這樣在CustomPainter#paint()中就可以獲取到這個大小就可以直接在這個大小內作畫了
@override
void paint(Canvas canvas, Size size) {
/// size即為我們設定的大小
}
複製程式碼
  • 通過GestureDetector完成手勢的監聽,這裡只需要處理水平拖動事件即可
  • 使用Stack佈局來完成底部可滾動的幀圖片元件頂部可拖動的元素
  • 由效果圖可以看出底部可滾動的幀圖片元件應該處於兩邊可拖拽耳朵內,所以兩邊需要向內縮排耳朵的大小

五、接下來就是將頂部可拖動的元素一個一個繪製出來了

  • 繪製左邊的耳朵
    • 從畫布的左上角(0,0)座標開始繪製;這裡繪製的就是個矩形只不過左邊耳朵,左邊需要加圓角;右邊的耳朵,右邊需要加圓角
    • 還需要在這個矩形中間在繪製一個白色的圓角矩形
  @override
  void paint(Canvas canvas, Size size) {
    _createEar(canvas, leftEarOffset, true);
    _createEar(canvas, rightEarOffset, false);
  }
  ///建立兩邊的耳朵
  void _createEar(Canvas canvas, Offset offset, bool leftCorner) {
    Rect rect = offset & earSize;
    Radius radius = Radius.circular(6);
    RRect rRect = RRect.fromRectAndCorners(
      rect,
      topLeft: leftCorner ? radius : Radius.zero,
      bottomLeft: leftCorner ? radius : Radius.zero,
      topRight: leftCorner ? Radius.zero : radius,
      bottomRight: leftCorner ? Radius.zero : radius,
    );
    earPaint.color = Color(0xFFFF443D);
    canvas.drawRRect(rRect, earPaint);

    ///白色矩形
    Rect whiteRect = Rect.fromCenter(
        center: Offset(offset.dx + rect.width / 2, offset.dy + rect.height / 2),
        width: earWhiteSize.width,
        height: earWhiteSize.height);
    earPaint.color = Colors.white;
    RRect whiteRRect = RRect.fromRectAndRadius(whiteRect, Radius.circular(4));
    canvas.drawRRect(whiteRRect, earPaint);
  }
複製程式碼
  • 繪製右邊的耳朵
    • 右邊的座標計算公式:rightEarOffset = Offset(畫布寬度 - 耳朵的寬度, 0);
  • 繪製中間矩形框
    • 座標計算公式:Rect.fromLTRB(左邊耳朵的偏移量+耳朵寬度, 0 + 1, 右邊耳朵的偏移量, 畫布高度 - 1);

    這裡說明一下+1和-1:是為了讓上下邊框往中間縮排一下,因為畫筆是有寬度的

	  ///建立中間的矩形
  void _createRect(
      Canvas canvas, Size size, Offset leftEarOffset, Offset rightEarOffset) {
      double left = leftEarOffset.dx + earSize.width;
      double right = rightEarOffset.dx;
      ///線的寬度
      double top = leftEarOffset.dy + 1;
      double bottom = size.height - 1;
      Rect rect = Rect.fromLTRB(left, top, right, bottom);
      canvas.drawRect(rect, rectPaint);
  }
複製程式碼
  • 繪製左右兩邊的陰影遮罩
    • 這個其實就是最左邊到左邊耳朵的一個灰色矩形,右邊耳朵到最右邊的一個矩形,如下:
  void _createMaskLayer(Canvas canvas, Size size) {
    Rect leftRect =
        Rect.fromLTWH(earSize.width, 0, leftEarOffset.dx, size.height);
    canvas.drawRect(leftRect, maskPaint);
    Rect rightRect = Rect.fromLTWH(rightEarOffset.dx, 0,
        size.width - rightEarOffset.dx - earSize.width, size.height);
    canvas.drawRect(rightRect, maskPaint);
  }
複製程式碼
  • 再說繪製時間線這裡需要先說明下,擷取時間段的計算;也就是中間矩形選中的時長(開始時間~結束時間)
    • 開始時間點計算公式:double startSecond = 軌道寬度 / 視訊時長(秒) * 左邊耳朵的偏移量
    • 結束時間點計算公式:double endSecond = startSecond + (右邊耳朵的偏移量 - (左邊耳朵的偏移量 + 耳朵的寬度)) / ( 軌道寬度 / 視訊時長(秒) )
  • 繪製時間線

    時間線需要一個動畫從開始點一直移動到結束點 然後在迴圈繼續,所以這裡只需要知道時間線移動的起點和終點移動的時間

    • 移動起點和終點計算公式:double begin = 左邊耳朵的偏移量 + 耳朵的寬度double end = 右邊耳朵的偏移量
    • 時長計算公式:這個在上面已經說了,所以只需要結束時間 - 開始時間即可
    • 最後通過AnimationController開啟時間線動畫即可
///繪製時間線
void _createTimeLine(Canvas canvas, Size size) {
  Offset start = Offset(timelineOffset.dx, 0);
  Offset end = Offset(timelineOffset.dx, size.height);
  canvas.drawLine(start, end, timelinePaint);
}

///時間線動畫
startTimelineAnimation() {
  int selectDuration = selectEndDur.inMilliseconds - selectStartDur.inMilliseconds;
  _timelineController = new AnimationController(
      duration: Duration(milliseconds: selectDuration), vsync: this);
  CurvedAnimation curve =
      CurvedAnimation(parent: _timelineController!, curve: Curves.linear);
  Animation animation =
      Tween(begin: leftEarOffset.dx + earSize.width, end: rightEarOffset!.dx)
          .animate(curve);
  animation.addListener(() {
    setState(() {
      timelineOffset = Offset(animation.value, 0);
    });
  });
  _timelineController?.repeat();
}
複製程式碼

六、手勢處理讓元素動起來,開頭已經說了使用GestureDetector元件來處理

  • 在接受到觸控事件時,我們需要去改變 左邊耳朵,右邊耳朵的偏移量然後通過setState(() {});進行狀態重新整理,這樣就讓元素動起來了。那麼這裡有個問題:怎麼知道當前觸控的是左邊還是右邊呢?

答案就是:通過Rectcontains()函式可以判定這個點是否在這個矩形區域內,這樣就知道手指觸控的是左邊還是右邊了

  • 判斷按下時,該改變左邊耳朵還是右邊耳朵偏移量,如下:
_onDown(Offset offset) {
  double dx = offset.dx;
  if (dx <= 0) dx = 0;
  ///判斷選中的是哪一個
  Rect leftRect = leftEarOffset & earSize;
  if (leftRect.contains(offset)) {
    touchLeft = true;
    return;
  }
  Rect rightRect = rightEarOffset! & earSize;
  if (rightRect.contains(offset)) {
    touchRight = true;
    return;
  }
}
複製程式碼
  • 然後在滑動時改變對應的偏移量同時重新整理狀態就可以達到拖動的效果了

在觸控事件這裡還有有個問題:開頭說了使用通過Stack元件來達到效果,這也就說所有的觸控事件都會被頂部的CustomPaint所消費掉,這就導致了當顯示的視訊超出設定的3分鐘時,底部幀圖片無法左右滑動來選擇範圍。

  • 上面這個問題就引申出了Flutter的事件分發處理,這裡就不詳細展開說了感興趣的可以找資料查閱下
  • 這裡對於事件處理的條件很簡單如下:
    • 當觸控的位置不是左 右兩邊耳朵的位置時,需要將事件繼續向下傳遞,然後讓SingleChildScrollView元件自行處理即可
    • 所以這裡我們需要自定義RenderBox並重寫hitTest()函式來對事件進行分發處理

那麼這裡怎麼自定義RenderBox來實現呢?

  • 最簡單方便的當然是自定義CustomPaintRenderCustomPaint,然後重寫CustomPaint#createRenderObject()函式返回自定義的RenderCustomPaint;這樣就可以重寫hitTest函式進行邏輯處理就可以了,程式碼如下:
class TrackCustomPaint extends CustomPaint {
  const TrackCustomPaint({
    Key? key,
    CustomPainter? painter,
    CustomPainter? foregroundPainter,
    Size size = Size.zero,
    bool isComplex = false,
    bool willChange = false,
    Widget? child,
  }) : super(
            key: key,
            painter: painter,
            foregroundPainter: foregroundPainter,
            size: size,
            isComplex: isComplex,
            willChange: willChange,
            child: child);

  @override
  TrackRenderCustomPaint createRenderObject(BuildContext context) {
    return TrackRenderCustomPaint(
      painter: painter,
      foregroundPainter: foregroundPainter,
      preferredSize: size,
      isComplex: isComplex,
      willChange: willChange,
    );
  }
}

class TrackRenderCustomPaint extends RenderCustomPaint {
  TrackRenderCustomPaint({
    CustomPainter? painter,
    CustomPainter? foregroundPainter,
    Size preferredSize = Size.zero,
    bool isComplex = false,
    bool willChange = false,
    RenderBox? child,
  }) : super(
          painter: painter,
          foregroundPainter: foregroundPainter,
          preferredSize: preferredSize,
          isComplex: isComplex,
          willChange: willChange,
          child: child,
        );

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    VideoTrackPainter trackPainter = painter as VideoTrackPainter;
    return trackPainter.interceptTouchEvent(position);
  }
}
複製程式碼
  • interceptTouchEvent()具體實現:就是判定當前觸控位置是否是左 右兩邊耳朵的位置
bool interceptTouchEvent(Offset offset) {
  Rect leftRect = leftEarOffset & earSize;
  Rect rightRect = rightEarOffset & earSize;
  return leftRect.contains(offset) || rightRect.contains(offset);
}
複製程式碼

七、現在就剩最後一個問題需要處理了:當滑動底部的幀圖片需要實時計算當前擷取的位置

  • 這裡以實際資料來舉例說明下:

    假設編輯的視訊是4分鐘,此時軌道最大顯示為3分鐘,那麼就可以理解為:底部幀圖片超出軌道的距離 代表的時長就是1分鐘了,那麼我們就可以通過這個方式計算當滑動底部幀圖片時擷取的視訊起止時間了

  • 計算公式如下:
///因為底部可以不滾動所以有可能會為0的情況,需要做處理
double scrollerSecond = 0;
double perScrollerSecond = _calcScrollerSecond();
if (perScrollerSecond != 0) {
  scrollerSecond = _scrollController.offset / perScrollerSecond;
}

///計算每秒的偏移量
  double _calcScrollerSecond() {
    int diffDuration = 視訊時長(秒) - 軌道顯示的時長(秒);
    if (diffDuration == 0) return 0;
    return _scrollController.position.maxScrollExtent / diffDuration;
  }
複製程式碼
  • 底部滑動的時間已經算好了,那麼就只需要把這個時間加到上面計算的開始時間裡就可以了,最終開始時間的計算公式為:double startSecond = scrollerSecond + 軌道寬度 / 視訊時長(秒) * 左邊耳朵的偏移量

八、到這裡這個視訊編輯軌道UI就說完,主要還是體現一個實現思路,具體程式碼可以到這裡檢視GitHub-VideoCropTrack

相關文章