需求背景

2020年直播是一个大热点,目前我们的直播业务也已上线了很多新的玩法。在 iOS 端迭代过程中,处理了许多手势相关的问题,现做一总结。

连击点赞

连击点赞用于提高用户点赞频率,分 2 个阶段性检测,第一次点击识别双击,识别双击之后开始识别连击,每次识别成功触发点赞动画。目前双击识别间隔是 0.3s,保持连击的时间间隔是 1.0s。识别过程中会有 3 种状态:

  1. status = 0,初始化状态
  2. status = 1,正在识别双击
  3. 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
    }
    
}