iOS 多个UIScrollView UITableView嵌套解决方案

一直以来,在实际项目中,无论是iOS、RN还是Android都会遇到一个场景,那就是UI界面会涉及到多个UIScrollView、ListView在垂直、水平方向上嵌套滑动,而在一些嵌套滑动中还会涉及更多复杂的业务逻辑。以iOS为例,比较常见的处理方式是UIScrollView嵌套UIScrollView或者UITableView,但这样其实存在很多问题,也会有很多难以解决的问题

比如:

1、UIScrollView互相嵌套,当需要滑动的时候去根据滑动位置设置是否isScrollEnabled,这样做对于简单的滑动没有问题,亦或者对于仅有一个简单的headerView是不会存在太大问题

2、当我们的headerView会有跟多复杂逻辑、更多滑动就会难以判定,甚至会出现滑动过程中界面卡顿的现象,这就是因为isScrollEnabled判定不好掌握

3、可能很多会采用,通过锤子滑动的时候headerView下方UIScrollView的contentOffset的改变来推动headerView重新设置origin,但是如果headerView的高度过高,设置超过了屏幕(就好比如headerView包含动态的置顶数据呢),如果不能够全屏滑动,那么这样子的嵌套就失去了意义

在这里,我使用了一种UIKit Dynamic + Gesture来处理,解决了上述问题,当然由于每个人的业务逻辑会存在很多的不同,无暂时无法写出一个框架来适应所有业务逻辑的处理,但是这个解决方案在很大程度上可以根据自己的业务逻辑,自行修改代码即可完成使用,在完成这个功能期间,我解决了如下问题,并且这些也许是你在实现时需要解决的问题:

image

1、全屏可滑动

2、通过MJRefresh实现的下拉刷新、加载更多

3、单个tab,但数据未填充满屏幕

4、单个tab,数据填充满屏幕,但未填充满外层UIScrollView的contentSize

5、单个tab填充满屏幕

6、多个tab部分数据填充满屏幕,部分未填充

7、上述情况的其他多个tab情况

8、其他包含顶部horizontal滑动的情况

9、headerView包含动态的置顶、其他高度过高的UI等情况

10、其他更多的坑,我已在代码中注释

主要代码实现如下,每一个地方都有较为详细的注释:


enum NestedSlidingType: Int {
    case singleTabNotFillScreen = 0    // 单个tab数据未填充满屏幕
    case singleTabFillScreenNotFillContentSize  // 单个tab数据填充满屏幕,未填充满外层ScrollView contentSize
    case single // 上述两种情况外的单个tab情况
    case multiTabPartFill // 多个tab部分数据填充屏幕,部分未填充
    case multiTab   // 上述情况外的其他多个tab情况
    case multiTabOtherHeaderView  // 包含其他更多情况
}

通过手势来处理整屏的滑动

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if let gesture = gestureRecognizer as? UIPanGestureRecognizer {
            let translationX = gesture.translation(in: view).x
            let translationY = gesture.translation(in: view).y
            if translationY == 0 {
                return true
            } else {
                /// 这里说一说手势处理,这个值可以设置得更大一些,保证在滑动垂直的时候触发了pageScrollView的滚动
                /// return fabsf(Float(translationX))/Float(translationY) >= 6.0
                /// 为了处理得更加严谨一点,应该这样(因为我们的headerView还可能存在更多的水平滑动,需要根具自己的需要判定在多大的偏移量的情况下处理horizontal滑动
                let point = gesture.location(in: view)
                let otherConvertPoint = view.convert(point, to: otherView)
                let pageConvertPoint = view.convert(point, to: pageScrollView)
                if otherView.point(inside: otherConvertPoint, with: nil) {  // 手势在otherView
                    return fabs(Float(translationX)) > fabs(Float(translationY))
                } else if pageScrollView.point(inside: pageConvertPoint, with: nil) {  // 手势在pageScrollView
                    return fabsf(Float(translationX))/Float(translationY) >= 6.0
                }
            }
        }
        return false
    }
    
    @objc func panGestureRecognizerAction(_ gesture: UIPanGestureRecognizer) {
        switch gesture.state {
        case .began:
            let translationX = gesture.translation(in: view).x
            let translationY = gesture.translation(in: view).y
            let velocityX = gesture.velocity(in: view).x
            let velocityY = gesture.velocity(in: view).y
            /// 这里有个坑,本可以直接使用translation即可的,但是在iphoneX、plus上的translation.y 在屏幕的左侧会存在translationY 始终 == 0 的情况,也就是当用左手指滑动的时候,你会发现根本不会执行后面的逻辑了
            isVertical = fabsf(Float(translationY)) > fabsf(Float(translationX)) || fabsf(Float(velocityY)) > fabsf(Float(velocityX))
            animator.removeAllBehaviors()
            decelerationBehavior = nil
            springBehavior = nil
            break
        case .changed:
            if isVertical {
                print("------------  手势改变 --------")
                _decelerateScrollView(gesture.translation(in: view).y)
            }
            break
        case .cancelled:
            break
        case .ended:
            print("------------  手势结束 --------")
            if isVertical {
                /// MARK: 模拟减速滑动
                dynamicItem.center = view.bounds.origin
                let velocity = gesture.velocity(in: view)
                let inertialBehavior = UIDynamicItemBehavior(items: [dynamicItem])
                inertialBehavior.addLinearVelocity(CGPoint(x: 0, y: velocity.y), for: dynamicItem)
                inertialBehavior.resistance = 2.0
                var lastCenter = CGPoint.zero
                inertialBehavior.action = { [weak self] () in
                    guard let weakSelf = self else { return }
                    if weakSelf.isVertical {
                        let currentY = weakSelf.dynamicItem.center.y - lastCenter.y
                        weakSelf._decelerateScrollView(currentY)
                    }
                    lastCenter = weakSelf.dynamicItem.center
                }
                animator.addBehavior(inertialBehavior)
                decelerationBehavior = inertialBehavior
            }
            break
        default:
            break
        }
        /// 这里需要每次重新设置translation
        gesture.setTranslation(CGPoint.zero, in: view)
    }

