一. 背景
首頁改版,想要做一個類似花小豬首頁滑動效果,具體如下所示:
花小豬滾動效果.gif
二. 分析
從花小豬首頁交互我們可以分析出如下信息:
- 首頁卡片分為三段式,底部、中間、頂部。
- 當首頁卡片在底部,只能先外部視圖整體往上滑動,滑動到頂部后,內部卡片頭部懸浮,內部卡片滾動視圖依然可以滾動。
- 當首頁卡片在中間,可以先外部視圖整體往上或者往下滑動,往下滑動到底部后,禁止滑動,滑動到頂部,內部視圖卡片頭部懸浮,內部滾動視圖可以滾動。
- 當首頁卡片在頂部,可以拖動卡片外部視圖整體下滑,也可以通過內部視圖向下滾動,滾動到跟內部頭部底部持平,變成整體一起向下滑動。而當內部滾動視圖向上滾動,內部卡片頭部懸浮固定。
- 首頁卡片滑動過程中,如果停在中間位置,依據卡片停止位置,距離底部、中間、頂部位置遠近,向距離近的一端,直接移動到相應位置,比如移動到中間和頂部位置之間,如果距離頂部近,則直接移動到頂部。
- 當首頁卡片在底部,上滑速度很快超過一定值,就直接到頂部。同樣在頂部下滑也一樣。
- 當首頁卡片在頂部,內部滾動視圖快速下滑,下滑到跟卡片頭部分開,產生彈簧效果,不直接一起下滑,但其他部分如果慢慢滑動,下滑到跟卡片頭部即將分開時,變成整體一起下滑。
三. 實現
理清了首頁卡片的滑動交互細節之后,我們開始設計對應類和相關職責。
image.png
從上面結構圖我們可以看出,主要分為三部分
- 卡片外層容器
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 /// 底部
}
如下是對應的判斷邏輯:
image.png
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
}
}
}
以上就是具體滾動判斷相關處理邏輯,對應實現效果如下。
Demo實現效果.gif