iOS弹幕之swift实现

弹幕在直播,视频类app上,会经常看到。这段时间研究了下弹幕的原理,并用swift实现了下。以此来记录。实现效果如下。github地址:SwiftDanmuView

danmu.gif

弹幕原理

假设方向是从右到左,那么弹幕就是头部从屏幕最右边,向左移动,直至尾部完全离开屏幕最左端的一个过程。

danmu.png

弹幕动画

弹幕动画比较简单,水平位移,从右到左的过程。

动画时间 = (屏幕宽度+弹幕长度) / speed,speed可自行设置。

弹轨

弹幕一般会有N条弹轨,这样弹幕可以同时在不同的弹轨中显示。

从待播放弹幕list中,从头取出一条,计算将要放到哪条弹轨。

主要算法是:遍历所有弹轨,计算该弹幕放入该弹轨,是否会与最后一条弹幕碰撞,若不会,则放到该轨道。若都不符合,那么继续放在list中,等待下一次的取出(有个定时器,每个0.1s从list中取出弹幕来播放)。

for i in 0..
防碰撞

防碰撞的原理:记录每条弹轨的最后一条弹幕的最右端显示到屏幕上的时间 + 时间间隔 + 当前时间 = t,即t = 弹幕宽度 / speed + interval(默认0.5s) + curTime,在要将一条弹幕放到弹轨时,若当前的时间>=t,则满足条件。

var shouldShow = true
// 检查是否满足条件
if let time = timeDict[index] {
  let currentTime = NSDate()
  if currentTime.timeIntervalSince1970 < TimeInterval(time) {
      shouldShow = false
  }
}
        
// 弹幕完全显示在屏幕的时间+间隔
let time = itemView.width / speed + 0.5 + CGFloat(NSDate().timeIntervalSince1970)
timeDict[index] = time
view重用

在弹幕量较大时,每次都新创建view,会耗费内存。在当弹幕动画结束后,可将其添加到重用池中,注意这里的动画结束有普通的结束和暂停恢复后的结束,2种情况都要处理放入重用池。在播放弹幕时,首先从重用池中取,没有就重新创建。

因为考虑到会有不同样式的弹幕,我这里的处理是,以样式的className为key来存要重用的view。

// key:className
lazy var reuseItemViewPool: [String: UIView] = {
   var reusePool = [String: UIView]()
   return reusePool
}()

// 取重用view
func reuseItemView(cls: AnyClass) -> UIView? {
   guard reuseItemViewPool.count > 0 else {
       return nil
   }
        
   let className = NSStringFromClass(cls)
   if let reuseView = reuseItemViewPool[className] {
       reuseItemViewPool.removeValue(forKey: className)

       return reuseView
   }
   
   return nil
}
  • 普通结束放入重用池:
let duration = (self.width + itemView.width) / speed
        
UIView.animate(withDuration: TimeInterval(duration), delay: 0, options: .curveLinear, animations: {
  itemView.x = -itemView.width
}) { (finished) in
  if (finished) {
      // add to reusePool
      self.reuseItemViewPool[NSStringFromClass(itemViewClass)] = itemView
      print("reusePool:\(self.reuseItemViewPool)")
      itemView.removeFromSuperview()
  }
}
  • 暂停恢复后放入重用池
let duration = (itemView.x + itemView.width) / speed

UIView.animate(withDuration: TimeInterval(duration), delay: 0, options: .curveLinear, animations: {
    itemView.x = -itemView.width
}) { (finished) in
     if (finished) {
         let mirror = Mirror(reflecting: itemView)
         self.reuseItemViewPool[NSStringFromClass(mirror.subjectType as! AnyClass)] = itemView
         itemView.removeFromSuperview()
     }
}
暂停/恢复
  • 暂停
    暂停说白了就是将动画移除,然后将弹幕放在它正确的位置。在动画过程中,presentationLayer是表示正在做动画的layer,取出其frame,就是真正此时弹幕的位置。

    func pause() {
       stopTimer()
       
       for itemView in self.subviews {
           if itemView.isKind(of: SLDanmuItemView.self) {
           if let frame = itemView.layer.presentation()?.frame {
               itemView.frame = frame
           }
              
           itemView.layer.removeAllAnimations()
           }
        }
    

}

    
* 恢复
恢复的过程,重新开始动画,有一点要注意的是,`防碰撞的时间戳要更新`。
    
  假设有条轨道的时间戳是t,在弹幕的尾部还没有完全显示在屏幕上的时候,点击了暂停,然后隔了2s,再点击恢复,那么这个时候,这条弹幕继续做动画,若没有更新碰撞时间戳,新放入的弹幕在判断时,当前时间有可能是会大于t的,然后会被放入这条轨道,从而会发生碰撞。
    
    ```
    // 更新时间,如果右边未完全显示在屏幕
     if (itemView.x + itemView.width > self.width) {
         let time = (itemView.x + itemView.width - self.width) / speed + 0.5 + CGFloat(NSDate().timeIntervalSince1970)
         timeDict[index] = time
     }
    ```
    
  恢复代码:
    ```
    func resume() {
        startTimer()
        
        for itemView in self.subviews {
            if itemView.isKind(of: SLDanmuItemView.self) {
                let index = rowWithY(y: itemView.y)
               
                // 更新时间,如果右边未完全显示在屏幕
                if (itemView.x + itemView.width > self.width) {
                    let time = (itemView.x + itemView.width - self.width) / speed + 0.5 + CGFloat(NSDate().timeIntervalSince1970)
                    timeDict[index] = time
                }
                
                let duration = (itemView.x + itemView.width) / speed
                
                UIView.animate(withDuration: TimeInterval(duration), delay: 0, options: .curveLinear, animations: {
                    itemView.x = -itemView.width
                }) { (finished) in
                    if (finished) {
                        let mirror = Mirror(reflecting: itemView)
                        self.reuseItemViewPool[NSStringFromClass(mirror.subjectType as! AnyClass)] = itemView
                        itemView.removeFromSuperview()
                    }
                }
            }
        }
    }
    ```

