使用Swift构建一个视频时间轴控件

关键词


控件 属性 VideoLine 扩展 逻辑 cgImage 访问 设计 自定义 交互

本文所有示例代码或Demo可以在此获取:https://github.com/WillieWangWei/VideoLine.git

如果本文对你有所帮助,请给个Star👍

概述


界面控件是所iOS程序重要的组成部分,用户可以通过它们与应用程序进行交互。苹果提供了一套强大的控件组来满足日常的开发需求,我们可以使用这些控件来搭建大部分的用户界面。
但是当我们需要实现一些特别的场景时,这些控件就无法满足需求。此时我们可以基于系统控件来编写自定义控件,比如以下场景中底部的选择器:


视频时间轴选择

本文从实际开发的角度出发,讲解一个控件从无到有的过程,是一篇综合性比较强的教程,主要涉及以下技术点:

  • UIKit
  • AVFoundation
  • Photos
  • SnapKit
  • Access Control
  • extension

目录:

  • 分析需求
  • 拆分控件
  • 搭建界面
  • 填充数据
  • 添加交互
  • 设计API
  • 代码优化

分析需求


这是一个常见的场景——当用户选择了一个本地视频后,在此界面预览视频并对其长度进行裁剪,最终得到符合业务要求的短视频。
暂时忽略上部视频的预览区域,我们需要实底部的“缩略图进度条”。观察后我们发现这个控件有以下几个特点:

  1. 对视频片段进行采样,生成缩略图排列,且可以左右滑动。
  2. 中间有一个选择区域,可以通过滑动左右两边的滑块来确定选中区域的大小。
  3. 左右滑块滑动时会出现一个边框,表示滑动的边界。
  4. 选择区域以外的内容有黑色半透明蒙版。
  5. 选择区域中有一条指示线指示当前播放进度。
  6. 有文字说明当前选择片段的开始时间、总共时长以及结束时间。
  7. 与上方播放器实时联动。

初步的分析让我们对需要实现的内容有了大致的了解,但通常会忽略很多细节,这会在实际编码中体现出来。

拆分控件


现在需要初步确定各个位置用什么系统控件来实现。这里考虑的越周全,实际编码时绕的弯路就越少,我们结合截图来分析:


拆分控件

123用来显示当前选择区域的状态,不接收点击事件,所以直接使用UILabel

7区域支持左右滑动,首先考虑UIScrollView。其承载了多个尺寸相同的缩略图且横向滑动,那么使用拥有重用机制的UICollectionView最合适。

6看起来是一个白色的方框,左右两边均可拖动,系统并未提供类似的控件,所以要对其再次进行拆分。
由于左右边框(滑块)都可以单独拖动,所以判断使用两个单独的UIView,并各自绑定不同的拖拽手势。为了方便的使用自定义图片,确定滑块使用UIImageView。上下的边框也分解为两个单独的UIView,添加约束使其前后与左右边框相接即可。如图:

选择区域框拆分

5又是一个边框,但是它的大小的固定的,用来表示6的可选范围,所以可以直接使用UIView,设置其layer的相关属性即可得到所需样式。

48是选择区域之外的黑色蒙版,它的边界随着相邻滑块的位置而变化。可以直接使用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!
}
  • 这里没有将48黑色蒙版声明为全局变量,因为它们一旦被创建和添加约束后,后续不会再进行修改。更多关于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种方法得到:

  1. 由外部直接传入图片数组。
  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,分别指向了两个方法leftSliderPaningrightSliderPaning,先来实现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)处。

根据这套规则,我们可以给leftSliderPaningrightSliderPaning的实现加上边界约束:

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代替。

不要忘记在leftSliderPaningrightSliderPaning方法中主动调用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,这对于我们的需求是最合适的。更多关于访问控制,请看这里

扩展

合理的使用扩展可以分割代码逻辑,让结构更加清晰。扩展支持协议,我们可以把UICollectionViewDataSourceUICollectionViewDelegate的方法实现提取出来放在一个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👍

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,265评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,078评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,852评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,408评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,445评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,772评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,921评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,688评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,130评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,467评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,617评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,276评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,882评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,740评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,967评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,315评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,486评论 2 348

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,059评论 4 62
  • 参考并有选择地翻译了:http://square.github.io/okhttp/ 综述 HTTP是是现代应用访...
    Lshare_Blog阅读 2,350评论 1 30
  • 阅读是件最美的小事,可以汇成耀眼的星河… 掬一口好茶,捧一本好书,进一寸有一寸的欢喜。 世间没有比人的灵魂更宝贵的...
    泥娃娃的天空阅读 271评论 0 0