Flutter视频编辑轨道 | 自定义View实现UI交互效果 | 触摸事件处理

Posted Code-Porter

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter视频编辑轨道 | 自定义View实现UI交互效果 | 触摸事件处理相关的知识,希望对你有一定的参考价值。

本篇文章主要是说明一下实现的思路, 故文中代码为部分代码完整源码点击此处查看

一、首先先来看下需要实现的交互效果

二、涉及的功能点

  • 轨道最大展示的时长,这里是3分钟(3分钟可自由配置)
  • 视频截取最短时长,这里是3秒钟(3秒钟可自由配置)
  • 当视频时长大于3分钟时,轨道底部可以滚动
  • 触摸事件处理
  • 轨道底部帧图片滑动时,实时计算截取的时间段
  • 时间线动画

三、首先需要对这个View进行拆解,这样有利于接下来的自定义View

共可拆分如下部分:

  • 左边拖拽耳朵
  • 右边拖拽耳朵
  • 中间红色矩形选中部分
  • 未选中部分的阴影
  • 时间线

四、通过继承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

以上是关于Flutter视频编辑轨道 | 自定义View实现UI交互效果 | 触摸事件处理的主要内容,如果未能解决你的问题,请参考以下文章

Flutter视频编辑轨道 | 自定义View实现UI交互效果 | 触摸事件处理

Flutter 自定义 View 介绍

Flutter 如何构建自定义 Widgets

C#axWindowsMediaPlayer使用自定义轨道栏更改视频位置

如何为 Flutter WebRTC 使用自定义视频源

Flutter自定义View之——价格选择器|双向滑动|手势处理