####弹幕数据结构


结构定义如下:
    

class SLDanmuInfo {
var text: String
var textColor: UIColor = UIColor.black
var itemViewClass: AnyClass = SLDanmuItemView.self
...
}

    
更新ui,sizeToFit更新frame。
    

class SLDanmuItemView: UIView {
func updateDanmuInfo(info: SLDanmuInfo) {
label.text = info.text
label.textColor = info.textColor

    setNeedsLayout()
}

// 计算自身frame
override func sizeToFit() {
super.sizeToFit()

    label.sizeToFit()
    
    label.frame = CGRect(x: leftMargin, y: topMargin, width: label.frame.size.width, height: label.frame.size.height)
    
    self.frame = CGRect(x: self.frame.origin.x, y: self.frame.origin.y, width: label.frame.size.width + 2 * leftMargin, height: label.frame.size.height + 2 * topMargin)
}

}

    
这种是最基础的,只更新text。由于要支持不同样式的弹幕,所以定义了`itemViewClass`。可设置该条弹幕所展示ui的`class`。
    
同时也可以自定义弹幕ui继承自`SLDanmuItemView`,danmuInfo继承`SLDanmuInfo`,在自定义ui中更新danmuInfo,`注意要重写sizeToFit,设置好frame`。

我这里自定义了个有背景色的ui。
    

class SLDanmuBgItemView: SLDanmuItemView {
lazy var bgView: UIView = {
var bgView = UIView()

        bgView.backgroundColor = UIColor.lightGray
        bgView.layer.cornerRadius = 4
        bgView.clipsToBounds = true
        
        return bgView
    }()

    override func commonInit() {
        super.commonInit()
        self.insertSubview(bgView, belowSubview: label)
    }

override func updateDanmuInfo(info: SLDanmuInfo) {
        super.updateDanmuInfo(info: info)
    
        if let info = info as? SLBgDanmuInfo {
            bgView.backgroundColor = info.bgColor
        }
    }

override func sizeToFit() {
    super.sizeToFit()
    bgView.frame = self.bounds
}

}

    

class SLBgDanmuInfo: SLDanmuInfo {
var bgColor: UIColor
...
}



####使用

设置好数据源即可。

class ViewController: UIViewController {

    lazy var danmuView: SLDanmuView = {
        var danmuView = SLDanmuView(frame: CGRect(x: 0, y: 50, width: self.view.width, height: 150))
        return danmuView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        var list = [SLDanmuInfo]()
        
        //test
        var info = SLDanmuInfo(text: "hi色黑龙江凡士林", textColor: UIColor.red, itemViewClass: SLDanmuBgItemView.self)
        list.append(info)
        
        info = SLDanmuInfo(text: "arre咳咳咳看", textColor: UIColor.blue, itemViewClass: SLDanmuItemView.self)
        list.append(info)
        
        info = SLDanmuInfo(text: "fds分手快乐发送", textColor: UIColor.black, itemViewClass: SLDanmuBgItemView.self)
        list.append(info)
        
        info = SLDanmuInfo(text: "23诶偶无偶", textColor: UIColor.purple, itemViewClass: SLDanmuItemView.self)
        list.append(info)
        
        info = SLDanmuInfo(text: "ff你好风刀霜剑反馈塑料袋交付的考四六级", textColor: UIColor.green, itemViewClass: SLDanmuBgItemView.self)
        list.append(info)
        
        info = SLDanmuInfo(text: "ff你好风刀霜剑发快递扩扩扩扩塑料袋交付的考四六级", textColor: UIColor.yellow, itemViewClass: SLDanmuItemView.self)
        list.append(info)
        
        info = SLBgDanmuInfo(text: "just for test", textColor: UIColor.brown, itemViewClass: SLDanmuBgItemView.self, bgColor: UIColor.red)
        list.append(info)
        
        for i in 0...10 {
            info = SLDanmuInfo(text: "考四六级" + String(i), textColor: UIColor.red, itemViewClass: SLDanmuItemView.self)
            list.append(info)
        }
        
        danmuView.pendingList.append(contentsOf: list)

        self.view.addSubview(danmuView)
    }

详细可以看源码:https://github.com/silan-liu/SwiftDanmuView

你可能感兴趣的:(iOS弹幕之swift实现)