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,体验一番,此功能在首页 - 小组 - 小组推荐 - 点击其中任意一个小组即可查看

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 155,457评论 4 357
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 65,943评论 1 285
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 105,327评论 0 236
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,307评论 0 201
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,630评论 3 283
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,114评论 1 202
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,514评论 2 305
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,227评论 0 193
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,859评论 1 234
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,186评论 2 238
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,741评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,087评论 2 248
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,614评论 3 228
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,926评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,630评论 0 190
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,117评论 2 261
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,076评论 2 257

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,529评论 25 707
  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    X先生_未知数的X阅读 15,932评论 3 118
  • 我在等你 等你在阳光洒满清露的晨 一起看远远的桃红闪烁在青青的草尖上 一起看青青的柳绿莹亮在五月的树梢下 我在等你...
    幽幽米蘭阅读 319评论 1 6
  • 生活中,这四种人活得最累: 1、想太多的人最累。 别人说的话,你别往心里去; 失去的情,你总是去回忆过去的事情,你...
    人生本是一场旅行阅读 439评论 0 0
  • 存在即有理。这句话倒底是指所有存在的事物都有它出现的原因,还是指他的存在本身是有道理和意义的。一个因,一个果,也是...
    Joanne_GW阅读 882评论 0 48