美文网首页collentionviewiOS Developer程序员
使用Swift构建一个视频时间轴控件

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

作者: Willie_ | 来源:发表于2017-02-28 22:12 被阅读749次

    关键词


    控件 属性 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👍

    相关文章

      网友评论

        本文标题:使用Swift构建一个视频时间轴控件

        本文链接:https://www.haomeiwen.com/subject/swjtgttx.html