自定义View之视频裁剪条

3,141 阅读6分钟

老规矩先上图

撸码

使用:

 val adapter = TestAdapter(this)
        main_cut.setFrameAdapter(adapter)
        main_cut.setVideoDuration(20000)
        //模拟延时加载帧列表
        main_cut.postDelayed({
            main_cut.computeWithDataComplete(dp2px(this, 60f), Runnable {
                //此处runnable作用是添加透明item
                adapter.addData("")
            })
        }, 3000)

        main_cut.setOnCutDurationListener { startMs, endMs, state, orientation ->
            /**
             * @param state
             * @see STATE_MOVE
             * @see STATE_IDLE
             *
             *
             * @param orientation
             * @see ORIENTATION_LEFT
             * @see ORIENTATION_RIGHT
             *
             */
            Log.d(
                TAG,
                "startMs = $startMs , endMs = $endMs ,state = $state , orientation = $orientation"
            )
            main_txt.text =
                "startMs = $startMs , endMs = $endMs ,state = $state , orientation = $orientation"
        }

        //设置进度条监听
        main_cut.setOnProgressListener(object : VideoCutLayout.OnProgressChangedListener {

            override fun onDragDown(time: Long) {
                //手指按下进度条
            }

            override fun onDragMove(time: Long) {
                //拖动进度条
            }

            override fun onDragUp(time: Long) {
                //松开进度条
            }
        })

分析一下:

  • 第一层是一个帧列表

  • 第二层是可以拖动的控件

首先看 拖动条 两侧是两张图,中间上下各一根线,通过手指滑动改变两张图的left /right 即可达到移动。需要注意的是滑动的坐标是以高亮部分为准,所以返回给外层时候需要减去两侧图片的宽度。


更新部分:增加拖动的方向值(LEFT,RIGHT)。与状态值(MOVE ,IDLE)

关键代码如下:

  #onDraw
        //画蒙层
        mPaint.style = Paint.Style.FILL
        mPaint.color = Color.parseColor("#80000000")
        canvas.drawRect(getLeftWidth().toFloat(), 0f, mLeftPadding, height.toFloat(), mPaint)
        canvas.drawRect(mRightPadding, 0f, width.toFloat(), height.toFloat(), mPaint)

        //先画上下两根线
        mPaint.style = Paint.Style.STROKE
        mPaint.color = ContextCompat.getColor(context, R.color.colorAccent)
        canvas.drawLine(mRectF.left, 0f, mRectF.right, 0f, mPaint)
        canvas.drawLine(mRectF.left, height.toFloat(), mRectF.right, height.toFloat(), mPaint)

        //再画边界
        canvas.drawBitmap(mLeftBitmap, mLeftPadding, 0f, mPaint)
        canvas.drawBitmap(mRightBitmap, mRightPadding - mRightBitmap.width, 0f, mPaint)
# onTouchEvent


        var consumed = false
        if (!isEnabled)
            return consumed
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                //判断是否点击点在边界上,如果是则处理此次事件。
                val downX = event.x
                mDownX = downX
                mLastX = downX
                consumed = if (downX > mLeftPadding && downX < mLeftPadding + mLeftBitmap.width) {
                    click = ORIENTATION_LEFT
                    mLastX = downX
                    true
                } else if (downX > mRightPadding - mRightBitmap.width && downX < mRightPadding) {
                    click = ORIENTATION_RIGHT
                    mLastX = downX
                    true
                } else {
                    click = -1
                    false
                }
            }
            //move事件判断移动是否超过端点,高亮部分是否小于最小时间宽度
            MotionEvent.ACTION_MOVE -> {
                val moveX = event.x
                val dx = moveX - mLastX
                consumed = when (click) {
                    ORIENTATION_LEFT -> {
                        val newPadding = max(mLeftPadding + dx, 0f)
                        if (newPadding + mLeftBitmap.width < mRectF.right) {
                            val curDuration =
                                (mRectF.right - newPadding - mLeftBitmap.width) * durationPx
                            if (!(curDuration <= minDuration + 1 && dx > 0)) {
                                mLeftPadding = newPadding
                            }
                        }
                        true
                    }
                    ORIENTATION_RIGHT -> {
                        val newPadding = min(mRightPadding + dx, width.toFloat())
                        if (newPadding - mRightBitmap.width > mRectF.left) {
                            val curDuration =
                                (newPadding - mRightBitmap.width - mRectF.left) * durationPx
                            if (!(curDuration <= minDuration + 1 && dx < 0)) {
                                mRightPadding = newPadding
                            }
                        }
                        true
                    }
                    else -> {

                        false
                    }
                }
                if (consumed) {
                    mLastX = moveX
                    mRectF.left = mLeftPadding + mLeftBitmap.width
                    mRectF.right = mRightPadding - mRightBitmap.width
                    invalidate()
                    mListener?.invoke(mRectF.left, mRectF.right, STATE_MOVE, click)
                }
            }

            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                //这里不考虑长按等事件,简单处理。 有需要可以再处理。
                consumed = true
