iOS弹幕之swift实现

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

danmu.gif

弹幕原理

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

danmu.png

弹幕动画

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

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

弹轨

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

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

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

for i in 0..<N {
    if 满足条件不发生碰撞
        return i
}
防碰撞

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

推荐阅读更多精彩内容

  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥ios动画全貌。在这里你可以看...
    每天刷两次牙阅读 8,321评论 6 30
  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥iOS动画全貌。在这里你可以看...
    F麦子阅读 4,996评论 5 13
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,544评论 25 707
  • 写在开篇 最近做了个视频直播项目,当用到弹幕时,找了很多网上弹幕demo。当时因为项目进度的原因,就随便选了一个漂...
    Rasping阅读 7,278评论 50 36
  • 每个小孩都见过萌萌的大脸泰迪熊,但殊不知他们抱着泰迪熊的感受,看了阳光姐姐的《拥抱幸福的小熊》真的好感动,...
    潘多拉小熊阅读 481评论 0 0