一. 背景
首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示:
二. 分析
从花小猪首页交互我们可以分析出如下信息:
- 首页卡片分为三段式,底部、中间、顶部。
- 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡片滚动视图依然可以滚动。
- 当首页卡片在中间,可以先外部视图整体往上或者往下滑动,往下滑动到底部后,禁止滑动,滑动到顶部,内部视图卡片头部悬浮,内部滚动视图可以滚动。
- 当首页卡片在顶部,可以拖动卡片外部视图整体下滑,也可以通过内部视图向下滚动,滚动到跟内部头部底部持平,变成整体一起向下滑动。而当内部滚动视图向上滚动,内部卡片头部悬浮固定。
- 首页卡片滑动过程中,如果停在中间位置,依据卡片停止位置,距离底部、中间、顶部位置远近,向距离近的一端,直接移动到相应位置,比如移动到中间和顶部位置之间,如果距离顶部近,则直接移动到顶部。
- 当首页卡片在底部,上滑速度很快超过一定值,就直接到顶部。同样在顶部下滑也一样。
- 当首页卡片在顶部,内部滚动视图快速下滑,下滑到跟卡片头部分开,产生弹簧效果,不直接一起下滑,但其他部分如果慢慢滑动,下滑到跟卡片头部即将分开时,变成整体一起下滑。
三. 实现
理清了首页卡片的滑动交互细节之后,我们开始设计对应类和相关职责。
从上面结构图我们可以看出,主要分为三部分
- 卡片外层容器
externalScrollView
,限定为UIScrollView
类型。 - 卡片内头部
insideHeaderView
,限定为UIView
类型。 - 卡片内滚动视图
insideTableView
,由于滚动视图所以insideTableView
一定是UIScrollView
类型,为了复用,这里我们限定为UITableView
这里其实我们不关心头部视图insideHeaderView
,因为内部头部视图insideHeaderView
和内部滚动视图insideTableView
之间的关系是固定,就是内部滚动视图insideTableView
一直在头部视图 insideHeaderView
下面。
同样我们也不关心滚动视图insideTableView
里面的内容,我们需要处理的就是卡片外层容器externalScrollView
和内部滚动视图insideTableView
之间交互关系。
因为所有这种类型交互处理逻辑是一致的,因此我们抽出FJFScrollDragHelper
类。
- 首先我们来认识下滚动辅助类
FJFScrollDragHelper
相关属性
/// scrollView 显示高度
public var scrollViewHeight: CGFloat = kScreenH
/// 限制的高度(超过这个高度可以滚动)
public var kScrollLimitHeight: CGFloat = kScreenH * 0.51
/// 滑动初始速度(大于该速度直接滑动到顶部或底部)
public var slideInitSpeedLimit: CGFloat = 3500.0
/// 当前 滚动 视图 位置
public var curScrollViewPositionType: FJFScrollViewPositionType = .middle
/// 最高 展示 高度
public var topShowHeight: CGFloat = 0
/// 中间 展示 高度
public var middleShowHeight: CGFloat = 0
/// 最低 展示 高度
public var lowestShowHeight: CGFloat = 0
/// 当前 滚动 视图 类型
private var currentScrollType: FJFCurrentScrollViewType = .externalView
/// 外部 滚动 view
public weak var externalScrollView: UIScrollView?
/// 内部 滚动 view
public weak var insideScrollView: UIScrollView?
/// 拖动 scrollView 回调
public var panScrollViewBlock: (() -> Void)?
/// 移动到顶部
public var goToTopPosiionBlock: (() -> Void)?
/// 移动到 底部 默认位置
public var goToLowestPosiionBlock: (() -> Void)?
/// 移动到 中间 默认位置
public var goToMiddlePosiionBlock: (() -> Void)?
我们看到FJFScrollDragHelper
内部弱引用了外部滚动视图externalScrollView
和内部滚动视图insideScrollView
。
-
关联对象,并给外部
externalScrollView
添加滑动手势
/// 添加 滑动 手势 到 外部滚动视图
public func addPanGestureRecognizer(externalScrollView: UIScrollView){
let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
externalScrollView.addGestureRecognizer(panRecoginer)
self.externalScrollView = externalScrollView
}
-
处理滑动手势
// MARK: - Actions
/// tableView 手势
@objc
private func panScrollViewHandle(pan: UIPanGestureRecognizer) {
/// 当前 滚动 内部视图 不响应拖动手势
if self.currentScrollType == .insideView {
return
}
guard let contentScrollView = self.externalScrollView else {
return
}
let translationPoint = pan.translation(in: contentScrollView.superview)
// contentScrollView.top 视图距离顶部的距离
contentScrollView.y += translationPoint.y
/// contentScrollView 移动到顶部
let distanceToTopH = self.getTopPositionToTopDistance()
if contentScrollView.y < distanceToTopH {
contentScrollView.y = distanceToTopH
self.curScrollViewPositionType = .top
self.currentScrollType = .all
}
/// 视图在底部时距离顶部的距离
let distanceToBottomH = self.getBottomPositionToTopDistance()
if contentScrollView.y > distanceToBottomH {
contentScrollView.y = distanceToBottomH
self.curScrollViewPositionType = .bottom
self.currentScrollType = .externalView
}
/// 拖动 回调 用来 更新 遮罩
self.panScrollViewBlock?()
// 在滑动手势结束时判断滑动视图距离顶部的距离是否超过了屏幕的一半,如果超过了一半就往下滑到底部
// 如果小于一半就往上滑到顶部
if pan.state == .ended || pan.state == .cancelled {
// 处理手势滑动时,根据滑动速度快速响应上下位置
let velocity = pan.velocity(in: contentScrollView)
let largeSpeed = self.slideInitSpeedLimit
/// 超过 最大 力度
if velocity.y < -largeSpeed {
gotoTheTopPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y < 0, velocity.y > -largeSpeed {
if self.curScrollViewPositionType == .bottom {
gotoMiddlePosition()
} else {
gotoTheTopPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > largeSpeed {
gotoLowestPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > 0, velocity.y < largeSpeed {
if self.curScrollViewPositionType == .top {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
}
let scrollViewDistanceToTop = kScreenH - contentScrollView.top
let topAndMiddleMeanValue = (self.topShowHeight + self.middleShowHeight) / 2.0
let middleAndBottomMeanValue = (self.middleShowHeight + self.lowestShowHeight) / 2.0
if scrollViewDistanceToTop >= topAndMiddleMeanValue {
gotoTheTopPosition()
} else if scrollViewDistanceToTop < topAndMiddleMeanValue,
scrollViewDistanceToTop > middleAndBottomMeanValue {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
}
处理滑动手势需要当前视图滚动类型currentScrollType
和卡片当前所处的位置curScrollViewPositionType
来分别进行判断。
/// 当前 滚动 视图 类型
public enum FJFCurrentScrollViewType {
case externalView /// 外部 视图
case insideView /// 内部 视图
case all /// 内部外部都可以响应
}
/// 当前 滚动 视图 位置 属性
public enum FJFScrollViewPositionType {
case top /// 顶部
case middle /// 中间
case bottom /// 底部
}
如下是对应的判断逻辑:
A. 在底部
/// 回到 底部 位置
private func gotoLowestPosition() {
self.curScrollViewPositionType = .bottom
self.goToLowestPosiionBlock?()
}
private func gotoLowestPosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
}
只能滚动外部视图,内部滚动视图偏移量是0
.
B. 在中间
/// 回到 中间 位置
private func gotoMiddlePosition() {
self.curScrollViewPositionType = .middle
self.goToMiddlePosiionBlock?()
}
private func gotoMiddlePosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
}
只能滚动外部视图,内部滚动视图偏移量是0
.
C. 在顶部
- 开始滚动判断:
/// 更新 当前 滚动类型 当开始拖动 (当在顶部,开始滑动时候,判断当前滑动的对象是内部滚动视图,还是外部滚动视图)
public func updateCurrentScrollTypeWhenBeginDragging(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
}
}
- 滚动过程中判断
/// 更新 滚动 类型 当滚动的时候,并返回是否立即停止滚动
public func isNeedToStopScrollAndUpdateScrollType(scrollView: UIScrollView) -> Bool {
if scrollView == self.insideScrollView {
/// 当前滚动的是外部视图
if self.currentScrollType == .externalView {
self.insideScrollView?.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
return true
}
if self.curScrollViewPositionType == .top {
if self.currentScrollType == .all { /// 在顶部的时候,外部和内部视图都可以滑动,判断当内部滚动视图视图的位置,如果滚动到底部了,则变为外部滚动视图跟着滑动,内部滚动视图不动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
} else if scrollView.isDecelerating == false,
self.currentScrollType == .insideView { /// 在顶部的时候,当内部滚动视图,慢慢滑动到底部,变成整个外部滚动视图跟着滑动下来,内部滚动视图不再滑动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
}
}
}
}
return false
}
- 滚动结束判断
/// 当在顶部,滚动停止时候 更新 当前 滚动类型 ,如果当前内部滚动视图,已经滚动到最底部,
/// 则只能滚动最外层滚动视图,如果内部滚动视图没有滚动到最底部,则外部和内部视图都可以滚动
public func updateCurrentScrollTypeWhenScrollEnd(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .all
}
}
}
以上就是具体滚动判断相关处理逻辑,对应实现效果如下。