一. 背景
首頁改版,想要做一個(gè)類似花小豬首頁滑動(dòng)效果,具體如下所示:

二. 分析
從花小豬首頁交互我們可以分析出如下信息:
- 首頁卡片分為三段式,底部、中間、頂部。
- 當(dāng)首頁卡片在底部,只能先外部視圖整體往上滑動(dòng),滑動(dòng)到頂部后,內(nèi)部卡片頭部懸浮,內(nèi)部卡片滾動(dòng)視圖依然可以滾動(dòng)。
- 當(dāng)首頁卡片在中間,可以先外部視圖整體往上或者往下滑動(dòng),往下滑動(dòng)到底部后,禁止滑動(dòng),滑動(dòng)到頂部,內(nèi)部視圖卡片頭部懸浮,內(nèi)部滾動(dòng)視圖可以滾動(dòng)。
- 當(dāng)首頁卡片在頂部,可以拖動(dòng)卡片外部視圖整體下滑,也可以通過內(nèi)部視圖向下滾動(dòng),滾動(dòng)到跟內(nèi)部頭部底部持平,變成整體一起向下滑動(dòng)。而當(dāng)內(nèi)部滾動(dòng)視圖向上滾動(dòng),內(nèi)部卡片頭部懸浮固定。
- 首頁卡片滑動(dòng)過程中,如果停在中間位置,依據(jù)卡片停止位置,距離底部、中間、頂部位置遠(yuǎn)近,向距離近的一端,直接移動(dòng)到相應(yīng)位置,比如移動(dòng)到中間和頂部位置之間,如果距離頂部近,則直接移動(dòng)到頂部。
- 當(dāng)首頁卡片在底部,上滑速度很快超過一定值,就直接到頂部。同樣在頂部下滑也一樣。
- 當(dāng)首頁卡片在頂部,內(nèi)部滾動(dòng)視圖快速下滑,下滑到跟卡片頭部分開,產(chǎn)生彈簧效果,不直接一起下滑,但其他部分如果慢慢滑動(dòng),下滑到跟卡片頭部即將分開時(shí),變成整體一起下滑。
三. 實(shí)現(xiàn)
理清了首頁卡片的滑動(dòng)交互細(xì)節(jié)之后,我們開始設(shè)計(jì)對(duì)應(yīng)類和相關(guān)職責(zé)。

