阅读 1548

iOS 嵌套滚动界面实现思路: UIScrollView 上面放 UIPageViewController

要实现这种效果:

UIScrollView ( base scroll ) 上面放 UIPageViewController,

UIPageViewController 有很多 Page, 一个 page 是一个 UIViewController,

page 上面可以放 UIScrollView ( child scroll )

需要

base scroll 可以滑, 他的内容高度 = 子滚动视图的内容高度 + 子滚动视图之上内容的高度

base scroll 的 content size's height = child scroll content size's height + size's height of view above child scroll

截屏2021-04-26 上午9.35.33.png

本文主要参考: bawn/Aquaman

实现的思路

类似的效果,上面的思路挺直观

不方便实现

试考虑下面场景:

上面放的 UIPageViewController,从一个 page 滑到另一个 page,

base scroll 的 content size's height = 子滚动视图 1 的内容高度 + 固定高度 ( header + menu )

变成

base scroll 的 content size's height = 子滚动视图 2 的内容高度 + 固定高度 ( header + menu )

可能有抖动

UI 就是障眼法

UIScrollView ( mainScrollView , 竖着滑 ) 上面放 UIScrollView ( contentScrollView 横着滚 ),

contentScrollView 上面放 contentStackView ( UIStackView ),

contentStackView 有很多 Page, 一个 page 就是,上面提到的 UIScrollView ( child scroll )

UIScrollView ( contentScrollView 横着滚 ), 其 isPagingEnabled = true

这样就模拟了,UIPageViewController 的滑动翻页效果

实现细节

lazy public private(set) var mainScrollView: AquaMainScrollView = {
        let scrollView = AquaMainScrollView()
        scrollView.delegate = self
        scrollView.am_isCanScroll = true
        return scrollView
    }()

复制代码

手势兼容,滚动区域控制:

  • 如果不加这一段,child scroll 滚动无效, 滚的就是 mainScrollView ( 竖着滑 )

  • 加了这一段,child scroll 上面,其独立滚动,

child scroll 上面固定高度的共用视图, 滚的是 mainScrollView



public class AquaMainScrollView: UIScrollView, UIGestureRecognizerDelegate {
    
    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {        
        // 子视图,不是 UIScrollView, 不用考虑
        guard let scrollView = gestureRecognizer.view as? UIScrollView else {
            return false
        }
        
        let offsetY = headerViewHeight + menuViewHeight
        let contentSize = scrollView.contentSize
        let targetRect = CGRect(x: 0,
                                y: offsetY - UIApplication.shared.statusBarFrame.height,
                                width: contentSize.width,
                                height: contentSize.height - offsetY)
        
        let currentPoint = gestureRecognizer.location(in: self)
        // 如果手势,点击在子视图区域
        // 允许子视图区域,独立滚动
        
        // 如果手势,点击在子视图上面的区域
        // 子视图的滚动手势,被屏蔽
        return targetRect.contains(currentPoint)
    }
}


复制代码

横向滚动

负责横向滚动的 UIScrollView

lazy internal var contentScrollView: UIScrollView = {
      let scrollView = UIScrollView()
      scrollView.delegate = self
      scrollView.bounces = false
      // 分页,就是 page 效果
      scrollView.isPagingEnabled = true
      // ...
      return scrollView
  }()

复制代码

手动横向滚动,翻页


extension AquamanPageViewController: UIScrollViewDelegate {
  
  public func scrollViewDidScroll(_ scrollView: UIScrollView) {
      
      if scrollView == mainScrollView {
          // 竖向
          // ...
      } else {
          // 横向
          // menu bar
          // 回调
          pageController(self, contentScrollViewDidScroll: scrollView)
          // front content page
          layoutChildViewControlls()
      }
  }
  
}
复制代码

翻页逻辑


internal func layoutChildViewControlls() {
       // 处理,每一页
       countArray.forEach { (index) in
           let containView = containViews[index]
           // 判断,要不要出现
           let isDisplayingInScreen = containView.displaying(in: view, containView: contentScrollView)
           // 要出现,就展示
           // 不要出现,就隐藏
           isDisplayingInScreen ? showChildViewContoller(at: index) : removeChildViewController(at: index)
       }
   }
复制代码

要出现,就展示

internal func showChildViewContoller(at index: Int) {
       // 状态检查
       // ...
       
       let viewController = // ...
       
       guard let targetViewController = viewController else {
           return
       }
       // 更新 UI 
       addChild(targetViewController)
       targetViewController.beginAppearanceTransition(true, animated: false)
       containView.addSubview(targetViewController.view)
       targetViewController.view.translatesAutoresizingMaskIntoConstraints = false
       // 更新约束,
       // 这里就是上文,提到的 
       // 从一个 page 滑到另一个 page, 有一个 content size 的改变
       NSLayoutConstraint.activate([
           targetViewController.view.leadingAnchor.constraint(equalTo: containView.leadingAnchor),
           targetViewController.view.trailingAnchor.constraint(equalTo: containView.trailingAnchor),
           targetViewController.view.bottomAnchor.constraint(equalTo: containView.bottomAnchor),
           targetViewController.view.topAnchor.constraint(equalTo: containView.topAnchor),
           ])
       targetViewController.endAppearanceTransition()
       targetViewController.didMove(toParent: self)
       targetViewController.view.layoutSubviews()
       // 状态维护
       containView.viewController = targetViewController

       let scrollView = targetViewController.aquamanChildScrollView()
       scrollView.am_originOffset = scrollView.contentOffset
       // ...  
     
   }
