弹幕在直播,视频类app上,会经常看到。这段时间研究了下弹幕的原理,并用swift实现了下。以此来记录。实现效果如下。github地址:SwiftDanmuView
弹幕原理
假设方向是从右到左,那么弹幕就是头部从屏幕最右边,向左移动,直至尾部完全离开屏幕最左端
的一个过程。
弹幕动画
弹幕动画比较简单,水平位移,从右到左的过程。
动画时间 = (屏幕宽度+弹幕长度) / 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