使用Swift构建一个视频时间轴控件
关键词
控件 属性 VideoLine 扩展 逻辑 cgImage 访问 设计 自定义 交互
本文所有示例代码或Demo可以在此获取:https://github.com/WillieWangWei/VideoLine.git
如果本文对你有所帮助,请给个Star👍
概述
界面控件是所iOS程序重要的组成部分,用户可以通过它们与应用程序进行交互。苹果提供了一套强大的控件组来满足日常的开发需求,我们可以使用这些控件来搭建大部分的用户界面。
但是当我们需要实现一些特别的场景时,这些控件就无法满足需求。此时我们可以基于系统控件来编写自定义控件,比如以下场景中底部的选择器:
视频时间轴选择
本文从实际开发的角度出发,讲解一个控件从无到有的过程,是一篇综合性比较强的教程,主要涉及以下技术点:
- UIKit
- AVFoundation
- Photos
- SnapKit
- Access Control
- extension
目录:
- 分析需求
- 拆分控件
- 搭建界面
- 填充数据
- 添加交互
- 设计API
- 代码优化
分析需求
这是一个常见的场景——当用户选择了一个本地视频后,在此界面预览视频并对其长度进行裁剪,最终得到符合业务要求的短视频。
暂时忽略上部视频的预览区域,我们需要实底部的“缩略图进度条”。观察后我们发现这个控件有以下几个特点:
- 对视频片段进行采样,生成缩略图排列,且可以左右滑动。
- 中间有一个选择区域,可以通过滑动左右两边的滑块来确定选中区域的大小。
- 左右滑块滑动时会出现一个边框,表示滑动的边界。
- 选择区域以外的内容有黑色半透明蒙版。
- 选择区域中有一条指示线指示当前播放进度。
- 有文字说明当前选择片段的开始时间、总共时长以及结束时间。
- 与上方播放器实时联动。
初步的分析让我们对需要实现的内容有了大致的了解,但通常会忽略很多细节,这会在实际编码中体现出来。
拆分控件
现在需要初步确定各个位置用什么系统控件来实现。这里考虑的越周全,实际编码时绕的弯路就越少,我们结合截图来分析:
拆分控件
1、2、3用来显示当前选择区域的状态,不接收点击事件,所以直接使用UILabel
。
7区域支持左右滑动,首先考虑UIScrollView
。其承载了多个尺寸相同的缩略图且横向滑动,那么使用拥有重用机制的UICollectionView
最合适。
6看起来是一个白色的方框,左右两边均可拖动,系统并未提供类似的控件,所以要对其再次进行拆分。
由于左右边框(滑块)都可以单独拖动,所以判断使用两个单独的UIView
,并各自绑定不同的拖拽手势。为了方便的使用自定义图片,确定滑块使用UIImageView
。上下的边框也分解为两个单独的UIView
,添加约束使其前后与左右边框相接即可。如图:
5又是一个边框,但是它的大小的固定的,用来表示6的可选范围,所以可以直接使用UIView
,设置其layer
的相关属性即可得到所需样式。
4、8是选择区域之外的黑色蒙版,它的边界随着相邻滑块的位置而变化。可以直接使用UIView
,并添加约束使其与相邻滑块相接。
整个控件在z方向(也就是遮盖关系)的层级为6 > 5 > 4 = 8 > 7 = 1 = 2 = 3。
搭建界面
新建一个Swift
文件,创建一个类VideoLine
,继承自UIView
。
class VideoLine: UIView {
}
给这个类添加拆分后必要的子控件。
class VideoLine: UIView {
/// 左滑块
var leftSlider: UIImageView!
/// 右滑块
var rightSlider: UIImageView!
/// 开始时间label
var startTimeLabel: UILabel!
/// 结束时间label
var endTimeLabel: UILabel!
/// 总计时间label
var durationTimeLabel: UILabel!
/// 下方呈现所有缩略图并可以滚动的view
var collectionView: UICollectionView!
/// 拖动滑块时出现的边界
var limitBoard: UIView!
/// 播放进度指示器
var indicator: UIView!
}
- 这里没有将4、8黑色蒙版声明为全局变量,因为它们一旦被创建和添加约束后,后续不会再进行修改。更多关于Swift中的变量,请看这里。
- 属性全部使用自动解包的可选类型,表示我们将在后续对所有对象进行初始化,并可以直接对其解包使用。更多关于可选类型,请看这里。
声明一个方法,对所有属性进行初始化。
// 初始化所有视图
func setupUtil() {
startTimeLabel = UILabel()
startTimeLabel.text = "开始时间"
self.addSubview(startTimeLabel)
startTimeLabel.snp.makeConstraints { (make) in
make.leading.equalTo(8)
make.top.equalTo(self)
}
endTimeLabel = UILabel()
endTimeLabel.text = "结束时间"
self.addSubview(endTimeLabel)
endTimeLabel.snp.makeConstraints { (make) in
make.trailing.equalTo(-8)
make.top.equalTo(self)
}
durationTimeLabel = UILabel()
durationTimeLabel.text = "总共时间"
self.addSubview(durationTimeLabel)
durationTimeLabel.snp.makeConstraints { (make) in
make.centerX.top.equalTo(self)
}
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = thumbnailSize
flowLayout.minimumLineSpacing = 0
flowLayout.minimumInteritemSpacing = 0
flowLayout.scrollDirection = .horizontal
collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: flowLayout)
collectionView.bounces = false
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
collectionView.contentInset = UIEdgeInsetsMake(0, CGFloat(margin), 0, CGFloat(margin))
collectionView.showsHorizontalScrollIndicator = false
collectionView.dataSource = self
collectionView.delegate = self
collectionView.backgroundColor = UIColor.orange
self.addSubview(collectionView)
collectionView.snp.makeConstraints { (make) in
make.leading.trailing.bottom.equalTo(self)
make.height.equalTo(thumbnailSize.height)
}
leftSlider = UIImageView()
leftSlider.backgroundColor = UIColor.white
leftSlider.isUserInteractionEnabled = true
leftSlider.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(leftSliderPaning)))
self.addSubview(leftSlider)
leftSlider.snp.makeConstraints { (make) in
make.leading.equalTo(margin)
make.bottom.equalTo(collectionView)
make.size.equalTo(CGSize(width: 10, height: thumbnailSize.height))
}
let leftMask = UIView()
leftMask.isUserInteractionEnabled = false
leftMask.backgroundColor = UIColor(white: 0, alpha: 0.7)
self.addSubview(leftMask)
leftMask.snp.makeConstraints { (make) in
make.leading.top.bottom.equalTo(collectionView)
make.trailing.equalTo(leftSlider.snp.leading)
}
rightSlider = UIImageView()
rightSlider.backgroundColor = UIColor.white
rightSlider.isUserInteractionEnabled = true
rightSlider.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(rightSliderPaning)))
self.addSubview(rightSlider)
rightSlider.snp.makeConstraints { (make) in
make.trailing.equalTo(-margin)
make.bottom.size.equalTo(leftSlider)
}
let rightMask = UIView()
rightMask.isUserInteractionEnabled = false
rightMask.backgroundColor = UIColor(white: 0, alpha: 0.7)
self.addSubview(rightMask)
rightMask.snp.makeConstraints { (make) in
make.trailing.top.bottom.equalTo(collectionView)
make.leading.equalTo(rightSlider.snp.trailing);
}
limitBoard = UIView()
limitBoard.layer.borderWidth = 2
limitBoard.layer.borderColor = UIColor(white: 1.0, alpha: 0.5).cgColor
self.addSubview(limitBoard)
limitBoard.snp.makeConstraints { (make) in
make.size.equalTo(CGSize(width: self.frame.width - 2 * margin, height: thumbnailSize.height))
make.center.equalTo(collectionView)
}
let topMask = UIView()
topMask.isUserInteractionEnabled = false
topMask.backgroundColor = UIColor.white
self.addSubview(topMask)
topMask.snp.makeConstraints { (make) in
make.top.equalTo(collectionView)
make.height.equalTo(3)
make.leading.equalTo(leftSlider.snp.trailing)
make.trailing.equalTo(rightSlider.snp.leading)
}
let bottomMask = UIView()
bottomMask.isUserInteractionEnabled = false
bottomMask.backgroundColor = UIColor.white
self.addSubview(bottomMask)
bottomMask.snp.makeConstraints { (make) in
make.bottom.equalTo(collectionView)
make.height.leading.trailing.equalTo(topMask)
}
indicator = UIView()
indicator.backgroundColor = UIColor.white
self.insertSubview(indicator, belowSubview: leftSlider)
indicator.snp.makeConstraints { (make) in
make.leading.equalTo(leftSlider);
make.width.equalTo(3);
make.top.bottom.equalTo(collectionView);
}
}
这部分代码比较多,但做的事情很简单,就是初始化每个控件并添加到我们自定义的控件上,然后设置其颜色用来调试。
为了让UICollectionView
能够正常的显示,我们需要实现UICollectionViewDataSource
并给一些临时数据:
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 15
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.imageView.backgroundColor = UIColor.red
return cell
}
- 对于一些多次使用的值,我们可以将其声明为常量方便调用,比如
setupUtil
法里的:
/// 缩略图尺寸
let thumbnailSize = CGSize(width: 30, height: 50)
/// 滑块距离左右边界的距离
let margin: CGFloat = 40.0
- 这里布局使用的是第三方自动布局库SnapKit,它是Robert Payne 写的Masonry的Swift版本。关于使用第三方库的问题,本文在总结中有说明。
按照拆分控件时得到的层级关系,我们将所有子控件添加到父视图后会得到以下效果:
基本界面
此时的层级关系:
基本层级关系
至此我们已经将所需的子控件创建完毕,形成了一个基本的效果。视觉功能的完善是一个很好的切入点,这可以让开发者对代码有直观的认知,并提供了高效的调试环境,接下来我们将进一步完成此功能。
填充数据
单纯的色块带着浓郁的山寨感,接下来我们让控件显示出它该有的样子吧。
首要的问题是如何让UICollectionViewCell
显示出视频的缩略图。要显示缩略图,就需要一个图片数组,数组有2种方法得到:
- 由外部直接传入图片数组。
- 由外部传入视频,内部解析得到图片数组。
本文以第二种方式讲解,你将学习到如何从一个视频中提取不同时间点的缩略图。
为了接收并保存视频对象,我们需要声明一个变量:
/// 绑定的AVAsset对象
var asset: AVAsset?
- iOS8之后,我们可以使用
Photos
框架从手机相册中请求视频对象,它是PHAsset
类型的。然后从PHAsset
中可以获取我们需要的AVAsset
类型的对象,这部分的实现可以在Demo中查看。我们自定义的控件目前只支持解析AVAsset?
类型。更多关于Photos
,请看Apple Developer Documentation - Photos。
拿到asset
之后,我们需要立即生成一些数据供之后使用,它们分别是:
/// 指定选择的区间,minDuration不得小于1秒。当maxDuration大于视频总长度时,会取视频总程度作为maxDuration
var range:(minDuration: Double, maxDuration: Double) = (2, 5)
/// 缩略图的最少个数
var minCount: Int = 10
/// 总共生成缩略图的个数
var totalCount: Int = 0
/// 选择区域距离左右边界的距离
var margin: CGFloat = 0
/// 视频的总时长
var originalDuration: CGFloat = 0
/// 区域中每一点距离代表的视频秒数,计算得到
var secondPerPoint: CGFloat!
/// 每张缩略图之间间隔的秒数
var timeSpacing: CGFloat!
/// 生成缩略图的对象
var imageGenerator: AVAssetImageGenerator!
/// 存放缩略图的数组
var images = [UIImage]()
- 这里
margin
再次出现,只是声明成了变量。在功能不断完善的过程中,之前的数据都有可能被重新修改或定义。
声明一个方法来计算这些属性的值:
// 计算出所需数值
func setupData() {
originalDuration = CGFloat(CMTimeGetSeconds(asset!.duration))
minCount = Int(self.frame.width) / Int(thumbnailSize.width) - 2
timeSpacing = CGFloat(range.maxDuration) / CGFloat(minCount)
totalCount = Int(originalDuration / timeSpacing)
secondPerPoint = timeSpacing / thumbnailSize.width;
margin = (self.frame.width - CGFloat(minCount) * thumbnailSize.width) * 0.5
}
这里解释一下数值规则:
- 缩略图排列需要一个最小值
minCount
,即控件可显示的item个数 - 2,保证当视频较短或者生成的缩略图较少时,也能保证最基本的显示。 -
totalCount
表示正常情况下缩略图的个数。 -
margin
由计算得到,表示左右滑块到控件边界的距离,保证用户的触摸区域不会超出屏幕。
基础数据准备完毕后,我们开始着手写一个方法提取视频的缩略图。每张缩略图所代表的时间点不同,所以需要一个表示时间点的参数,看起来像是这样:
func getVideoThumbnail(second: Double) -> UIImage {
}
实现细节:
func getVideoThumbnail(second: Double) -> UIImage {
// 使用asset初始化imageGenerator
imageGenerator = AVAssetImageGenerator(asset: asset!)
// 创建CMTime对象
let time = CMTime(seconds: Double(second), preferredTimescale: 1)
// 声明临时变量
var cgImage: CGImage
do {
// 尝试取缩略图
cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
} catch {
// 异常处理
print(error)
return nil
}
return UIImage(cgImage: cgImage)
}
写到这里会发现异常处理中return nil
会得到编译器异常
Nil is incompatible with return type 'UIImage'
这是因为方法的返回值是UIImage
,Swift不允许将nil作为实际类型的返回值。将返回值改成可选的UIImage?
即可,表示此方法的返回值可能为空。
解决编译器异常后此方法即可正常工作,方法返回指定时间点的缩略图。看起来很美好,但测试后发现一个问题:当处理横屏录制的视频时,返回的图像依然是竖屏状态,即旋转了90°。此时我们优化这个方法,在内部对视频方向进行识别:
func getVideoThumbnail(second: Double, size: CGSize, transform: CGAffineTransform?) -> UIImage? {
imageGenerator = AVAssetImageGenerator(asset: asset!)
var actualTime = CMTime()
let time = CMTime(seconds: Double(second), preferredTimescale: 1)
var cgImage: CGImage
do {
cgImage = try imageGenerator.copyCGImage(at: time, actualTime: &actualTime)
} catch {
print(error)
return nil
}
// 开启一个CGContext,对cgImage进行方向处理
UIGraphicsBeginImageContextWithOptions(size, false, 0)
let context = UIGraphicsGetCurrentContext()
var image = UIImage()
if transform?.tx != 0 { // 竖屏录制的视频
context?.draw(cgImage, in: .init(x: 0, y: 0, width: size.width, height: size.height))
context?.translateBy(x: size.width, y: 0)
image = UIImage(cgImage: context!.makeImage()!, scale: 0, orientation: .leftMirrored)
} else { // 横屏录制的视频
context?.draw(cgImage, in: .init(x: 0, y: 0, width: size.height * (1 + (size.height - size.width) / size.height), height: size.height))
image = UIImage(cgImage: context!.makeImage()!, scale: 0, orientation: .downMirrored)
}
UIGraphicsEndImageContext()
return image
}
具体细节完成后,我们将imageGenerator
的初始化方法提取出来,在外部这样使用:
func generatorImages() {
imageGenerator = AVAssetImageGenerator(asset: asset!)
for i in 0..<totalCount {
if let image = self.getVideoThumbnail(second: Double(i) * Double(timeSpacing),
size: thumbnailSize,
transform: asset?.tracks.first?.preferredTransform) {
images.append(image)
}
}
}
func getVideoThumbnail(second: Double, size: CGSize, transform: CGAffineTransform?) -> UIImage? {
var actualTime = CMTime()
let time = CMTime(seconds: Double(second), preferredTimescale: 1)
var cgImage: CGImage
do {
cgImage = try imageGenerator.copyCGImage(at: time, actualTime: &actualTime)
} catch {
print(error)
return nil
}
UIGraphicsBeginImageContextWithOptions(size, false, 0)
let context = UIGraphicsGetCurrentContext()
var image = UIImage()
if transform?.tx != 0 { // 竖屏录制的视频
context?.draw(cgImage, in: .init(x: 0, y: 0, width: size.width, height: size.height))
context?.translateBy(x: size.width, y: 0)
image = UIImage(cgImage: context!.makeImage()!, scale: 0, orientation: .leftMirrored)
} else { // 横屏录制的视频
context?.draw(cgImage, in: .init(x: 0, y: 0, width: size.height * (1 + (size.height - size.width) / size.height), height: size.height))
image = UIImage(cgImage: context!.makeImage()!, scale: 0, orientation: .downMirrored)
}
UIGraphicsEndImageContext()
return image
}
至此,我们得到了一个保存着数量为totalCount
的缩略图数组images
,其中的每一张缩略图是在视频asset
中每隔timeSpacing
秒一次取到的,其大小为thumbnailSize
,且方向同视频方向一致。
接下来将这个数组交给collectionView
显示。为了使用方便,我们自定义一个VideoLineCell
,它继承自UICollectionViewCell
,包含一个UIImageView
来显示缩略图。
private class VideoLineCell: UICollectionViewCell {
lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleToFill
imageView.clipsToBounds = true
self.contentView.addSubview(imageView)
imageView.snp.makeConstraints { (make) in
make.edges.equalTo(self.contentView)
}
return imageView
}()
}
-
lazy
关键字表示此属性是延迟加载的,它拥有一个闭包,只有当外部第一次使用此属性时,闭包里的内容才会被执行。更多关于Swift的lazy
关键字,请看这里。
修改collectionView
的cell注册方法以及UICollectionViewDataSource
的实现:
collectionView.register(VideoLineCell.self, forCellWithReuseIdentifier: "cell")
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return totalCount
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! VideoLineCell
cell.imageView.image = images[indexPath.row]
return cell
}
现在来运行程序吧,我们会看到以下效果:
填充数据后的界面
此时的层级关系:
填充数据后的层级关系
至此,我们的控件已经可以自动解析视频并生成缩略图进行显示了,感觉不错。接下来让我们给它加上手势交互吧。
添加交互
在拆分控件时我们得到一个结论:选择区域的左右滑块是两个独立的UIImageView
,并拥有各自的拖拽手势,当拖动时会显示边界边框,现在来实现这个想法吧。
我们在初始化左右滑块的时候绑定了两个UIPanGestureRecognizer
,分别指向了两个方法leftSliderPaning
和rightSliderPaning
,先来实现leftSliderPaning
:
func leftSliderPaning(panGR: UIPanGestureRecognizer) {
// 获取偏移量
let tX = panGR.translation(in: self).x
// 更新滑块约束
leftSlider.snp.updateConstraints({ (make) in
make.leading.equalTo(leftSlider.frame.minX + tX)
})
// 重置偏移量
panGR.setTranslation(CGPoint.zero, in: self)
// 隐藏或显示边界
limitBoard.isHidden = panGR.state != .changed
}
很简单对不对?确实如此,滑块已经可以跟随我们的手指左右滑动了。但是有一个很关键的问题,边界在哪里?此时需要一套规则来确定滑块滑动的边界:
- 左滑块最左可以滑到距离边界
margin
处,最右可以滑到距离右滑块(最短截取时间 /secondPerPoint
)处。 - 右滑块最右可以滑到距离边界
margin
处,最左可以滑到距离左滑块(最短截取时间 /secondPerPoint
)处。
根据这套规则,我们可以给leftSliderPaning
和rightSliderPaning
的实现加上边界约束:
func leftSliderPaning(panGR: UIPanGestureRecognizer) {
if originalDuration <= CGFloat(range.minDuration) {
return
}
let tX = panGR.translation(in: self).x
let min = margin
let max = rightSlider.frame.maxX - CGFloat(range.minDuration) / secondPerPoint
if leftSlider.frame.minX + tX < min {
leftSlider.snp.updateConstraints({ (make) in
make.leading.equalTo(min)
})
} else if leftSlider.frame.minX + tX > max {
leftSlider.snp.updateConstraints({ (make) in
make.leading.equalTo(max)
})
} else {
leftSlider.snp.updateConstraints({ (make) in
make.leading.equalTo(leftSlider.frame.minX + tX)
})
}
panGR.setTranslation(CGPoint.zero, in: self)
limitBoard.isHidden = panGR.state != .changed
}
func rightSliderPaning(panGR: UIPanGestureRecognizer) {
if originalDuration <= CGFloat(range.minDuration) {
return
}
let tX = panGR.translation(in: self).x
let min = margin
let max = self.frame.width - (leftSlider.frame.minX + CGFloat(range.minDuration) / secondPerPoint)
if self.frame.width - (rightSlider.frame.maxX + tX) < min {
rightSlider.snp.updateConstraints({ (make) in
make.trailing.equalTo(-min)
})
} else if self.frame.width - (rightSlider.frame.maxX + tX) > max {
rightSlider.snp.updateConstraints({ (make) in
make.trailing.equalTo(-max)
})
} else {
rightSlider.snp.updateConstraints({ (make) in
make.trailing.equalTo(-(self.frame.width - rightSlider.frame.maxX - tX))
})
}
panGR.setTranslation(CGPoint.zero, in: self)
limitBoard.isHidden = panGR.state != .changed
}
现在来运行程序吧,会得到这样的效果:
此时交互已经完成了一半。先不要看滑块了,来解决上方状态label的显示问题吧。
观察可知:当左右滑块拖动或者collectionView
滚动时,上方的label会实时更新。那么我们可已将更新内容的逻辑写在collectionView
的代理方法中,当监听到其滚动时就更新状态,而拖动滑块时也可以主动调用此代理方法来触发状态更新:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 更新label显示内容
let startSecond = (leftSlider.frame.minX + collectionView.contentOffset.x) * secondPerPoint
startTimeLabel.text = String(format: "%02d:%02d开始", Int(startSecond / 60), Int(startSecond.truncatingRemainder(dividingBy: 60)))
let endSecond = (rightSlider.frame.maxX + collectionView.contentOffset.x) * secondPerPoint
endTimeLabel.text = String(format: "%02d:%02d结束", Int(endSecond / 60), Int(endSecond.truncatingRemainder(dividingBy: 60)))
let durationSecond = (rightSlider.frame.maxX - leftSlider.frame.minX) * secondPerPoint;
durationTimeLabel.text = String(format: "共%.1f秒", durationSecond)
}
- 在当前的Swift版本中,取模
%
操作符已不可用,可以使用方法truncatingRemainder
代替。
不要忘记在leftSliderPaning
和rightSliderPaning
方法中主动调用collectionView
的代理方法:
self.scrollViewDidScroll(collectionView)
现在的效果:
至此,控件内部的显示及交互已经比较完整了。接下来我们要为其设计一套便捷安全的使用方法。
设计API
在API的设计上,需要遵从需求驱动开发的原则。如果我们不是控件的开发者而是使用者,我们会期望如何去使用它?也许是这样:
var videoLine = VideoLine(frame: xxx)
view.addSubview(videoLine)
使用者是很”懒惰“的,他们会希望你的控件使用起来尽可能的简单有效,最好是1行代码甚至0行代码解决问题。对于我们这个比较复杂的控件来说,虽然这种要求有些不现实,但也要尽力去降低它的使用难度。如果控件不需要高度自定义,那么它的使用原则应该是:
- 尽量少的对外属性
- 尽量少的可调用方法
- 尽量少的传递回调
这需要我们压缩控件需求的内容,只让使用者给予最必要的数据支持,附加数据均由内部产生,这就是所谓的”高内聚,低耦合“。
回头看我们的控件,它必要的数据只有两个,一个是视频的AVAsset
对象,另外一个是当前视频播放到的秒数。
视频的AVAsset
对象是一次性赋值的,我们可以创建一个指定构造器来强制用户传入此参数,否则控件将无法正常工作:
init(frame: CGRect, asset: AVAsset) {
super.init(frame: frame)
self.asset = asset
}
当前视频播放到的秒数可以使用属性观察器来监听,这里我们提供一个方法来更新:
func update(second: Double) {
// 更新播放进度指示器
let startSecond = (leftSlider.frame.minX + collectionView.contentOffset.x) * secondPerPoint;
let offset = (CGFloat(second) - startSecond) / secondPerPoint;
indicator.snp.updateConstraints { (make) in
make.leading.equalTo(leftSlider).offset(offset);
}
}
当然,我们也需要暴露出一些其他属性以提供一定程度的自定义,比如:
/// 指定选择的区间,minDuration不得小于1秒。当maxDuration大于视频总长度时,会取视频总程度作为maxDuration
var range:(minDuration: Double, maxDuration: Double) = (2, 5)
/// 左滑块
var leftSlider: UIImageView!
/// 右滑块
var rightSlider: UIImageView!
/// 单个缩略图的大小,默认(width: 40, height: 70)
var thumbnailSize: CGSize = CGSize(width: 40, height: 70)
此时VideoLine
的使用方法为:
// 通过构造器指定frame,以及绑定的AVAsset
videoLine = VideoLine(frame: xxx, asset: xxx)
// 添加到父视图上
view.addSubview(videoLine)
// 以下为可选赋值或方法
// 指定可选的区间,(2, 5)指最少选择2秒的内容,最多选择5秒的内容
videoLine.range = (2, 5)
// 自定义UI
videoLine.leftSlider.image = xxx
videoLine.rightSlider.image = xxx
videoLine.thumbnailSize = xxx
videoLine.update(second: xxx)
此时控件需要处理的外部数据均已获得,为了保证使用者已经对控件赋值完毕,需要明确的开始处理这些数据时,我们声明一个对外方法:
func process()
使用者可以自行调用此方法来表示赋值完毕,可以开始工作了:
videoLine = VideoLine(frame: xxx, asset: xxx)
view.addSubview(videoLine)
...
videoLine.range = (2, 5)
// 开始处理数据
videoLine.process()
现在要考虑采如何进行数据回调,本文以代理设计模式讲解。首先声明一个协议:
protocol VideoLineDelegate {
}
当设计代理方法时,可以参照苹果已经提供的某些代理方法,比如UIScrollViewDelegate
的一些方法:
func scrollViewDidScroll(_ scrollView: UIScrollView)
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool)
那么我们控件的代理方法可以声明为:
protocol VideoLineDelegate {
/// 当左滑块或右滑块正在拖动时会调用此方法
///
/// - Parameters:
/// - videoLine: 当前对象
/// - startSecond: 当前选中区间的开始秒数
/// - endSecond: 当前选中区间的结束秒数
optional func videoLine(_ videoLine: VideoLine, sliderValueChanged startSecond: Double, endSecond: Double)
/// 当左滑块或右滑块结束拖动时会调用此方法
///
/// - Parameter videoLine: 当前对象
optional func videoLineDidEndDragging(_ videoLine: VideoLine)
}
这样设计遵循苹果官网设计风格,方便使用者使用。在代码中选择合适的时机来调用这些方法吧:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
// 通知代理
guard let _ = delegate?.videoLine?(self, sliderValueChanged: Double(startSecond), endSecond: Double(endSecond)) else {
print("videoLineSliderValueChanged is not implemented")
return
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 通知代理
guard let _ = delegate?.videoLineDidEndDragging?(self) else {
print("videoLineDidEndDragging is not implemented")
return
}
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
// 通知代理
guard let _ = delegate?.videoLineDidEndDragging?(self) else {
print("videoLineDidEndDragging is not implemented")
return
}
}
func leftSliderPaning(panGR: UIPanGestureRecognizer) {
...
if panGR.state == .ended {
guard let _ = delegate?.videoLineDidEndDragging?(self) else {
print("videoLineDidEndDragging is not implemented")
return
}
}
}
func rightSliderPaning(panGR: UIPanGestureRecognizer) {
...
if panGR.state == .ended {
guard let _ = delegate?.videoLineDidEndDragging?(self) else {
print("videoLineDidEndDragging is not implemented")
return
}
}
}
- Swift中无法通过
respondsToSelector
方法来判断一个对象是否实现了某个方法,我们可以使用guard let _ = delegate?.someFunc
语句来判断。更多关于guard语句,请看这里。
至此,控件的API已经编写完毕,可以作为一个完整的控件供开发者使用了。但是此时它还不够健壮,需要对内部逻辑进行打磨优化。
代码优化
访问控制
现在来review我们的代码,发现存在一些隐患,比如使用者可以访问到控件内部独立使用的变量,甚至改变它们,比如:
videoLine.originalDuration = 10.0
或者调用内部逻辑方法:
videoLine.generatorImages()
originalDuration
保存着我们基于视频对象得到的数值,并影响着其他变量,如果被外部修改,可能会造成难以预料的后果。因此我们需要规定此类变量或方法对内可以访问,对外不可访问,这就需要使用Swift中的访问限制关键fileprivate
。
由fileprivate
修饰的变量只能在文件内部访问,包括extension
,这对于我们的需求是最合适的。更多关于访问控制,请看这里。
扩展
合理的使用扩展可以分割代码逻辑,让结构更加清晰。扩展支持协议,我们可以把UICollectionViewDataSource
和UICollectionViewDelegate
的方法实现提取出来放在一个extension
中,比如:
extension VideoLine: UICollectionViewDataSource, UICollectionViewDelegate {
// MARK: UICollectionViewDataSource
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
...
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
...
}
// MARK: UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
...
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
...
}
}
扩展同样支持访问控制,我们可以写一个私有扩展来声明私有方法:
private extension VideoLine {
// 计算出所需数值
func setupData() {
...
}
// 初始化所有视图
func setupUtil() {
...
}
func generatorImages() {
...
}
func getVideoPreViewImage(second: Double, size: CGSize, transform: CGAffineTransform?) -> UIImage? {
...
}
- 注意扩展不支持存储属性,但支持计算属性。
合理使用扩展之后,我们的代码结构看起来十分清晰:
class VideoLine: UIView {
...
}
extension VideoLine: UICollectionViewDataSource, UICollectionViewDelegate {
...
}
private extension VideoLine {
...
}
更多关于扩展,请看The Swift Programming Language (Swift 3.0.1): Extensions。
异常处理
控件声明了一个元组来保存可选择的时间范围range: (minDuration: Double, maxDuration: Double)
,如果使用者将其赋值为(10, 5)显然是不合理的。假如控件不对异常数据进行响应,那么造成的显示异常或崩溃会让使用者感到困惑。因此我们使用属性观察器来过滤不合理的赋值,并抛出异常提示:
/// 指定选择的区间,minDuration不得小于1秒。当maxDuration大于视频总长度时,会取视频总程度作为maxDuration
var range: (minDuration: Double, maxDuration: Double) = (2, 5) {
willSet {
assert(
newValue.minDuration >= 1 &&
newValue.maxDuration >= 1 &&
newValue.maxDuration >= newValue.minDuration,
"range value error")
}
}
同样的在process
方法中:
/// 当所需的属性赋值完毕后,调用此方法开始处理处理数据
func process() {
assert(asset != nil, "asset cann't be nil")
self.setupData()
self.setupUtil()
}
注释或文档
一个控件也许不需要复杂的文档,但关键逻辑、方法或属性的注释还是必须的。虽说好的代码不需要注释,但为了让使用者省心以及方便后续的维护,强烈建议补充注释。
总结
本文是笔者在Swift视频开发中的一些尝试,总结了一个控件从无到有的过程。在实现上肯定不是最优解,目前存在一些已知问题:
- 引用了第三方的库。这是做任何轮子都需要尽量避免的,如果使用者的项目中没有使用轮子需要的库,那么需要引入它,带来了额外的开销。如果使用了相同的库版本却不同,有可能出现编译冲突。
- 子控件较多。这是为了编码方便所作出的让步,如果考虑渲染性能,需要尽量简化图层。
- 扩展度较低。高的扩展度或灵活性带来的是更复杂的编码逻辑和维护成本,如果想做一个优秀的控件,这是必须考虑的问题。
总的来说,本文所列举的实现过程已经可以承载类似的业务需求,如果你觉得有进一步优化的必要,欢迎留言或与我联系。
在文章开始所展示的场景中,选取时间段之后通常会对视频本身进行裁剪、压缩、加水印等操作,稍后笔者会开一篇新的文章来讲解这些常用的视频编辑方法,有兴趣的同学可以持续关注一下。
本文所有示例代码或Demo可以在此获取:https://github.com/WillieWangWei/VideoLine.git
如果本文对你有所帮助,请给个Star👍