從上面結(jié)構(gòu)圖我們可以看出,主要分為三部分
- 卡片外層容器
externalScrollView,限定為UIScrollView類型。 - 卡片內(nèi)頭部
insideHeaderView,限定為UIView類型。 - 卡片內(nèi)滾動(dòng)視圖
insideTableView,由于滾動(dòng)視圖所以insideTableView一定是UIScrollView類型,為了復(fù)用,這里我們限定為UITableView
這里其實(shí)我們不關(guān)心頭部視圖insideHeaderView,因?yàn)閮?nèi)部頭部視圖insideHeaderView和內(nèi)部滾動(dòng)視圖insideTableView之間的關(guān)系是固定,就是內(nèi)部滾動(dòng)視圖insideTableView一直在頭部視圖 insideHeaderView下面。
同樣我們也不關(guān)心滾動(dòng)視圖insideTableView里面的內(nèi)容,我們需要處理的就是卡片外層容器externalScrollView和內(nèi)部滾動(dòng)視圖insideTableView之間交互關(guān)系。
因?yàn)樗羞@種類型交互處理邏輯是一致的,因此我們抽出FJFScrollDragHelper類。
- 首先我們來認(rèn)識(shí)下滾動(dòng)輔助類
FJFScrollDragHelper相關(guān)屬性
/// scrollView 顯示高度
public var scrollViewHeight: CGFloat = kScreenH
/// 限制的高度(超過這個(gè)高度可以滾動(dòng))
public var kScrollLimitHeight: CGFloat = kScreenH * 0.51
/// 滑動(dòng)初始速度(大于該速度直接滑動(dòng)到頂部或底部)
public var slideInitSpeedLimit: CGFloat = 3500.0
/// 當(dāng)前 滾動(dòng) 視圖 位置
public var curScrollViewPositionType: FJFScrollViewPositionType = .middle
/// 最高 展示 高度
public var topShowHeight: CGFloat = 0
/// 中間 展示 高度
public var middleShowHeight: CGFloat = 0
/// 最低 展示 高度
public var lowestShowHeight: CGFloat = 0
/// 當(dāng)前 滾動(dòng) 視圖 類型
private var currentScrollType: FJFCurrentScrollViewType = .externalView
/// 外部 滾動(dòng) view
public weak var externalScrollView: UIScrollView?
/// 內(nèi)部 滾動(dòng) view
public weak var insideScrollView: UIScrollView?
/// 拖動(dòng) scrollView 回調(diào)
public var panScrollViewBlock: (() -> Void)?
/// 移動(dòng)到頂部
public var goToTopPosiionBlock: (() -> Void)?
/// 移動(dòng)到 底部 默認(rèn)位置
public var goToLowestPosiionBlock: (() -> Void)?
/// 移動(dòng)到 中間 默認(rèn)位置
public var goToMiddlePosiionBlock: (() -> Void)?
我們看到FJFScrollDragHelper內(nèi)部弱引用了外部滾動(dòng)視圖externalScrollView和內(nèi)部滾動(dòng)視圖insideScrollView。
-
關(guān)聯(lián)對(duì)象,并給外部
externalScrollView添加滑動(dòng)手勢
/// 添加 滑動(dòng) 手勢 到 外部滾動(dòng)視圖
public func addPanGestureRecognizer(externalScrollView: UIScrollView){
let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
externalScrollView.addGestureRecognizer(panRecoginer)
self.externalScrollView = externalScrollView
}
-
處理滑動(dòng)手勢
// MARK: - Actions
/// tableView 手勢
@objc
private func panScrollViewHandle(pan: UIPanGestureRecognizer) {
/// 當(dāng)前 滾動(dòng) 內(nèi)部視圖 不響應(yīng)拖動(dòng)手勢
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 移動(dòng)到頂部
let distanceToTopH = self.getTopPositionToTopDistance()
if contentScrollView.y < distanceToTopH {
contentScrollView.y = distanceToTopH
self.curScrollViewPositionType = .top
self.currentScrollType = .all
}
/// 視圖在底部時(shí)距離頂部的距離
let distanceToBottomH = self.getBottomPositionToTopDistance()
if contentScrollView.y > distanceToBottomH {
contentScrollView.y = distanceToBottomH
self.curScrollViewPositionType = .bottom
self.currentScrollType = .externalView
}
/// 拖動(dòng) 回調(diào) 用來 更新 遮罩
self.panScrollViewBlock?()
// 在滑動(dòng)手勢結(jié)束時(shí)判斷滑動(dòng)視圖距離頂部的距離是否超過了屏幕的一半,如果超過了一半就往下滑到底部
// 如果小于一半就往上滑到頂部
if pan.state == .ended || pan.state == .cancelled {
// 處理手勢滑動(dòng)時(shí),根據(jù)滑動(dòng)速度快速響應(yīng)上下位置
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)
}
處理滑動(dòng)手勢需要當(dāng)前視圖滾動(dòng)類型currentScrollType和卡片當(dāng)前所處的位置curScrollViewPositionType來分別進(jìn)行判斷。
/// 當(dāng)前 滾動(dòng) 視圖 類型
public enum FJFCurrentScrollViewType {
case externalView /// 外部 視圖
case insideView /// 內(nèi)部 視圖
case all /// 內(nèi)部外部都可以響應(yīng)
}
/// 當(dāng)前 滾動(dòng) 視圖 位置 屬性
public enum FJFScrollViewPositionType {
case top /// 頂部
case middle /// 中間
case bottom /// 底部
}
如下是對(duì)應(yīng)的判斷邏輯:

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()
}
}
只能滾動(dòng)外部視圖,內(nèi)部滾動(dòng)視圖偏移量是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()
}
}
只能滾動(dòng)外部視圖,內(nèi)部滾動(dòng)視圖偏移量是0.
C. 在頂部
- 開始滾動(dòng)判斷:
/// 更新 當(dāng)前 滾動(dòng)類型 當(dāng)開始拖動(dòng) (當(dāng)在頂部,開始滑動(dòng)時(shí)候,判斷當(dāng)前滑動(dòng)的對(duì)象是內(nèi)部滾動(dòng)視圖,還是外部滾動(dòng)視圖)
public func updateCurrentScrollTypeWhenBeginDragging(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
}
}
- 滾動(dòng)過程中判斷
/// 更新 滾動(dòng) 類型 當(dāng)滾動(dòng)的時(shí)候,并返回是否立即停止?jié)L動(dòng)
public func isNeedToStopScrollAndUpdateScrollType(scrollView: UIScrollView) -> Bool {
if scrollView == self.insideScrollView {
/// 當(dāng)前滾動(dòng)的是外部視圖
if self.currentScrollType == .externalView {
self.insideScrollView?.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
return true
}
if self.curScrollViewPositionType == .top {
if self.currentScrollType == .all { /// 在頂部的時(shí)候,外部和內(nèi)部視圖都可以滑動(dòng),判斷當(dāng)內(nèi)部滾動(dòng)視圖視圖的位置,如果滾動(dòng)到底部了,則變?yōu)橥獠繚L動(dòng)視圖跟著滑動(dòng),內(nèi)部滾動(dòng)視圖不動(dòng)
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
} else if scrollView.isDecelerating == false,
self.currentScrollType == .insideView { /// 在頂部的時(shí)候,當(dāng)內(nèi)部滾動(dòng)視圖,慢慢滑動(dòng)到底部,變成整個(gè)外部滾動(dòng)視圖跟著滑動(dòng)下來,內(nèi)部滾動(dòng)視圖不再滑動(dòng)
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
}
}
}
}
return false
}
- 滾動(dòng)結(jié)束判斷
/// 當(dāng)在頂部,滾動(dòng)停止時(shí)候 更新 當(dāng)前 滾動(dòng)類型 ,如果當(dāng)前內(nèi)部滾動(dòng)視圖,已經(jīng)滾動(dòng)到最底部,
/// 則只能滾動(dòng)最外層滾動(dòng)視圖,如果內(nèi)部滾動(dòng)視圖沒有滾動(dòng)到最底部,則外部和內(nèi)部視圖都可以滾動(dòng)
public func updateCurrentScrollTypeWhenScrollEnd(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .all
}
}
}
以上就是具體滾動(dòng)判斷相關(guān)處理邏輯,對(duì)應(yīng)實(shí)現(xiàn)效果如下。