复制代码
菜单点击触发,横向翻页
    public func setSelect(index: Int, animation: Bool) {
        let offset = CGPoint(x: contentScrollView.bounds.width * CGFloat(index),
                             y: contentScrollView.contentOffset.y)
                             
        // 触发横向滚动,带入之前的滚动逻辑
        contentScrollView.setContentOffset(offset, animated: animation)
        if animation == false {
            // 启动竖向滚动的 KVO
            contentScrollViewDidEndScroll(contentScrollView)
        }
    }
复制代码

状态保持: 保留用户的操作和状态

设计的,提供子视图的方法

override func pageController(_ pageController: AquamanPageViewController, viewControllerAt index: Int) -> AquamanController{
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        if index == 0 {
            return storyboard.instantiateViewController(withIdentifier: "SupermanViewController") as! SupermanViewController
        } else  {
            // return ... 
        } 
    }
复制代码

这里使用了,状态保持

如果没有状态保持,每次翻页,就是重新创建一页

基础设施

便利方法

extension NSCache where KeyType == NSString, ObjectType == UIViewController {
    
    subscript(index: Int) -> UIViewController? {
        get {
            return object(forKey: "\(index)" as NSString)
        }
        set {
            guard let newValue = newValue
                , self[index] != newValue else {
                return
            }
            setObject(newValue, forKey: "\(index)" as NSString)
        }
    }
}

复制代码

通过 NSCache, 把已经实例过的方法,缓存起来

private let memoryCache = NSCache<NSString, UIViewController>()
复制代码

存:

上文提高的, func layoutChildViewControlls(),

不出现,就隐藏

    private func removeChildViewController(at index: Int) {
        // 状态检查
        // ...
        
        let containView = containViews[index]
        guard containView.isEmpty == false
            , let viewController = containView.viewController else {
            return
        }
        viewController.clearFromParent()
        if memoryCache[index] == nil {
            // 回调
            pageController(self, willCache: viewController, forItemAt: index)
            // 没缓存,就缓存下
            memoryCache[index] = viewController
        }
    }

复制代码

取:

上文提高的, func layoutChildViewControlls(),

要出现,就展示

internal func showChildViewContoller(at index: Int) {
        // 状态检查
        // ...
        
        // 有缓存,就用缓存,
        // 没有缓存,就用新建
        let cachedViewContoller = memoryCache[index] as? AquamanController
        let viewController = cachedViewContoller != nil ? cachedViewContoller : pageController(self, viewControllerAt: index)
        
        guard let targetViewController = viewController else {
            return
        }
        
        // 更新 UI 
        // ...
    }


复制代码

效果增强

横向滚动,就没有竖向滚动

 public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        if scrollView == contentScrollView {
            // 开始横向拖动,
            // 禁止竖向滚动功能
            mainScrollView.isScrollEnabled = false
        }
    }

    
    public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if scrollView == contentScrollView {
            // 结束横向拖动,
            // 激活竖向滚动功能
            mainScrollView.isScrollEnabled = true
            if decelerate == false {
                contentScrollViewDidEndScroll(contentScrollView)
            }
        }
    }
复制代码

菜单顶部吸附,效果

代理,做一部分:


public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        if scrollView == mainScrollView {
            // 竖向
            let offsetY = scrollView.contentOffset.y
            if offsetY >= sillValue {
                // 不能滚
                // 吸附效果,
                // 就是限定 UIScrollView 的 contentOffset
                scrollView.contentOffset = CGPoint(x: 0, y: sillValue)
                currentChildScrollView?.am_isCanScroll = true
                scrollView.am_isCanScroll = false
                pageController(attach: self, menuView: true)
            } else {
                // 判断,能不能滚动
                let negScroll = (scrollView.am_isCanScroll == false)
                pageController(attach: self, menuView: negScroll)
                if negScroll{
                    // 不能滚
                    // 吸附效果,
                    // 就是限定 UIScrollView 的 contentOffset
                    scrollView.contentOffset = CGPoint(x: 0, y: sillValue)
                }
            }
        } else {
            // 横向
            // ...
        }
    }
复制代码

KVO, 做一个补充:


internal func didDisplayViewController(at index: Int) {
        // 状态检查
        // ...
        let containView = containViews[index]
        currentViewController = containView.viewController
        currentChildScrollView = currentViewController?.aquamanChildScrollView()
        currentIndex = index
        
        childScrollViewObservation?.invalidate()
        // 如果当前,子视图,是 UIScrollView,
        // 就做一个观测 KVO
        let keyValueObservation = currentChildScrollView?.observe(\.contentOffset, options: [.new, .old], changeHandler: { [weak self] (scrollView, change) in
            guard let self = self, change.newValue != change.oldValue else {
                return
            }
            self.childScrollView(didScroll: scrollView)
        })
        childScrollViewObservation = keyValueObservation
        // 回调
        // ...
    }
复制代码

KVO 观测


internal func childScrollView(didScroll scrollView: UIScrollView){
        // 记录的初始偏移量
        let scrollOffset = scrollView.am_originOffset.val
        
        let offsetY = scrollView.contentOffset.y
        
        if scrollView.am_isCanScroll == false {
            scrollView.contentOffset = scrollOffset
        }
        else if offsetY <= scrollOffset.y {
            scrollView.contentOffset = scrollOffset
            scrollView.am_isCanScroll = false
            // 重新激活,父滚动视图的可滚动
            // 即,取消顶部吸附效果
            mainScrollView.am_isCanScroll = true
        }
    }
复制代码

github repo demo

文章分类
iOS
文章标签