引言
视频截取在社交类 APP 中十分常见。有了上传视频的功能,就不可避免地需要提供截取和编辑的选项。如果我们过度依赖第三方库,项目的代码可能会变得异常臃肿,因为这些库往往包含许多我们用不到的功能,而且它们的 UI 样式和功能通常比较固定,不支持定制。因此,有条件的话,尽可能自行实现这些功能。
原本我打算直接介绍视频截取的实现方式,但发现相关的 UI 设计也非常有趣。如果不把 UI 和视频截取功能结合起来,即使掌握了截取技术,也可能难以打造出一个好用的视频编辑工具。因此,在本篇博客中,我们先来介绍视频截取中最常见的 UI 样式和小组件。
组件结构
我们创建一个继承自UIView的SVVideoEditBar类,整个编辑的操作小组件可以分为播放控制和截取控制两部分:
播放控制
第一部分是播放和暂停按钮,控制截取后视频的播放和暂停功能,这里比较简单只需要一个按钮就可以实现。
/// 播放按钮
private var playButton = UIButton()
/// 添加播放按钮
private func addPlayerButton() {
playButton.setImage(UIImage(named: "icon_play_light_fill_24"), for: .normal)
playButton.setImage(UIImage(named: "icon_pause_light_fill_24"), for: .selected)
self.addSubview(playButton)
playButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16.0)
make.centerY.equalToSuperview()
make.width.height.equalTo(24.0)
}
}
截取控制
第二部分为截取控制部分可以再详细划分为展示部分和操作部分。
展示
对于展示部分我们采用UICollectionView来显示视频获取到的缩略图。
/// 列表
private var collectionView:UICollectionView!
/// 添加列表
private func addCollectionView() {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 0.0
layout.scrollDirection = .horizontal
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.contentInset = UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 10.0)
collectionView.backgroundColor = UIColor.clear
collectionView.showsHorizontalScrollIndicator = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
self.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.leading.equalTo(lineView.snp.trailing).offset(20.0)
make.trailing.equalToSuperview().offset(-20.0)
make.top.equalToSuperview().offset(8.0)
make.bottom.equalToSuperview().offset(-8.0)
}
collectionView.backgroundColor = .red
}
关于列表中图片的尺寸会根据视频的尺寸来确定,所以我们将大小在代理方法中进行设置
extension SVVideoEditBar: UICollectionViewDelegate,UICollectionViewDataSource,UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 10
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: 40.0, height: collectionView.bounds.height)
}
}
滑动遮罩
遮罩也分为两部分,一部分为黄色的边框,边框内的内容表示的是视频截取后保留的部分,而黄色边框以外的黑色半透明的遮罩则表示视频截取后舍弃的部分。
黄色的部分由我们自定义的视图SVEditorSliderView来实现。
/// 可拖拽滑动视图
private var sliderView = SVEditorSliderView()
/// 添加滑动视图
private func addSliderView() {
self.addSubview(sliderView)
sliderView.layer.cornerRadius = 8.0
sliderView.backgroundColor = UIColor.yellow
updateLeftRightOffset()
// 右侧拖拽回调
sliderView.rightDragBlock = { [weak self] x in
...
}
// 左侧拖拽回调
sliderView.leftDragBlock = { [weak self] x in
....
}
}
/// 更新左右侧偏移
private func updateLeftRightOffset() {
sliderView.snp.remakeConstraints { make in
make.leading.equalTo(lineView.snp.trailing).offset(leftOffset)
make.top.bottom.equalToSuperview()
make.trailing.equalToSuperview().offset(rightOffset)
}
}
两侧的黑色半透明遮罩直接通过UIView设置背景颜色的方式来实现
/// 左侧蒙层
private var leftMaskView = UIView()
/// 右侧蒙层
private var rightMaskView = UIView()
/// 添加左侧蒙层
private func addLeftMaskView() {
leftMaskView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
self.addSubview(leftMaskView)
leftMaskView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalTo(self.lineView.snp.trailing)
make.trailing.equalTo(sliderView.snp.leading)
}
}
/// 添加右侧蒙层
private func addRightMaskView() {
rightMaskView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
self.addSubview(rightMaskView)
rightMaskView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalTo(sliderView.snp.trailing)
make.trailing.equalToSuperview()
}
}
进度条
另外还有一个在播放时会跟随播放进度而移动的进度条,直接使用UIView加阴影的方式来实现。
class SVProgressLineView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .white
self.layer.cornerRadius = 1.0
// 阴影
self.layer.shadowColor = UIColor.black.cgColor
self.layer.shadowOffset = CGSize(width: -1, height: 0)
self.layer.shadowOpacity = 0.5
self.layer.shadowRadius = 2.0
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 进度线
private var progressLineView = SVProgressLineView()
/// 添加进度线
private func addProgressLineView() {
self.addSubview(progressLineView)
progressLineView.snp.makeConstraints { make in
make.leading.equalTo(sliderView).offset(20.0 + 10.0)
make.centerY.equalToSuperview()
make.width.equalTo(2.0)
make.height.equalTo(self.snp.height).inset(8.0)
}
}
实现操作视图 - SVEditorSliderView
接下来我们把重点集中到SVEditorSliderView上面,首先它需要一个镂空效果,来显示底部的缩图列表,另外它的两侧还需要可拖拽来修改视频的截取区域。
镂空效果
那我们先来实现它的镂空效果,采用图层的mask属性和贝塞尔曲线结合来实现镂空。
/// maskLayer
private let maskLayer = CAShapeLayer()
/// path
private var path = UIBezierPath()
/// fullPath
private var fullPath = UIBezierPath()
override init(frame: CGRect) {
super.init(frame: frame)
maskLayer.backgroundColor = UIColor.black.cgColor
self.layer.mask = maskLayer
maskLayer.fillRule = .evenOdd
...
}
override func layoutSubviews() {
let width = self.bounds.width
let height = self.bounds.height
fullPath = UIBezierPath(rect: self.bounds)
path = UIBezierPath(rect: CGRect(x: 20.0, y: 8.0, width: width - 40.0, height: height - 16.0))
fullPath.append(path)
fullPath.usesEvenOddFillRule = true
maskLayer.path = fullPath.cgPath
....
}
拖拽事件
在视图的最左侧和最右侧添加可拖拽的UIView视图,并处理拖拽事件,由于该视图的布局在父视图上,所以我们选择将退拽事件回调出去来处理。
/// 右侧可拖拽视图
private let rightDragView = UIView()
/// 左侧可拖拽视图
private let leftDragView = UIView()
/// 右侧退拽回调
var rightDragBlock:((CGFloat)->Void)?
/// 左侧拖拽回调
var leftDragBlock:((CGFloat)->Void)?
/// 添加右侧可拖拽视图
private func addRightDragView() {
// rightDragView.backgroundColor = .white
self.addSubview(rightDragView)
let pan = UIPanGestureRecognizer(target: self, action: #selector(panAction(_:)))
rightDragView.addGestureRecognizer(pan)
rightDragView.isUserInteractionEnabled = true
}
/// 添加左侧可拖拽视图
private func addLeftDragView() {
// leftDragView.backgroundColor = .white
self.addSubview(leftDragView)
let pan = UIPanGestureRecognizer(target: self, action: #selector(panAction(_:)))
leftDragView.addGestureRecognizer(pan)
leftDragView.isUserInteractionEnabled = true
}
@objc private func panAction(_ pan:UIPanGestureRecognizer) {
// 获取视图
let view = pan.view
if view == rightDragView {
let translation = pan.translation(in: self)
let x = translation.x
rightDragBlock?(x)
} else if view == leftDragView {
let translation = pan.translation(in: self)
let x = translation.x
leftDragBlock?(x)
}
pan.setTranslation(.zero, in: self)
}
override func layoutSubviews() {
let width = self.bounds.width
let height = self.bounds.height
...
rightDragView.frame = CGRect(x: width - 20.0, y: 0.0, width: 20.0, height: height)
leftDragView.frame = CGRect(x: 0.0, y: 0.0, width: 20.0, height: height)
}
拖拽处理
处理拖拽事件是个细活,在拖拽过程中我们需要更新sliderView的布局约束,我们把它分成两个部分来讨论。
右侧拖拽事件
在SVVideoEditBar类中我们还定义另外两个CGFloat类型属性
/// 左侧偏移
private var leftOffset:CGFloat = 0.0
/// 右侧偏移
private var rightOffset:CGFloat = 0.0
分别表示sliderView的左侧约束的偏移量和右侧约束的偏移量,默认都为0.0。
在进行右侧退拽时,我们首先需要注意的是,往右拖拽时不能超过SVVideoEditBar的最右端,而往左退拽时不能超过sliderView自己的最左端的拖拽视图。
// 右侧拖拽回调
sliderView.rightDragBlock = { [weak self] x in
guard let self = self else { return }
// 限制右侧往右的拖拽范围
if self.sliderView.frame.maxX >= self.bounds.width && x > 0 {
print("右侧往右拖拽到最大范围")
self.rightOffset = 0.0
self.updateLeftRightOffset()
return
}
// 限制右侧往左的拖拽范围
if self.sliderView.frame.maxX <= (self.sliderView.frame.minX+40.0) && x < 0 {
print("右侧往左拖拽到最小范围")
self.rightOffset = -(self.bounds.width - self.sliderView.frame.minX - 40.0)
self.updateLeftRightOffset()
return
}
self.rightOffset = self.rightOffset + x
self.updateLeftRightOffset()
}
左侧拖拽事件
当我们拖拽左侧是视图时,需要注意当往左侧拖拽时不能超过左侧的起始位置,也就是竖线的最最右侧。而往右拖拽时同样也不能超过右侧的拖拽视图。
// 左侧拖拽回调
sliderView.leftDragBlock = { [weak self] x in
guard let self = self else { return }
// 限制左侧往左的拖拽范围
if self.sliderView.frame.minX <= self.lineView.frame.maxX && x < 0 {
print("左侧往左拖拽到最小范围")
self.leftOffset = 0.0
self.updateLeftRightOffset()
return
}
// 限制左侧往右的拖拽范围
if self.sliderView.frame.minX >= (self.sliderView.frame.maxX-40.0) && x > 0 {
print("左侧往右拖拽到最大范围")
self.leftOffset = self.sliderView.frame.maxX - 40.0 - self.lineView.frame.maxX
self.updateLeftRightOffset()
return
}
self.leftOffset = self.leftOffset + x
self.updateLeftRightOffset()
}
这样像GIF图片一样的视频剪裁小组件的UI部分就实现了。
结语
本篇博客主要介绍了视频截取中的UI小组件,介绍了如何实现镂空效果,以及拖拽事件,尤其是拖拽时临界值的处理。
获取到了裁剪区域之后,我们就可以根据视频的长度来进行视频截取了,那么下一篇博客我们将开始进入视频截取的数据处理部分。