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來處理,解決了上述問題,當然由於每個人的業務邏輯會存在很多的不同,無暫時無法寫出一個框架來適應所有業務邏輯的處理,但是這個解決方案在很大程度上可以根據自己的業務邏輯,自行修改代碼即可完成使用,在完成這個功能期間,我解決了如下問題,並且這些也許是你在實現時需要解決的問題:

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,體驗一番,此功能在首頁 - 小組 - 小組推薦 - 點擊其中任意一個小組即可查看

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章