iOS 仿花小豬首頁滑動效果

一. 背景

首頁改版,想要做一個類似花小豬首頁滑動效果,具體如下所示:

花小豬滾動效果.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

  1. 關聯對象,并給外部externalScrollView添加滑動手勢

/// 添加 滑動 手勢 到 外部滾動視圖
    public func addPanGestureRecognizer(externalScrollView: UIScrollView){
        let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
        externalScrollView.addGestureRecognizer(panRecoginer)
        self.externalScrollView = externalScrollView
    }
  1. 處理滑動手勢

// 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

DEMO地址: https://github.com/fangjinfeng/FJFSwiftBlogDemo

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373