彈幕在直播,視頻類(lèi)app上,會(huì)經(jīng)??吹?。這段時(shí)間研究了下彈幕的原理,并用swift實(shí)現(xiàn)了下。以此來(lái)記錄。實(shí)現(xiàn)效果如下。github地址:SwiftDanmuView

彈幕原理
假設(shè)方向是從右到左,那么彈幕就是頭部從屏幕最右邊,向左移動(dòng),直至尾部完全離開(kāi)屏幕最左端的一個(gè)過(guò)程。

彈幕動(dòng)畫(huà)
彈幕動(dòng)畫(huà)比較簡(jiǎn)單,水平位移,從右到左的過(guò)程。
動(dòng)畫(huà)時(shí)間 = (屏幕寬度+彈幕長(zhǎng)度) / speed,speed可自行設(shè)置。
彈軌
彈幕一般會(huì)有N條彈軌,這樣彈幕可以同時(shí)在不同的彈軌中顯示。
從待播放彈幕list中,從頭取出一條,計(jì)算將要放到哪條彈軌。
主要算法是:遍歷所有彈軌,計(jì)算該彈幕放入該彈軌,是否會(huì)與最后一條彈幕碰撞,若不會(huì),則放到該軌道。若都不符合,那么繼續(xù)放在list中,等待下一次的取出(有個(gè)定時(shí)器,每個(gè)0.1s從list中取出彈幕來(lái)播放)。
for i in 0..<N {
if 滿足條件不發(fā)生碰撞
return i
}
防碰撞
防碰撞的原理:記錄每條彈軌的最后一條彈幕的最右端顯示到屏幕上的時(shí)間 + 時(shí)間間隔 + 當(dāng)前時(shí)間 = t,即t = 彈幕寬度 / speed + interval(默認(rèn)0.5s) + curTime,在要將一條彈幕放到彈軌時(shí),若當(dāng)前的時(shí)間>=t,則滿足條件。
var shouldShow = true
// 檢查是否滿足條件
if let time = timeDict[index] {
let currentTime = NSDate()
if currentTime.timeIntervalSince1970 < TimeInterval(time) {
shouldShow = false
}
}
// 彈幕完全顯示在屏幕的時(shí)間+間隔
let time = itemView.width / speed + 0.5 + CGFloat(NSDate().timeIntervalSince1970)
timeDict[index] = time
view重用
在彈幕量較大時(shí),每次都新創(chuàng)建view,會(huì)耗費(fèi)內(nèi)存。在當(dāng)彈幕動(dòng)畫(huà)結(jié)束后,可將其添加到重用池中,注意這里的動(dòng)畫(huà)結(jié)束有普通的結(jié)束和暫?;謴?fù)后的結(jié)束,2種情況都要處理放入重用池。在播放彈幕時(shí),首先從重用池中取,沒(méi)有就重新創(chuàng)建。
因?yàn)榭紤]到會(huì)有不同樣式的彈幕,我這里的處理是,以樣式的className為key來(lái)存要重用的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
}
- 普通結(jié)束放入重用池:
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()
}
}
- 暫?;謴?fù)后放入重用池
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()
}
}
暫停/恢復(fù)
-
暫停
暫停說(shuō)白了就是將動(dòng)畫(huà)移除,然后將彈幕放在它正確的位置。在動(dòng)畫(huà)過(guò)程中,presentationLayer是表示正在做動(dòng)畫(huà)的layer,取出其frame,就是真正此時(shí)彈幕的位置。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() } }
}
* 恢復(fù)
恢復(fù)的過(guò)程,重新開(kāi)始動(dòng)畫(huà),有一點(diǎn)要注意的是,`防碰撞的時(shí)間戳要更新`。
假設(shè)有條軌道的時(shí)間戳是t,在彈幕的尾部還沒(méi)有完全顯示在屏幕上的時(shí)候,點(diǎn)擊了暫停,然后隔了2s,再點(diǎn)擊恢復(fù),那么這個(gè)時(shí)候,這條彈幕繼續(xù)做動(dòng)畫(huà),若沒(méi)有更新碰撞時(shí)間戳,新放入的彈幕在判斷時(shí),當(dāng)前時(shí)間有可能是會(huì)大于t的,然后會(huì)被放入這條軌道,從而會(huì)發(fā)生碰撞。
```
// 更新時(shí)間,如果右邊未完全顯示在屏幕
if (itemView.x + itemView.width > self.width) {
let time = (itemView.x + itemView.width - self.width) / speed + 0.5 + CGFloat(NSDate().timeIntervalSince1970)
timeDict[index] = time
}
```
恢復(fù)代碼:
```
func resume() {
startTimer()
for itemView in self.subviews {
if itemView.isKind(of: SLDanmuItemView.self) {
let index = rowWithY(y: itemView.y)
// 更新時(shí)間,如果右邊未完全顯示在屏幕
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()
}
}
}
}
}
```
####彈幕數(shù)據(jù)結(jié)構(gòu)
結(jié)構(gòu)定義如下:
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()
}
// 計(jì)算自身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)
}
}
這種是最基礎(chǔ)的,只更新text。由于要支持不同樣式的彈幕,所以定義了`itemViewClass`??稍O(shè)置該條彈幕所展示ui的`class`。
同時(shí)也可以自定義彈幕ui繼承自`SLDanmuItemView`,danmuInfo繼承`SLDanmuInfo`,在自定義ui中更新danmuInfo,`注意要重寫(xiě)sizeToFit,設(shè)置好frame`。
我這里自定義了個(gè)有背景色的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
...
}
####使用
設(shè)置好數(shù)據(jù)源即可。
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分手快樂(lè)發(fā)送", textColor: UIColor.black, itemViewClass: SLDanmuBgItemView.self)
list.append(info)
info = SLDanmuInfo(text: "23誒偶無(wú)偶", textColor: UIColor.purple, itemViewClass: SLDanmuItemView.self)
list.append(info)
info = SLDanmuInfo(text: "ff你好風(fēng)刀霜?jiǎng)Ψ答佀芰洗桓兜目妓牧?jí)", textColor: UIColor.green, itemViewClass: SLDanmuBgItemView.self)
list.append(info)
info = SLDanmuInfo(text: "ff你好風(fēng)刀霜?jiǎng)Πl(fā)快遞擴(kuò)擴(kuò)擴(kuò)擴(kuò)塑料袋交付的考四六級(jí)", 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: "考四六級(jí)" + String(i), textColor: UIColor.red, itemViewClass: SLDanmuItemView.self)
list.append(info)
}
danmuView.pendingList.append(contentsOf: list)
self.view.addSubview(danmuView)
}
詳細(xì)可以看源碼:https://github.com/silan-liu/SwiftDanmuView