通过UIKit Dynamic来模拟滑动及回弹的效果

private func _decelerateScrollView(_ detal: CGFloat) {
        guard let curSegmentScrollView = curSegmentChildVC?.tableView else { return }
        
        let maxOffsetY: CGFloat = HeaderView.defaultHeight + otherView.height - UIScreen.naviBarHeight
        
        /// MARK: 仅有一个tab,并且tab不能够将mainScrollView推到顶部
        if curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < curSegmentScrollView.height && type == .singleTabNotFillScreen || type == .singleTabFillScreenNotFillContentSize {
            var mainOffsetY = outterScrollView.contentOffset.y - detal
            let offset1 = outterScrollView.contentOffset.y + outterScrollView.height
            let offset2 = pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height
            if mainOffsetY > 0 {
                if offset2 < outterScrollView.height {  // 可以往上多滑动40,有一个弹回效果
                    mainOffsetY = offset2 + 40 < offset1 ? 40 : mainOffsetY
                } else {
                    if mainOffsetY + outterScrollView.height > offset2 + 60 {
                        mainOffsetY = offset2 + 60 - outterScrollView.height
                    }
                }
            } else {
                if mainOffsetY < -200 {
                    mainOffsetY = -200
                }
            }
            outterScrollView.contentOffset = CGPoint(x: 0, y: mainOffsetY)
        } else {  /// MARK: 其他情况
            if outterScrollView.contentOffset.y >= maxOffsetY {
                var offsetY = curSegmentScrollView.contentOffset.y - detal
                if offsetY < 0 || curSegmentScrollView.contentSize.height < curSegmentScrollView.height {
                    offsetY = 0
                    var mainOffsetY = outterScrollView.contentOffset.y - detal
                    mainOffsetY = mainOffsetY < 0 ? outterScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height) : mainOffsetY
                    outterScrollView.contentOffset = CGPoint(x: 0, y: min(mainOffsetY, maxOffsetY))
                    print("-------- 处理其他情况 ---------- if ------------- ")
                } else if curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < curSegmentScrollView.height {
                    offsetY = 0
                    print("---------- 处理其他情况 -------- else if 1 ------------- ")
                } else if offsetY >= curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height {
                    offsetY = curSegmentScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height)
                    print("--------- 处理其他情况 --------- else if 2 ------------- ")
                }
                curSegmentScrollView.contentOffset = CGPoint(x: 0, y: offsetY)
            } else {  /// 处理mainScrollView
                var mainOffsetY = outterScrollView.contentOffset.y - detal
                if mainOffsetY >= maxOffsetY {
                    mainOffsetY = maxOffsetY
                } else if mainOffsetY < 0 {
                    mainOffsetY = outterScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height)
                    if mainOffsetY < -200 { // 下拉刷新最多下拉到200位置
                        mainOffsetY = -200
                    }
                }
                print("--------------- 处理outterScrollView  -------- \(mainOffsetY)")
                outterScrollView.contentOffset = CGPoint(x: 0, y: mainOffsetY)
                if mainOffsetY == 0 {
                    _updateSegmentScrollViewContentOffset(CGPoint.zero)
                }
            }
        }
        
        
        /// MARK: 模拟回弹效果
        let bounce0 = curSegmentScrollView.contentSize.height < curSegmentScrollView.height && (type == .singleTabNotFillScreen || type == .singleTabFillScreenNotFillContentSize) && pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < outterScrollView.contentOffset.y + outterScrollView.height  // 单个到底的回弹
        let bounce1 = outterScrollView.contentOffset.y < 0   // main到顶的回弹
        let bounce2 = detal < 0 && curSegmentScrollView.contentSize.height > curSegmentScrollView.height && curSegmentScrollView.contentOffset.y > curSegmentScrollView.contentSize.height - curSegmentScrollView.height - curSegmentScrollView.mj_footer.height  // curSegment 到底的回弹
        let bounce = bounce0 || bounce1 || bounce2
        if bounce && decelerationBehavior != nil && springBehavior == nil {
            var target = CGPoint.zero
            if bounce0 {
                dynamicItem.center = outterScrollView.contentOffset
                let offset = pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height
                if offset < outterScrollView.height {
                    target = CGPoint.zero
                } else {
                    target = CGPoint(x: 0, y: offset - outterScrollView.height + 10)
                }
                _springScrollViewContentOffset(outterScrollView, target)
            } else if outterScrollView.contentOffset.y < 0 {
                dynamicItem.center = outterScrollView.contentOffset
                if outterScrollView.contentOffset.y < -outterScrollView.mj_header.height - UIScreen.statusBarMoreHeight - 20 {
                    target = CGPoint(x: 0, y: -outterScrollView.mj_header.height - UIScreen.statusBarMoreHeight)
                } else {
                    target = CGPoint.zero
                }
                _springScrollViewContentOffset(outterScrollView, target)
                print(" spring ------------------   if  ------------- \(NSStringFromCGPoint(target))")
            } else if curSegmentScrollView.contentOffset.y > curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height {
                dynamicItem.center = curSegmentScrollView.contentOffset
                /// MARK: 需要将footer 显示出来
                let offsetY = curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height
                target = CGPoint(x: 0, y: offsetY < 0 ? 0 : offsetY)
                _springScrollViewContentOffset(curSegmentScrollView, target)
                print(" spring ------------------   else  ------------- \(NSStringFromCGPoint(target))")
            }
        }
    }
    
    /// 处理回弹
    private func _springScrollViewContentOffset(_ scrollView: UIScrollView, _ point: CGPoint) {
        dynamicItem.center = scrollView.contentOffset
        animator.removeAllBehaviors()
        decelerationBehavior = nil
        springBehavior = nil
        let tmpSprintBehavior = UIAttachmentBehavior(item: dynamicItem, attachedToAnchor: point)
        tmpSprintBehavior.length = 0
        tmpSprintBehavior.damping = 1
        tmpSprintBehavior.frequency = 2
        tmpSprintBehavior.action = { [weak self] () in
            guard let weakSelf = self else { return }
            scrollView.contentOffset = weakSelf.dynamicItem.center
            if scrollView == weakSelf.outterScrollView && scrollView.contentOffset.y == 0 {
                weakSelf._updateSegmentScrollViewContentOffset(CGPoint.zero)
            }
        }
        animator.addBehavior(tmpSprintBehavior)
        springBehavior = tmpSprintBehavior
    }
    
    private func _rubberBandDistance(_ offset: CGFloat, _ dimission: CGFloat) -> CGFloat {
        let constant: CGFloat = 0.55
        let result = (constant * CGFloat(fabsf(Float(offset))) * dimission) / (dimission + constant * CGFloat(fabs(Float(offset))))
        return offset < 0.0 ? -result : result
    }

最后这种解决方案虽然能够解决上述的很多问题,并且也比较方便进行后期的UI扩展改变,但也不是没有存在问题,其中最主要也是最难的一个就是:在业务功能复杂的时候,需要涉及到很多计算,就是这个计算会花费比较多的时间。
秉承 Talk is cheap, Show me the Code附上Demo,如果觉得此种方案能够解决你在项目中也到的问题,也可star一下,亦或者下载我们的医联App,体验一番,此功能在首页 - 小组 - 小组推荐 - 点击其中任意一个小组即可查看

你可能感兴趣的:(iOS 多个UIScrollView UITableView嵌套解决方案)