• iOS_NestedScrollView(嵌套ScrollView)


    手势协议

    首先需要了解UIGestureRecognizerDelegate协议的这个方法:

    /// 是否同时相应这俩手势,默认返回 false
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
    
    • 1
    • 2
    • 3
    • 4

    当底部scrollView返回true时,添加在它上面的scrollView滑动时,它也可以滑动了。
    这时候两个scrollView都会滑动,我们可以在滑动回调里,根据当前的情况进行处理,实现想要的滑动规则了。


    滑动规则制定

    Tips:规则一定要提前确认好。

    实现抽屉效果如下:
    在这里插入图片描述

    下拉:内部列表拉到最顶部了,才放大headerView
    上拉:先把headerView缩到最小,再上滑内部列表


    实现

    1、层级关系

    层级关系

    • mainScrollView:添加在vc.view上,铺满。其顶部内边距contentInset.top等于header最大高度-最小高度 即 可滑动的高度。
    • tabContainerView:添加在mainScrollView上,但其originYheaderView的最小高度。
    • headerView: 添加在vc.view上,置顶,其高度根据mainScrollView.contentOffset.y计算出来,使其正好贴在tabContainerView上。

    注:这样布局的原因是:不需要频繁的修改headerViewtabContainerViewframe,只需要修改他们的高度就行。卡顿效果能明显减少。


    2、初始化视图

    private lazy var mainScrollView: MOMultiResponseScrollView = {
        let scroll = MOMultiResponseScrollView(frame: .zero)
        scroll.delegate = self
        scroll.bounces = false
        scroll.backgroundColor = .blue
        return scroll
    }()
    
    private lazy var headerView: UIView = {
        let view = UIView(frame: .zero)
        view.backgroundColor = .red
        return view
    }()
    
    private lazy var tabsContainerCtl: MOMultiTabContainerViewController = {
        let ctl = MOMultiTabContainerViewController(nibName: nil, bundle: nil)
        ctl.view.backgroundColor = .cyan
        return ctl
    }()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    • MOMultiResponseScrollView内部实现了UIGestureRecognizerDelegate,允许俩手势同时相应
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
    
    • 1
    • 2
    • 3

    在这里插入图片描述


    3、添加视图

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.mainScrollView)
        self.mainScrollView.addSubview(self.tabsContainerCtl.view)
        self.view.addSubview(self.headerView)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    4、布局

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        let viewSize = self.view.bounds.size
        let safeInset = self.view.safeAreaInsets
        let containerWidth = viewSize.width - safeInset.left - safeInset.right
        let containerHeight = viewSize.height - safeInset.top - safeInset.bottom
    
        let mainScrollView = self.mainScrollView
        let headerView = self.headerView
        let tabsContainerView = self.tabsContainerCtl.view
        
        /// 铺满
        mainScrollView.frame = CGRect(x: safeInset.left,
                                      y: safeInset.top,
                                      width: containerWidth,
                                      height: containerHeight)
        mainScrollView.contentSize = CGSize(width: containerWidth,
                                            height: containerHeight)
        /// 内边距为可滑动值
        let scrollTopInset = headerViewMaxHeight - headerViewMinHeight
        mainScrollView.contentInset = UIEdgeInsets(top: scrollTopInset,
                                                   left: 0.0,
                                                   bottom: 0.0,
                                                   right: 0.0)
        /// 高度根据偏移算出
        let headerHeight = headerViewMinHeight + abs(mainScrollView.contentOffset.y)
        headerView.frame = CGRect(x: safeInset.left,
                                  y: safeInset.top,
                                  width: containerWidth,
                                  height: headerHeight)
        /// 高度等于剩下的范围
        tabsContainerView?.frame = CGRect(x: 0.0,
                                          y: headerViewMinHeight,
                                          width: containerWidth,
                                          height: containerHeight - headerHeight)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    5、传递滑动回调

    将所有滑动回调都交由MOSubScrollExecutor处理:(把嵌套滑动规则集中在一个文件里,方便管理和复用)

    // MARK: - Private Methods - 主 ScrollView 的回调事件
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        self.scrollExecutor.mainScrollViewWillBeginDragging(scrollView)
    }
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        self.scrollExecutor.mainScrollViewDidScroll(scrollView)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    private lazy var tabsContainerCtl: MOMultiTabContainerViewController = {
        let ctl = MOMultiTabContainerViewController(nibName: nil, bundle: nil)
        /// 内部 ScrollView 的回调事件
        ctl.willBeginDragging = { [weak self] (scrollView: UIScrollView) in
            self?.scrollExecutor.subScrollWillBeginDragging(scrollView)
        }
        ctl.didScroll = { [weak self] (scrollView: UIScrollView) in
            self?.scrollExecutor.subScrollDidScroll(scrollView)
        }
        ctl.view.backgroundColor = .cyan
        return ctl
    }()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    6、处理滑动回调

    6.1、标记属性:

    /// 用于判断其最大最小状态
    private var mainScrollView: UIScrollView?
    /// 记录拖拽前的偏移,用于不可滑动状态时,重置偏移
    private var mainScrollOffsetBeforeDragging: CGPoint = .zero
    /// 是否处于可滑动状态
    private var mainScrollEnable: Bool
    
    /// 用于防重入
    private var currentSubScrollView: UIScrollView?
    /// 记录拖拽前的偏移,用于不可滑动状态时,重置偏移
    private var subScrollViewPreOffset: CGPoint = .zero
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    6.2、helper方法:

    /// 判断最大最小态:
    func headerIsMinState() -> Bool {
        return mainScrollView.contentOffset.y.isEqual(to: 0.0)
    }
    
    func headerIsMaxState() -> Bool {
        return mainScrollView.contentInset.top.isEqual(to: abs(mainScrollView.contentOffset.y))
    }
    
    /// 重置偏移的方法:
    /// 更新 scrollView 的 offset, 相同时跳过,防止极限情况死循环
    private func updateScrollView(_ scrollView: UIScrollView, _ offset: CGPoint) {
        if scrollView.contentOffset.equalTo(offset) {
            return
        }
        scrollView.contentOffset = offset;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    6.3、mainScrollView的滑动回调:

    public func mainScrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        self.mainScrollView = scrollView
        /// 记录拖拽前的偏移
        self.mainScrollOffsetBeforeDragging = scrollView.contentOffset
    }
    
    public func mainScrollViewDidScroll(_ scrollView: UIScrollView) {
        if self.mainScrollEnable {
            /// 需要重新布局,重新计算 headerView 和 containerView 的高度
            /// 触发 MONestedScrollViewController 的 viewDidLayoutSubviews 方法
            self.mainScrollSuperView?.setNeedsLayout()
            return
        }
        /// 不可滑动时,重置偏移
        self.updateScrollView(scrollView, self.mainScrollOffsetBeforeDragging)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    6.4、subScrollView的滑动回调:

    public func subScrollWillBeginDragging(_ scrollView: UIScrollView) {
        /// 切换tab时重置标记位
        if self.currentSubScrollView != nil &&
            !self.currentSubScrollView!.isEqual(scrollView) {
            self.mainScrollEnable = true
        }
        self.currentSubScrollView = scrollView
        self.subScrollViewPreOffset = scrollView.contentOffset
    }
    
    public func subScrollDidScroll(_ scrollView: UIScrollView) {
        /// 丢弃其他scrollView的回调(case: 刚拖拽完tabView,立马切换到webView,此时还会收到tabView的滑动回调)
        if !scrollView.isEqual(self.currentSubScrollView) {
            return
        }
        if scrollView.contentOffset.y.isEqual(to: self.subScrollViewPreOffset.y) {
            return
        }
        let pullDown: Bool = scrollView.contentOffset.y < self.subScrollViewPreOffset.y
        if pullDown {
            self.handlePullDown(scrollView) /// 处理下拉
        } else {
            self.handlePullUp(scrollView)   /// 处理上拉
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    这里也有用手势的速度来判断上拉 or 下拉的,但是在手离开后的减速滑动时速度就为0了,所以这里没有用velocity


    6.5、处理subScrollView下拉:

    /// 下拉: list 先拉到顶,再放大 headerView
    func handlePullDown(_ scrollView: UIScrollView) {    
        /// 还没拉到顶 或 headerView已是最大状态,允许subScrollView滑动,不做处理
        if scrollView.contentOffset.y > 0 ||
            self.headerIsMaxState() {
            self.mainScrollEnable = false
            self.subScrollViewPreOffset = scrollView.contentOffset
        } else {
            /// 拉到顶部了 且 播放器需要放大
            self.mainScrollEnable = true
            
            /// 重置偏移(放大player时,不需要下拉刷新效果)
            self.updateScrollView(scrollView, .zero)
            self.subScrollViewPreOffset = .zero
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    6.6、处理subScrollView上拉:

    /// pullUp 上拉: 先缩小播放器,再拉 list
    func handlePullUp(_ scrollView: UIScrollView) {    
        /// headerView 已是最小状态,允许subScrollView滑动,不做处理
        if self.headerIsMinState() {
            self.mainScrollEnable = false
            self.subScrollViewPreOffset = scrollView.contentOffset
            return
        }
        self.mainScrollEnable = true
        if scrollView.contentOffset.y <= 0 { /// 忽略下拉刷新的回弹(否则死循环)
            return
        }
        print("headerView缩小时,重置subScrollView偏移")
        self.updateScrollView(scrollView, self.subScrollViewPreOffset)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    CGFloat判等

    都知道1.1 * 1.1 = 1.21,但在代码里确不一定:

    let first = 1.1
    let second = 1.1
    let result = first * second
    let floatEqual = result == 1.21
    print("\(first) * \(second) = \(result) is \(floatEqual)")
    
    // log:
    1.1 * 1.1 = 1.2100000000000002 is false
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    由于UIScrollViewcontentOffset的精确度问题,所以在计算或判等时需要注意了。

    这里有两种实现方案:

    • 1、contentInset.top 取整
    • 2、使用FLT_EPSILONdoubleDBL_EPSILON
    let equal = fabs(streamRatio - QNBUALiveShowPlayerDefaultAspectRatio) < FLT_EPSILON;
    
    • 1
    • 3、contentOffset.y 判等时 使用 NSDecimalNumber
    let firstNum = NSDecimalNumber(string: "1.1")
    let secondNum = NSDecimalNumber(string: "1.1")
    let resultNum = firstNum.multiplying(by: secondNum)
    let numberEqual = resultNum.compare(NSDecimalNumber(string: "1.21")) == .orderedSame
    print("\(firstNum) * \(secondNum) = \(resultNum) is \(numberEqual)")
    
    // log:
    1.1 * 1.1 = 1.21 is true
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    github demo


    参考:

    Strange problem comparing floats in objective-C

  • 相关阅读:
    TypeSprict -- 基础类型
    leetcode 21
    1.机器学习基本概念学习笔记
    【Mysql主从配置方法---单主从】
    【Mysql】EXPLAIN
    java毕业设计维保管理系统mybatis+源码+调试部署+系统+数据库+lw
    华为数通方向HCIP-DataCom H12-831题库(多选题:41-60)
    共享日志。 vCorfu: A Cloud-Scale Object Store on a Shared Log
    pinctrl
    AUTOSAR跨网络的CAN报文路由的实现
  • 原文地址:https://blog.csdn.net/Margaret_MO/article/details/126445212