Android 仿抖音视频裁剪范围选择控件,支持本地视频和网络视
2020-11-03 本文已影响0人
lucasDev
实现后效果:由于是在模拟器上跑的背面的封面列表加载不出来,实际效果请真机运行
image.png
具体代码如下:
绘制上层滑动控件部分
package com.cj.customwidget.widget
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.cj.customwidget.R
import com.cj.customwidget.p
/**
* @package com.cj.customwidget.widget
* @author luan
* @date 2020/10/16
* @des 视频裁剪区域选择
*/
class CropSeekBar : View {
constructor(context: Context) : super(context) {
initView(context, null)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
initView(context, attrs)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initView(context, attrs)
}
private val color = Color.WHITE//边框颜色
var slideW = 20f//两侧滑块宽度
var strokeW = 4f//上下边框宽度
var slideOutH = 10f//进度滑块越界高度
var midSlideW = 8f//中间滑块宽度
private var radio = 16f//圆角角度
val slidePadding = 100//两侧滑块外边距
var midProgress = 0f//中间滑块的x坐标
var seekLeft = 0f//左测滑块的x坐标
var seekRight = 0f//右测滑块的x坐标
var maxInterval = 60L * 1000//最大区间-时长ms
var minInterval = 10L * 1000//最小区间-时长ms
private val strokeLinePaint = Paint()
private val slidePaint = Paint()
private val path = Path()
private val progressRectF = RectF()//中间滑块有效触摸范围
private val leftSlideTouchRectF = RectF()//左滑块有效触摸范围
private val rightSlideTouchRectF = RectF()//右滑块有效触摸范围
private var isMoveSlide: Boolean = false
var onChangeProgress: (progress: Float) -> Unit = { progress -> }
var onSectionChange: (left: Float, right: Float) -> Unit = { left, right -> }
// var onTouchChange: (isTouch: Boolean) -> Unit = {}
private fun initView(context: Context, attrs: AttributeSet?) {
setWillNotDraw(false)
attrs?.apply {
val obtain = context.obtainStyledAttributes(attrs, R.styleable.CropSeekBar)
slideOutH = obtain.getDimension(R.styleable.CropSeekBar_vc_slide_out_h, slideOutH)
radio = obtain.getDimension(R.styleable.CropSeekBar_vc_radio, radio)
obtain.recycle()
}
strokeLinePaint.isAntiAlias = true
strokeLinePaint.strokeWidth = strokeW
strokeLinePaint.color = color
strokeLinePaint.strokeWidth = strokeW
slidePaint.isAntiAlias = true
slidePaint.color = color
slidePaint.style = Paint.Style.FILL
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
seekLeft = slidePadding.toFloat() + slideW / 2
seekRight = width - slidePadding.toFloat() - slideW / 2
midProgress = slidePadding.toFloat() + slideW + midSlideW / 2
super.onLayout(changed, left, top, right, bottom)
}
override fun onDraw(canvas: Canvas) {
leftSlideTouchRectF.left = seekLeft - slideW / 2
leftSlideTouchRectF.top = slideOutH
leftSlideTouchRectF.right = seekLeft + slideW / 2
leftSlideTouchRectF.bottom = height.toFloat() - slideOutH
rightSlideTouchRectF.left = seekRight - slideW / 2
rightSlideTouchRectF.top = slideOutH
rightSlideTouchRectF.right = seekRight + slideW / 2
rightSlideTouchRectF.bottom = height.toFloat() - slideOutH
//绘制播放进度滑块
progressRectF.left = midProgress - midSlideW / 2
progressRectF.top = 0f
progressRectF.right = midProgress + midSlideW / 2
progressRectF.bottom = height.toFloat()
//绘制上下边框
canvas.drawLine(
leftSlideTouchRectF.right,
slideOutH + strokeW / 2,
rightSlideTouchRectF.left,
slideOutH + strokeW / 2,
strokeLinePaint
)
canvas.drawLine(
leftSlideTouchRectF.right,
height.toFloat() - slideOutH - strokeW / 2,
rightSlideTouchRectF.left,
height.toFloat() - slideOutH - strokeW / 2,
strokeLinePaint
)
//绘制两边滑块
path.reset()
path.moveTo(leftSlideTouchRectF.left, radio + leftSlideTouchRectF.top)
path.quadTo(
leftSlideTouchRectF.left,
leftSlideTouchRectF.top,
radio + leftSlideTouchRectF.left,
leftSlideTouchRectF.top
)
path.lineTo(leftSlideTouchRectF.right, leftSlideTouchRectF.top)
path.lineTo(leftSlideTouchRectF.right, leftSlideTouchRectF.bottom)
path.lineTo(radio + leftSlideTouchRectF.left, leftSlideTouchRectF.bottom)
path.quadTo(
leftSlideTouchRectF.left,
leftSlideTouchRectF.bottom,
leftSlideTouchRectF.left,
leftSlideTouchRectF.bottom - radio
)
path.lineTo(leftSlideTouchRectF.left, radio + leftSlideTouchRectF.top)
canvas.drawPath(path, slidePaint)
path.reset()
path.moveTo(rightSlideTouchRectF.left, rightSlideTouchRectF.top)
path.lineTo(rightSlideTouchRectF.right - radio, rightSlideTouchRectF.top)
path.quadTo(
rightSlideTouchRectF.right,
rightSlideTouchRectF.top,
rightSlideTouchRectF.right,
radio + rightSlideTouchRectF.top
)
path.lineTo(rightSlideTouchRectF.right, rightSlideTouchRectF.bottom - radio)
path.quadTo(
rightSlideTouchRectF.right,
rightSlideTouchRectF.bottom,
rightSlideTouchRectF.right - radio,
rightSlideTouchRectF.bottom
)
path.lineTo(rightSlideTouchRectF.left, rightSlideTouchRectF.bottom)
path.lineTo(rightSlideTouchRectF.left, rightSlideTouchRectF.top)
canvas.drawPath(path, slidePaint)
canvas.drawRoundRect(progressRectF, midSlideW, midSlideW, slidePaint)
super.onDraw(canvas)
}
private val SCROLL_MODE_NONE = 0
private val SCROLL_MODE_LEFT = 1//左滑块
private val SCROLL_MODE_RIGHT = 2//右滑块
private val SCROLL_MODE_PROGRESS = 3//播放进度滑块
private var scrollMode = SCROLL_MODE_NONE
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (leftSlideTouchRectF.contains(event.x, event.y)) {
//移动左滑块
scrollMode = SCROLL_MODE_LEFT
return true
} else if (rightSlideTouchRectF.contains(event.x, event.y)) {
//移动右滑块
scrollMode = SCROLL_MODE_RIGHT
return true
} else if (event.x in progressRectF.left - 10..progressRectF.right + 10) {
//移动中间滑块
scrollMode = SCROLL_MODE_PROGRESS
return true
}
}
MotionEvent.ACTION_MOVE -> {
val minW = (width - slidePadding * 2 - slideW * 2) * (minInterval.toFloat() / maxInterval)
if (scrollMode == SCROLL_MODE_LEFT) {
if (event.x > slidePadding) {
if (seekRight - event.x - slideW > minW) { //判断最小区间
seekLeft = event.x
} else {
seekLeft = seekRight - minW - slideW
}
} else {//回到默认位置
seekLeft = slidePadding.toFloat() + slideW / 2
}
midProgress = seekLeft + slideW / 2 + midSlideW / 2
isMoveSlide = true
onSectionChange(seekLeft, seekRight)
onChangeProgress(midProgress)
invalidate()
return true
} else if (scrollMode == SCROLL_MODE_RIGHT) {
if (event.x < width - slidePadding) {
if (event.x - seekLeft - slideW > minW) { //判断最小区间
seekRight = event.x
} else {
seekRight = seekLeft + minW + slideW
}
} else {
seekRight = width - slidePadding.toFloat() - slideW / 2
}
midProgress = seekRight - slideW / 2 - midSlideW / 2
isMoveSlide = true
onSectionChange(seekLeft, seekRight)
onChangeProgress(midProgress)
invalidate()
return true
} else if (scrollMode == SCROLL_MODE_PROGRESS) {
if (event.x in seekLeft + slideW / 2..seekRight - slideW / 2) {//只允许在区间内滑动
midProgress = event.x
}
isMoveSlide = false
onChangeProgress(midProgress)
invalidate()
return true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isMoveSlide = false
if (scrollMode == SCROLL_MODE_RIGHT || scrollMode == SCROLL_MODE_LEFT) {
onChangeProgress(midProgress)
}
scrollMode = SCROLL_MODE_NONE
}
}
return super.onTouchEvent(event)
}
}
绘制下层封面列表,以及与上层空间联动
package com.cj.customwidget.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.RectF
import android.media.MediaMetadataRetriever
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import com.cj.customwidget.p
import java.lang.Exception
import kotlin.concurrent.thread
/**
* @package com.cj.customwidget.widget
* @author luan
* @date 2020/10/28
* @des
*/
class VideoCropSeekBar : FrameLayout {
constructor(context: Context) : super(context) {
initView(context, null)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
initView(context, attrs)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initView(context, attrs)
}
lateinit var coverView: LinearLayout
lateinit var seekBar: CropSeekBar
private var coverRectF = RectF()
private var picW = 80f//每张封面宽度--这并不是最终值,会根据控件长度调整
var videoDuration = 0L//视频时长
var onSeekChange: (progress: Long) -> Unit = { }//当进度发生变化
var onSectionChange: (left: Float, right: Float) -> Unit = { left, right -> }
var onTouchChange: (isTouch: Boolean) -> Unit = {}
private fun initView(context: Context, attrs: AttributeSet?) {
coverView = LinearLayout(context)
addView(coverView)
seekBar = CropSeekBar(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
addView(seekBar)
seekBar.onChangeProgress = {
seekChange()
}
seekBar.onSectionChange = { left, right ->
seekChange()
onSectionChange(left, right)
}
}
//获取左侧滑块时间轴
fun getLeftSlideSecond(): Long {
return (((seekBar.seekLeft - coverRectF.left + seekBar.slideW / 2) / coverView.width) * videoDuration).toLong()
}
//获取右侧滑块时间轴
fun getRightSlideSecond(): Long {
return (((seekBar.seekRight - coverRectF.left - seekBar.slideW / 2) / coverView.width) * videoDuration).toLong()
}
//设置视频资源
fun setVideoUri(videoPath: String) {
getVideoInfo(videoPath) { retriever ->
//计算封面列表矩形大小和位置
var coverW: Float
if (videoDuration < seekBar.maxInterval) {//如果视频长度小于最大区间,则封面列表宽度等于最大区间,这个时候时间轴会被拉伸
coverW = seekBar.seekRight - seekBar.seekLeft - seekBar.slideW
seekBar.maxInterval = videoDuration
} else {
coverW =
(videoDuration.toFloat() / seekBar.maxInterval) * (width - seekBar.slidePadding * 2 - seekBar.slideW * 2)
}
val coverMargin = seekBar.slidePadding + seekBar.slideW
coverRectF.set(
coverMargin,
seekBar.slideOutH + seekBar.strokeW,
coverW + coverMargin,
height - seekBar.slideOutH - seekBar.strokeW
)
invalidate()
onSectionChange(seekBar.seekLeft, seekBar.seekRight)
thread {
try {
//计算需要获取多少张封面
val picNum = (coverW / picW).toInt()
//根据图片数量再次计算封面宽度,使图片可以填满整个列表
picW = coverW / picNum
//获取第一帧
var firstFrame = retriever.getFrameAtTime(1, MediaMetadataRetriever.OPTION_PREVIOUS_SYNC)
addCover(firstFrame)
//计算获取每张封面的时间间隔
val videoDuration1 = videoDuration
val diffTime = videoDuration1 / picNum
var index = 1
while (index * diffTime < videoDuration) {
firstFrame =
retriever.getFrameAtTime(
index++ * diffTime * 1000,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
)
addCover(firstFrame)
}
retriever.release()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun addCover(firstFrame: Bitmap?) {
post {
try {
coverView.addView(ImageView(context).apply {
layoutParams = LinearLayout.LayoutParams(picW.toInt(), LinearLayout.LayoutParams.MATCH_PARENT)
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(firstFrame)
})
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun getVideoInfo(videoPath: String, block: (MediaMetadataRetriever) -> Unit) {
thread {
//解析视频参数
val retriever = MediaMetadataRetriever()
if (videoPath.startsWith("http:") || videoPath.startsWith("https:"))
retriever.setDataSource(videoPath, HashMap<String, String>())//网络视频
else
retriever.setDataSource(videoPath)
//获取视频长度
videoDuration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION).toLong()
"videoDuration:$videoDuration".p()
seekBar.invalidate()
post { block.invoke(retriever) }
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
seekBar.layout(0, 0, width, height)
layoutCover()
}
private var lastX = 0f
private var lastY = 0f
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
onTouchChange(true)
return true
}
MotionEvent.ACTION_MOVE -> {
//限制边界
val diffX = event.x - lastX
if (coverRectF.left + diffX > seekBar.slidePadding + seekBar.slideW) {
coverRectF.left = seekBar.slidePadding + seekBar.slideW
coverRectF.right = coverView.width + coverRectF.left
} else if (coverRectF.right + diffX <= seekBar.width - seekBar.slidePadding - seekBar.slideW) {
coverRectF.right = seekBar.width - seekBar.slidePadding - seekBar.slideW
coverRectF.left = coverRectF.right - coverView.width
} else {
coverRectF.left += diffX
coverRectF.right += diffX
}
layoutCover()
seekChange()
lastX = event.x
lastY = event.y
return true
}
MotionEvent.ACTION_UP -> {
onTouchChange(false)
}
}
return super.onTouchEvent(event)
}
private fun seekChange() {
val progress = (seekBar.midProgress - coverRectF.left) / coverView.width//进度百分比
onSeekChange((progress * videoDuration).toLong())
}
private fun layoutCover() {
coverView.layout(
coverRectF.left.toInt(),
coverRectF.top.toInt(),
coverRectF.right.toInt(),
coverRectF.bottom.toInt()
)
}
}
项目地址:https://github.com/LucasDevelop/CustomView. 视频裁剪Seek Bar界面