//                val upX = event.x
//                if (abs(upX - mDownX) < 5) {
//                    performClick()
//                } else {
                mListener?.invoke(mRectF.left, mRectF.right, STATE_IDLE, click)
//                }
                click = -1

            }

        }

        return consumed

接着组合帧列表和拖动条,这里我使用RecyclerView作为帧列表控件,适配器通过调用者传进来,这样可以满足不同的UI需求。自定义一个CutLayout继承自FrameLayout即可,添加view的部分就不写了。说一下计算时间的思路:分为两种情况,视频时长大于最大时长和视频时长小于最大时长。当视频时长小于最大时长时,直接计算出拖动条高亮部分每个像素代表多少时间 t 即可,开始时间 = start * t , 结束时间=start + (end-start)*t。 当视频时长大于最大时长时候,这时候帧列表是可以滚动的,所以计算出整个可滚动的长度所代表的的单位时长 s 所以 开始时间 = 滚动长度*s + start*t 结束时间一样的。 同时需要考虑的一点是由于帧列表的item宽度是int类型,而我们计算使用的float所以有些时候当视频时长小于最大时长时,帧列表没有填充满整个高亮部分,会有几个像素的误差,这个时候我们需要改变拖动条的宽度,并在初次计算时将误差减掉。当视频时长大于最大时长时,需要将尾巴露出来一点,我采用的是改变拖动条的margin right,并在帧列表中添加一个透明的item

关键计算代码如下:

/**
     * 当加载完成的时候进行初始化计算
     * 一定要调用这个方法
     *
     * @param func 需要添加一个透明的item
     */
    fun computeWithDataComplete(itemWidth: Int, func: Runnable) {
        mRecyclerView.post {

           var diff = 0f //裁剪区域和list的宽度差
            var offset = 0 //右边露出的宽度
            val cutRange = mCutView.getEnd() - mCutView.getStart() //拖动条的原始区间
            var width = cutRange //拖动条的区间,用来最终计算
            //给剪辑时长赋初始值
            mCutDuration = if (mVideoDuration <= mMaxDuration) {
                mVideoDuration
            } else {
                //如果视频时长大于了最大时长,那么需要将拖动控件往右边移动,让右边露出一些来。
                //将recyclerView的marginRight 设置为0 ,添加一个透明的item,将cutView的右边margin设为item的宽度
                func.run()
                val paramsList = mRecyclerView.layoutParams
                if (paramsList is MarginLayoutParams) {
                    paramsList.rightMargin = 0
                }
                val params = mCutView.layoutParams
                if (params is MarginLayoutParams) {
                    //改变了cutView的margin后,修正了,所以之后的listener中right的坐标不需要减去offset
                    offset = itemWidth - mCutView.getRightWidth()
                    params.rightMargin += offset
                }
                mCutView.layoutParams = params
                //如果要将后面露出来,加一个透明的item
                width -= offset //宽度需要减去露出的部分
                //第二层加一个半透明的view
                val layerView = View(context)
                layerView.setBackgroundColor(Color.parseColor("#80000000"))
                val layerViewParams =
                    LayoutParams(offset + mCutView.getRightWidth(), LayoutParams.MATCH_PARENT)
                val margin = dp2px(context, 3f)
                layerViewParams.topMargin = margin
                layerViewParams.bottomMargin = margin
                layerViewParams.gravity = Gravity.END
                addViewInLayout(layerView, 1, layerViewParams)
                mMaxDuration
            }
            val range = mRecyclerView.computeHorizontalScrollRange()
            //因为帧的item宽度为int,所以会损失一些精度,导致两者有几个像素的误差。这里计算的时候把它减出来.
            if (cutRange > range) {
                val params = mCutView.layoutParams
                if (params is MarginLayoutParams) {
                    //改变了cutView的padding后,修正了,所以之后的listener中right的坐标不需要减去diff
                    diff = cutRange - range
                    params.rightMargin = diff.toInt()
                    width -= diff
                }
                mCutView.layoutParams = params
            }
            //计算出每个像素代表的时长
            mPxDuration = mCutDuration / width
            mCutView.durationPx = mPxDuration
            mFramePxDuration = mVideoDuration / range.toFloat()

            //增加一根竖线,表示播放进度
            val params2 = LayoutParams(mLineWidth, LayoutParams.MATCH_PARENT)
//            params2.leftMargin = (mCutView.getStart() - mLineWidth / 2f).toInt()
            addViewInLayout(mLine, childCount, params2)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                mLine.elevation = 30f
                mLine.outlineProvider = ViewOutlineProvider.BOUNDS
            }
            mLine.translationX = mCutView.getStart() - mLineWidth / 2f

            computeDuration(
                mCutView.getStart(),
                mCutView.getEnd() - diff - offset,
                STATE_IDLE,
                ORIENTATION_LEFT
            )
            isComplete = true
            mCutView.isEnabled = true
            mRecyclerView.isEnabled = true
            requestLayout()
        }
    }
    
    
    
    //计算坐标的偏移量
    //由于CutView返回的是高亮部部分的坐标,但是maxWidth是减去了指针宽度的。
    //所以真正的偏移量应该用 高亮部分的坐标-指针宽度,由于CutView的宽度和父容器宽度一致,所以这个值其实等于
    //CutView的 leftPadding . 但注意,仅仅是值相同,其中计算的思想是不一样的。
    private fun computeDuration(left: Float, right: Float) {
        val startPx = left - mCutView.getLeftWidth()
        val offset = mRecyclerView.computeHorizontalScrollOffset()
        var startMs = (startPx * mPxDuration + offset * mFramePxDuration).toLong() //起始时间
        var endMs = (startMs + (right - left) * mPxDuration).toLong()
        if (endMs > mVideoDuration) {
            startMs -= (endMs - mVideoDuration)
            endMs = mVideoDuration
        }
        startMs = Math.max(0, startMs)
        mCutDuration = endMs - startMs
        mListener?.invoke(startMs, endMs)
    }

