要实现这种效果:
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
本文主要参考: 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
}
}
复制代码