iOS_NestedScrollView(嵌套ScrollView)

手势协议

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

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

当底部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
}()

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

在这里插入图片描述


3、添加视图

override func viewDidLoad() {
    super.viewDidLoad()
    self.view.addSubview(self.mainScrollView)
    self.mainScrollView.addSubview(self.tabsContainerCtl.view)
    self.view.addSubview(self.headerView)
}

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)
}

5、传递滑动回调

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

// MARK: - Private Methods - 主 ScrollView 的回调事件
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    self.scrollExecutor.mainScrollViewWillBeginDragging(scrollView)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    self.scrollExecutor.mainScrollViewDidScroll(scrollView)
}
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
}()

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

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;
}

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)
}

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)   /// 处理上拉
    }
}

这里也有用手势的速度来判断上拉 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
    }
}

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)
}

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

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

这里有两种实现方案:

  • 1、contentInset.top 取整
  • 2、使用FLT_EPSILONdoubleDBL_EPSILON
let equal = fabs(streamRatio - QNBUALiveShowPlayerDefaultAspectRatio) < FLT_EPSILON;
  • 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

github demo


参考:

Strange problem comparing floats in objective-C

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

莫小言mo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值