老规矩先上图
撸码
使用:
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