直播间点赞 上下滑动技术细节
需求背景
2020年直播是一个大热点,目前我们的直播业务也已上线了很多新的玩法。在 iOS 端迭代过程中,处理了许多手势相关的问题,现做一总结。
连击点赞
连击点赞用于提高用户点赞频率,分 2 个阶段性检测,第一次点击识别双击,识别双击之后开始识别连击,每次识别成功触发点赞动画。目前双击识别间隔是 0.3s,保持连击的时间间隔是 1.0s。识别过程中会有 3 种状态:
- status = 0,初始化状态
- status = 1,正在识别双击
- status = 2,已经识别双击,正在识别双击
那么用户每次点击屏幕,会做一系列逻辑处理,代码如下:
@objc dynamic func continuityClickRecognizer(p: CGPoint, in v: UIView) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(cancleContinuityClick), object: nil)
self.perform(#selector(cancleContinuityClick), with: nil, afterDelay: continuityClickPeriod)
if status == 0 { // 第一次点击,开始识别双击
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(cancleDoubleClick), object: nil)
status = 1
self.perform(#selector(cancleDoubleClick), with: nil, afterDelay: doubleClickPeriod)
} else if status == 1 { // 第二次点击,识别上双击
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(cancleDoubleClick), object: nil)
status = 2
createAnimationWith(point: p, in: v)
} else if status == 2 { // 已经连击上
createAnimationWith(point: p, in: v)
}
}
// 取消连击识别
@objc dynamic func cancleContinuityClick() {
status = 0
}
// 取消双击识别
@objc dynamic func cancleDoubleClick() {
status = 0
}
其中,每次双击或连击识别成功会产生一个点赞动画 View,动画消失 View 自动释放。
上下滑动切换
为了方便用户观看更多的直播间,4.54.5 版本加入上下滑动切换直播间功能,有 2 个实现思路,使用 UITableView ,或使用 UIScrollView。为了不产生性能和复用的问题,这里使用 UIScrollView 来实现。
UIScrollView 实现上下滑动的方案和实现图片轮播的方案一样,采用三个 SingleView,作为上中下3个待显示的直播间的承载 view,滑动 scrollView 时,在 func scrollViewDidScroll(_ scrollView: UIScrollView) 代理方法里面做三个 SingleView 的切换判断,在 scrollViewDidEndDecelerating 里做最终的直播间的加载播放,和frame 位置调整,其中切换 SingleView 的代码如下:
@objc public dynamic func scrollViewDidScroll(_ scrollView: UIScrollView) {
let h = scrollView.frame.size.height
...
if scrollView.contentOffset.y >= h*2 { // 上滑
removeLiveView()
if index == 0 { // 首次上滑到第 3 个
index += 2
scrollView.contentOffset = CGPoint(x: 0, y: h)
topView.roomModel = centerView.roomModel
centerView.roomModel = bottomView.roomModel
} else {
index += 1
if index == roomList.count-1 {
centerView.roomModel = roomList[index-1]
} else {
scrollView.contentOffset = CGPoint(x: 0, y: h)
topView.roomModel = centerView.roomModel
centerView.roomModel = bottomView.roomModel
}
}
if index < roomList.count-1 && roomList.count >= 3 {
bottomView.roomModel = roomList[index+1]
}
} else if scrollView.contentOffset.y <= 0 { // 下拉
removeLiveView()
if index == 1 {
topView.roomModel = roomList[index-1]
centerView.roomModel = roomList[index]
bottomView.roomModel = roomList[index+1]
index -= 1
} else {
if index == roomList.count-1 {
index -= 2
} else {
index -= 1
}
scrollView.contentOffset = CGPoint(x: 0, y: h)
bottomView.roomModel = centerView.roomModel
centerView.roomModel = topView.roomModel
if index>0 {
topView.roomModel = roomList[index-1]
}
}
}
if scrollView.contentOffset.y == h*2 {
...
}
}
基本思路是,在将要滑动到顶或画到底时交换 3 个 SingleView 的直播数据和位置调整,来实现无限滑动的效果,需要注意的是,由于数据交换直接做了 contentOffset 调整,为了避免直播闪烁的问题,需提前将直播画面移除。
滑动手势冲突处理
除了点赞和上下滑动以外,直播间还可能存在 2 种手势,一个是右滑清屏,一个是底部列表的滑动手势。其中底部列表的滑动手势则会与直播间上下滑动手势冲突,需要额外处理。
处理的情况分为两种,一种是之前的商品列表,以 childController 和 subView 形式添加在直播间的页面上的,系统默认识别上下切换直播间的手势,这种的处理方式是添加一个变量,判断当前有类似的底部弹出 view,有则禁止直播间的上下滑动切换:
if self.isHorizontal || self.livePortraitMainVC?.isPresentingView ?? false {
let index: Int = Int(scrollView.contentOffset.y + 0.4*h) / Int(h)
scrollView.setContentOffset(CGPoint(x: 0, y: index*Int(h)), animated: false)
}
另外一种则是对于新的底部弹出列表,例如用户榜单,直接以 pop 形式弹出,处理方式是将其添加在一个中间 popView 上,在 popView 上处理点击和滑动手势来解决冲突问题。其中 popView 首先需要添加点击和滑动手势:
self.addGestureRecognizer(self.tapGesture)
self.addGestureRecognizer(self.panGesture)
然后设置该手势与 scrollView 的手势共存:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == self.panGesture {
if otherGestureRecognizer is UIPanGestureRecognizer {
if otherGestureRecognizer.view is UIScrollView {
return true
}
}
}
return false
}
设置手势触发条件:
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == self.tapGesture {
let p = gestureRecognizer.location(in: self.contentView)
if self.contentView.layer.contains(p) && gestureRecognizer.view == self {
return false
}
} else if gestureRecognizer == self.panGesture {
return true
}
return true
}
接下来则是2个手势处理细节:
@objc private dynamic func handleTapGesture(tap: UITapGestureRecognizer) {
debugPrint("handleTapGesture")
let p = tap.location(in: self.contentView)
// 点击空白区域收起弹出的列表
if !self.contentView.layer.contains(p) && tap.view == self {
self.dismiss()
}
}
@objc private dynamic func handlePanGesture(pan: UIPanGestureRecognizer) {
let translation = pan.translation(in: self.contentView)
let p = pan.location(in: self.contentView)
if self.isDisMissing {
return
}
if !self.contentView.layer.contains(p) && pan.view == self {
self.dismiss()
return
}
if self.isDragScrollView {
if self.scrollView.contentOffset.y <= 0 {
if translation.y > 0 { // xiangxia
self.scrollView.contentOffset = .zero
self.scrollView.panGestureRecognizer.isEnabled = false
self.isDragScrollView = false
var f = contentView.frame
f.origin.y += translation.y
self.contentView.frame = f
}
}
} else {
let cm = self.frame.size.height - self.contentView.frame.size.height
if translation.y > 0 { // xiangxia
var f = contentView.frame
f.origin.y += translation.y
self.contentView.frame = f
} else if translation.y < 0 && self.contentView.frame.origin.y > cm { // xiangshang
var f = contentView.frame
f.origin.y = max(self.contentView.frame.origin.y + translation.y, cm)
self.contentView.frame = f
}
}
pan.setTranslation(.zero, in: self.contentView)
if pan.state == .ended {
let v = pan.velocity(in: self.contentView)
self.scrollView.panGestureRecognizer.isEnabled = true
// 判断是否要下拉收起列表
if v.y > 0 && self.lastTransitionY > 5 && !self.isDragScrollView {
self.dismiss()
} else {
if let sv = self.superview {
self.show(fromView: sv, completion: nil)
}
}
}
self.lastTransitionY = translation.y
}
}
既已览卷至此,何不品评一二: