##关键词
控件 属性 VideoLine 扩展 逻辑 cgImage 访问 设计 自定义 交互
本文所有示例代码或Demo可以在此获取:https://github.com/WillieWangWei/VideoLine.git
如果本文对你有所帮助,请给个Star?
##概述
界面控件是所iOS程序重要的组成部分,用户可以通过它们与应用程序进行交互。苹果提供了一套强大的控件组来满足日常的开发需求,我们可以使用这些控件来搭建大部分的用户界面。 但是当我们需要实现一些特别的场景时,这些控件就无法满足需求。此时我们可以基于系统控件来编写自定义控件,比如以下场景中底部的选择器:
本文从实际开发的角度出发,讲解一个控件从无到有的过程,是一篇综合性比较强的教程,主要涉及以下技术点:
UIKitAVFoundationPhotosSnapKitAccess Controlextension目录:
分析需求拆分控件搭建界面填充数据添加交互设计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?