高仿剪映视频多轨剪辑页实现
剪映
是当下比较火的一款手机视频剪辑工具,由抖音官方推出,可用于手机短视频的剪辑制作,拥有强大的多轨编辑能力。其中视频剪辑页用于剪辑的View拥有出色的交互性,很考验Android的基础能力,值得拿出来学习一下。
观察剪映的视频剪辑页面,可见主要有时间轴
、视频轨道
、时间游标
和预览窗口
四部分组成。时间轴
用于展示当前的时间长度和时间刻度,通过缩放手势可以改变最小刻度值,拖动可以对音视频进行seek。视频轨道
用于显示轨道在时间轴上的长度、以及轨道信息,同时视频轨道会显示对应时间的帧图像,而音频轨道则会显示波形图。时间游标
会固定在整个View的中间位置,虽然叫它游标,但实际上并不会移动,只能通过移动时间轴和视频轨道来表示当前的时间位置。预览窗口
用于显示视频帧,通常是SurfaceView或TextureView,比较简单,非本文的重点。
实现
本文并不会完全通过Canvas绘制每一个UI元素,而是尽可能利用Android现有的View进行组合实现,虽然性能较低,但实现起来简单。整个View结构分三层:
-
AlTrackContainer
作为整个View的根,继承自HorizontalScrollView以实现水平滚动,同时负责缩放手势处理以及时间游标
的绘制。 -
AlTrackView
负责组织时间轴
和各个视频轨道
的布局,同时响应缩放手势,实时改变子View的长度。 -
AlTimelineView
作为时间轴
,负责绘制时间刻度,同时响应缩放手势,实时改变时间刻度和长度。 -
AlTrackItemView
单纯继承自TextView,用于显示轨道名称以及音频的波形。
时间轴
AlTimelineView由时间刻度和圆点组成,时间刻度格式为##:##,值得注意的是刻度与圆点之间有一个最小和最大间距,这里把刻度与圆点距离、最小和最大间距分别定义为Space、MinSpace和MaxSpace,Space总是大于MinSpace,小于MaxSpace,其中MaxSpace=MinSpace*4+圆点直径+刻度文字宽度,以便于Space>MaxSpace时,正好能够增加显示一个时间刻度。
部分时间刻度- 根据View的宽度、##:##宽度以及Space与MinSpace、MaxSpace的关系初始化刻度值,并把每个刻度值的String保存到一个数组。
- 当通过缩放手势放大时间轴,刻度间距由小到大变化,直到Space>MaxSpace时,根据View的宽度、刻度宽度以及Space与MinSpace、MaxSpace的关系重新生成新的刻度,并覆盖保存到数组,如果计算得当的话,新的刻度Space总是大于MinSpace,小于MaxSpace。
- 同理,当通过缩放手势放大时间轴,直到Space<MinSpace时,重新计算刻度数组。不同于上面的放大逻辑,这里直接把刻度数量除以2,然后根据新的刻度数量重新计算间距,这样就能实现刻度间距由大到小的效果。
此时我们只需要在onDraw
中根据Space把刻度数组里的文字、以及刻度之间的小圆点绘制出来即可。核心代码如下:
//放大的情况下保持最小刻度不变
private fun keepZoomLevel(visibleWidth: Int): Int {
if (abs(mLastVisibleWidth - visibleWidth) < 5) {
return textVec.size
}
mLastVisibleWidth = visibleWidth
val tmp = (visibleWidth - textSize.x * textVec.size) / (textVec.size - 1).toFloat()
if (tmp < textSize.x + cursorRect.width() * 2 && tmp > cursorRect.width()) {
spaceSize = tmp
return textVec.size
}
return Int.MIN_VALUE
}
private fun measureText(): Int {
if (durationInUS <= 0) {
textVec.clear()
return 0
}
//textSize.x为##:##的宽度,加textSize.x是为了保证##:##的宽度中间为该刻度值。
val visibleWidth = measuredWidth + textSize.x - paddingLeft - paddingRight
var count = (visibleWidth / (textSize.x + cursorRect.width())).toInt()
if (textVec.size == count) {
return count
}
if (textVec.isNotEmpty()) {
if (Int.MIN_VALUE != keepZoomLevel(visibleWidth)) {
return textVec.size
}
count = if (count < textVec.size) {
textVec.size / 2
} else {
textVec.size * 2
}
}
textVec.clear()
if (count > 1) {
spaceSize = (visibleWidth - textSize.x * count) / (count - 1).toFloat()
for (i in 0 until count) {
textVec.add(fmt.format(Date(i * durationInUS / (count - 1) / 1000)))
}
} else {
spaceSize = (visibleWidth - textSize.x).toFloat()
textVec.add(fmt.format(Date(0)))
}
return count
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
val count = measureText()
for (i in 0 until count) {
val text = textVec[i]
val x = paddingLeft - textSize.x / 2f + ((textSize.x + spaceSize) * i).toFloat()
canvas?.drawText(text, x, (measuredHeight + textSize.y) / 2f, paint)
if (i < count - 1) {
canvas?.drawCircle(
x + textSize.x + spaceSize / 2f,
measuredHeight / 2f,
cursorSize / 2f, paint
)
}
}
}
视频轨道
AlTrackItemView
由AlTrackView
进行布局,AlTrackView同时页负责时间轴的摆放,功能比较简单。只需要保证AlTimelineView和AlTrackItemView的垂直线性布局即可,同时需要保证AlTrackItemView在时间轴下的占比,并且在缩放的同时成比例改变AlTrackItemView和AlTrackView的宽度。
首先AlTrackView
需要有一个缩放接口,该接口输入一个缩放比例,比例改变的同时在onMeasure方法内部根据缩放系数改变自身宽度。
fun setScale(scale: AlRational) {
this.scale.num = scale.num
this.scale.den = scale.den
requestLayout()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
measureChildren(widthMeasureSpec, heightMeasureSpec)
if (originWidth <= 0) {
originWidth = width
}
setMeasuredDimension(
originWidth * scale.num / scale.den + paddingLeft + paddingRight,
height
)
}
而AlTimelineView则需要在AlTrackView初始化时进行添加。这里给AlTimelineView添加了一个上下的padding,让刻度与View的边缘保持一定间距。
constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int)
: super(context, attrs, defStyleAttr) {
onResolveAttribute(context, attrs, defStyleAttr, 0)
onInitialize(context)
}
private fun onInitialize(context: Context) {
clipToPadding = false
mTimeView = AlTimelineView(context)
mTimeView.setPadding(
0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt(),
0, applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f).toInt()
)
addView(mTimeView, makeLayoutParams())
}
同时AlTrackView需要有一个addTrack接口,支持外部添加不同的轨道。该接口会通过传入的轨道信息,生成对应的AlTrackItemView(TextView),同时把生成的View和轨道信息保存到不同的Map中,方便进行布局。updateAudioTrack用于根据音频轨道的文件路径生成音频波形的Bitmap,然后作为View的背景,音频波形图可以通过FFmpeg
命令生成。
fun addTrack(track: AlMediaTrack) {
if (tMap.containsKey(track.id)) {
return
}
tMap[track.id] = track
vMap[track.id] = TextView(context)
vMap[track.id]?.textSize = 14f
vMap[track.id]?.setTextColor(Color.WHITE)
vMap[track.id]?.text = when (track.type) {
AlMediaType.TYPE_VIDEO -> "Track ${track.id}"
AlMediaType.TYPE_AUDIO -> "Track ${track.id}"
else -> "Unknown Track"
}
vMap[track.id]?.setBackgroundColor(
when (track.type) {
AlMediaType.TYPE_VIDEO -> mVideoColor
AlMediaType.TYPE_AUDIO -> mAudioColor
else -> Color.RED
}
)
val padding = applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f).toInt()
vMap[track.id]?.setPadding(padding, padding, padding, padding)
addView(vMap[track.id], makeLayoutParams())
requestLayout()
//显示音频轨道波形图
updateAudioTrack(track)
}
最后通过在onLayout方法中对AlTimelineView和AlTrackItemView进行布局,这里会根据轨道的时长占总时长的比例来设置AlTrackItemView自身的宽度。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var height = 0
var w = measuredWidth
var h = mTimeView.measuredHeight
mTimeView.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h)
mTimeView.layout(l, height, l + w, height + h)
height += h
vMap.forEach {
val track = tMap[it.key]
val view = it.value
w = measuredWidth - paddingLeft - paddingRight
h = view.measuredHeight
var offset = 0
if (null != track && mTimeView.getDuration() > 0 && track.duration > 0) {
offset = (track.seqIn * w / mTimeView.getDuration()).toInt()
w = (track.duration * w / mTimeView.getDuration()).toInt()
}
view.layout(paddingLeft + l + offset, height, paddingLeft + l + w + offset, height + h)
view.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), h)
height += h
}
}
AlTrackContainer
AlTrackContainer
作为AlTrackView
的直接父级,承载着横向滚动的功能,我们可以继承HorizontalScrollView
实现。同时实现了缩放手势的监听,通过缩放手势计算缩放系数,层层传递到AlTrackView
和AlTimelineView
进行缩放响应。缩放手势的监听很简单,只需要使用Android提供的ScaleGestureDetector即可。
private val mScaleDetector = ScaleGestureDetector(context, mScaleListener)
private val mScaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
private var previousScaleFactor = 1f
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
previousScaleFactor = 1f
return super.onScaleBegin(detector)
}
override fun onScaleEnd(detector: ScaleGestureDetector?) {
previousScaleFactor = 1f
super.onScaleEnd(detector)
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
val anchor = PointF(
detector.focusX * 2 / measuredWidth.toFloat() - 1f,
-(detector.focusY * 2 / measuredHeight.toFloat() - 1f)
)
scale = scale * detector.scaleFactor / previousScaleFactor
previousScaleFactor = detector.scaleFactor
//限制最大最小缩放系数
if (scale < 0.5f) {
scale = 0.5f
}
if (scale > 3) {
scale = 3f
}
//把缩放系数传给AlTrackView
getChildView().setScale(AlRational((scale * 10000).toInt(), 10000))
return super.onScale(detector)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
mScaleDetector.onTouchEvent(event)
return super.onTouchEvent(event)
}
同时AlTrackContainer
还需要绘制中心的游标,用来标示当前的时间点,这里游标使用一个圆角矩形来表示。由于游标需要显示在所有元素的上方,如果在onDraw中绘制会被其它元素遮挡,所以需要在dispatchDraw
中绘制。至此,高仿剪映多轨编辑View实现完成。
override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas)
canvas?.drawRoundRect(
scrollX + (measuredWidth - cursorSize) / 2,
0f,
scrollX + (measuredWidth + cursorSize) / 2,
measuredHeight.toFloat(),
cursorSize / 2f,
cursorSize / 2f,
paint
)
}
实际效果对比
高仿效果 剪映放大效果总结
以上只是对剪映主要逻辑的实现,实际还缺失很多比较细微的功能,比如显示视频截图、删除移动轨道等,并且实际效果与剪映还有一些差异。希望通过本文能给读者学习Android自定义View带来一些帮助。最后附上源码:
AlTrackContainer
AlTrackView
AlTimelineView
Special
如果只是实现一个UI的交互功能,有点太缺乏挑战了。实际上本文不仅实现了用于编辑的交互UI,而且还实现了音视频多轨预览剪辑的逻辑。
- 支持同时添加多个音视频轨道进行播放预览!
- 支持剪映没有的多视频轨道图层移动和缩放,可以任意摆放各个视频轨道的位置!
- 支持常规的音视频Seek、暂停与播放等。
以上源码都开源在hwvc项目,感兴趣的读者可以自取。
欢迎关注微信公众,第一时间获取一手多媒体技术资讯