更新部分

  • 视频播放增加了进度条

进度条的实现思路:在原有Layout中增加一个View,通过改变translationX来实现移动。

移动的距离 = (播放的时间 - 起始时间) / 单位像素

    /**
     *  更新播放时间,移动小竖线
     *
     *  @param time 当前播放时间
     */
    fun updatePlayTime(time: Long) {
//        if (time < mStartTime)
//            return
        if (time < mStartTime)
            mStartTime = time

        if (!isComplete)
            return
        val duration = max(time - mStartTime, 0)
        val distance = duration / mPxDuration - mLineWidth / 2f
        setLineLeft(min(mCutView.getStart() + distance, mCutView.getEnd() - mLineWidth / 2f))
    }

上面代码中移动距离减去了 mLineWidth / 2f 这个意思是小竖线的宽度的二分之一,这里因为需求是小竖线的一半要覆盖在裁剪边界之上,也就是小竖线用来指示时间的位置是 小竖线的中间。

另外就是处理小竖线的拖动:重写Layout的 onInterceptTouchEvent 以及onTouchEvent,如果按下的坐标位于竖线中则拦截此次事件,自己处理竖线的移动,通过改变translationX

   override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        //如果按下的坐标位于竖线,则拦截此次事件
        if (ev.action == MotionEvent.ACTION_DOWN) {
            return if (isContainsDown(ev.x, ev.y)) {
                true
            } else {
                super.onInterceptTouchEvent(ev)
            }
        }
        return super.onInterceptTouchEvent(ev)
    }


 //拖动竖线
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        var consume = false
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                mLastX = event.x
                consume = isContainsDown(event.x, event.y)
                if (consume) {
                    mDragListener?.onDragDown(getProgressTime())
                }
            }

            MotionEvent.ACTION_MOVE -> {
                val dx = event.x - mLastX
                val newMargin = mLine.translationX + dx
                consume = if (newMargin >= mCutView.getStart() && newMargin <= mCutView.getEnd()) {
                    mLine.translationX = newMargin
                    mLastX = event.x
                    mDragListener?.onDragMove(getProgressTime())
                    true
                } else {
                    false
                }
            }

            MotionEvent.ACTION_UP -> {
                mDragListener?.onDragUp(getProgressTime())
            }

        }

        return consume || super.onTouchEvent(event)
    }

再一个是处理拖动裁剪框,小竖线要跟着移动。思路是一样的。

全部代码放在GitHub