iOS_NestedScrollView(嵌套ScrollView)

2022-09-08 17:43:01 浏览数 (1)

手势协议

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

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

当底部scrollView返回true时,添加在它上面的scrollView滑动时,它也可以滑动了。

这时候两个scrollView都会滑动,我们可以在滑动回调里,根据当前的情况进行处理,实现想要的滑动规则了。


滑动规则制定

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

实现抽屉效果如下:

MONestedScrollView.gifMONestedScrollView.gif

下拉:内部列表拉到最顶部了,才放大headerView

上拉:先把headerView缩到最小,再上滑内部列表


实现

1、层级关系

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

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


2、初始化视图

代码语言:swift复制
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 }undefined
  • MOMultiTabContainerViewController内部是一个scrollView,添加多个subScrollView,结构如下:(详情可见MOMultiTabContainerViewController.swift)
icon-tabContiner@2x.pngicon-tabContiner@2x.png

3、添加视图

代码语言:swift复制
override func viewDidLoad() {
    super.viewDidLoad()
    self.view.addSubview(self.mainScrollView)
    self.mainScrollView.addSubview(self.tabsContainerCtl.view)
    self.view.addSubview(self.headerView)
}

4、布局

代码语言:swift复制
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处理:(把嵌套滑动规则集中在一个文件里,方便管理和复用)

代码语言:swift复制
// MARK: - Private Methods - 主 ScrollView 的回调事件
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    self.scrollExecutor.mainScrollViewWillBeginDragging(scrollView)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    self.scrollExecutor.mainScrollViewDidScroll(scrollView)
}
代码语言:swift复制
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、标记属性:

代码语言:swift复制
/// 用于判断其最大最小状态
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方法:

代码语言:swift复制
/// 判断最大最小态:
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的滑动回调:

代码语言:swift复制
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的滑动回调:

代码语言:swift复制
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下拉:

代码语言:swift复制
/// 下拉: 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上拉:

代码语言:swift复制
/// 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,但在代码里确不一定:

代码语言:swift复制
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 取整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
  • 2、使用`FLT_EPSILON`

```swift

let equal = fabs(streamRatio - QNBUALiveShowPlayerDefaultAspectRatio) < FLT_EPSILON;

```

  • 3、`contentOffset.y` 判等时 使用 `NSDecimalNumber`

github demo


参考:

Strange problem comparing floats in objective-C

0 人点赞