UIScrollView 和 UICollectionView 分頁效果

UIScrollView 和 UICollectionView 分頁效果

UIScrollView 能夠滾動顯示寬度或高度大於其 bounds 的內容。有些時候,須要有分頁效果。每一頁有統一的大小,相鄰無縫水平或垂直排列。當水平或垂直滾動鬆開手後,會在其中一頁徹底顯示的位置停下,滾動的距離是一頁寬度或高度的整數倍。具體實現方法分兩種狀況討論:分頁大小等於、小於 bounds 大小。分頁大小大於 bounds 大小的狀況,不知道有什麼應用場景,不討論。html

分頁大小等於 bounds 大小

若是分頁大小與 bounds 大小相等,把 UIScrollView 的 isPagingEnabled 屬性設置爲 true 便可。此屬性的官方解釋git

If the value of this property is true, the scroll view stops on multiples of the scroll view’s bounds when the user scrolls.

每一頁的大小爲 bounds 的大小,每次水平或垂直滾動的距離是 bounds 寬度或高度的整數倍。github

分頁大小小於 bounds 大小

用 UIScrollView 和 UICollectionView 實現的方法不同,須要分別討論。swift

代碼已上傳 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo數組

UIScrollView 分頁

UIScrollView 的 clipsToBounds 屬性默認爲 true,超出 bounds 的子視圖(超出部分)是看不到的。能夠把 clipsToBounds 設置爲 false,把 isPagingEnabled 設置爲 true,把 bounds 設置爲須要的分頁大小,在視覺上就基本達到分頁效果了。然而,這樣會出現的問題是:ide

  1. 滾動條只在 bounds 之內顯示(因此分頁效果只是視覺上「基本達到」)
  2. UIScrollView 顯示的內容會超出所在 UIViewController 的 view 所在範圍,當 UINavigationController 發生 push 或 pop 時,可能會看到超出部分,不美觀
  3. 觸摸 bounds 之外的區域沒有響應

對於第 1 個問題,能夠設置 scrollIndicatorInsets 屬性的值,調整滾動條位置。或者隱藏滾動條,把 showsVerticalScrollIndicator 和 showsHorizontalScrollIndicator 都設置爲 false。能夠用 UIPageControl 或自定義控件來顯示當前分頁在全部分頁中的位置。性能

對於第 2 個問題,能夠把當前所在 UIViewController 的 view 的 clipsToBounds 設置爲 true;或者把 scroll view 放在另外一個 UIView 上,把這個 UIView 的 clipsToBounds 設置爲 true。ui

對於第 3 個問題,須要重載 hitTest(_:with:) 方法。此方法的官方介紹this

Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.

此方法返回包含觸摸點的最上層視圖(UIView),沒有則返回nil。觸摸屏幕時,屏幕上的視圖經過此方法尋找發生觸摸的視圖。code

Points that lie outside the receiver’s bounds are never reported as hits, even if they actually lie within one of the receiver’s subviews. This can occur if the current view’s clipsToBounds property is set to false and the affected subview extends beyond the view’s bounds.

當觸摸點在 bounds 以外,此方法返回 nil,表示當前視圖不是發生觸摸的視圖。這就是問題的緣由。須要自定義 UIScrollView,重載此方法,讓此方法在 bounds 以外觸摸當前視圖也返回被觸摸的視圖。自定義類 PageScrollView

class PageScrollView: UIScrollView {
    
    var interactionAreaNotInBounds: [CGRect] = [] // Use bounds coordinate system
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        clipsToBounds = false
        isPagingEnabled = true
        showsVerticalScrollIndicator = false
        showsHorizontalScrollIndicator = false
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // Bounds is changed when scrolling
        // Update interaction area not in bounds according to current bounds
        let bounds = self.bounds
        let areas = interactionAreaNotInBounds.map { (rect) -> CGRect in
            return CGRect(x: bounds.minX + rect.minX,
                          y: bounds.minY + rect.minY,
                          width: rect.width,
                          height: rect.height)
        }
        // Find area contains point
        for area in areas where area.contains(point) {
            // Check subview
            for subview in subviews {
                // Convert point from current coordinate system to that of subview
                let convertedPoint = convert(point, to: subview)
                // Hit-test subview and return it if it is hit
                if let view = subview.hitTest(convertedPoint, with: event) {
                    return view
                }
            }
            // Return self if no subview is hit
            return self
        }
        // No area contains point
        // Do super hit-test
        return super.hitTest(point, with: event)
    }
}

初始化 PageScrollView 並肯定 frame 或 bounds 後,須要給 interactionAreaNotInBounds 屬性賦值。把 bounds 以外會響應觸摸的區域(用 bounds 最初的座標)寫成數組進行賦值。例如,frame 爲 (30, 0, 100, 100),要讓左邊寬 30、高 100 的區域爲響應區域,則給 interactionAreaNotInBounds 賦值爲 [CGRect(x: -30, y: 0, width: 30, height: 100)]。

當要分頁的頁數較少、每頁內容很少的時候,能夠用這個方法實現。若是要顯示不少頁的內容,一次把全部分頁視圖加到 scroll view 上,影響性能。這種狀況能夠用 UICollectionView 實現,UICollectionViewCell 是重用的,節約資源。用 UICollectionView 實現的方法不一樣。

UICollectionView 分頁

若是 UICollectionView 用以上的方法實現,出現的問題是,不在 bounds 以內的 UICollectionViewCell 可能消失。由於 cell 是重用的,移出 bounds 以後可能就被移除而準備重用。UICollectionView 繼承自 UIScrollView,能夠經過 UIScrollViewDelegate 的方法,模擬分頁效果。具體實現方法與分頁大小有關。

分頁較大

當分頁較大時,好比水平滾動,一頁寬度大於屏幕寬度一半,每次滾動的最遠距離就限制到相鄰分頁。這樣的限制與 isPagingEnabled 的效果基本符合。實現 UIScrollViewDelegate 的一個方法便可。

private var selectedIndex: Int = 0 // index of page displayed
private let cellWidth: CGFloat = UIScreen.main.bounds.width - 100
private let cellHeight: CGFloat = 100

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Destination x
    let x = targetContentOffset.pointee.x
    // Page width equals to cell width
    let pageWidth = cellWidth
    // Check which way to move
    let movedX = x - pageWidth * CGFloat(selectedIndex)
    if movedX < -pageWidth * 0.5 {
        // Move left
        selectedIndex -= 1
    } else if movedX > pageWidth * 0.5 {
        // Move right
        selectedIndex += 1
    }
    if abs(velocity.x) >= 2 {
        targetContentOffset.pointee.x = pageWidth * CGFloat(selectedIndex)
    } else {
        // If velocity is too slow, stop and move with default velocity
        targetContentOffset.pointee.x = scrollView.contentOffset.x
        scrollView.setContentOffset(CGPoint(x: pageWidth * CGFloat(selectedIndex), y: scrollView.contentOffset.y), animated: true)
    }
}

selectedIndex 表示當前分頁序號,默認顯示最左邊的一頁,所以初始化爲 0。若是最開始顯示其餘頁,須要改變selectedIndex 的值。經過 selectedIndex 的值,將要停下來的座標 x,計算出位移 movedX。當位移絕對值大於分頁寬度的一半時,滾動到位移方向的相鄰頁。

給 targetContentOffset.pointee.x 賦值,改變滾動終點的 x 座標。寬度較大的分頁效果滾動速率不能太慢,因此當速率小於 2 時,給 targetContentOffset.pointee.x 賦值爲當前位置即中止滾動,調用 setContentOffset(_:animated:) 方法,當即以默認速度滾動到終點。

如今,還有一個小問題,就是滾動到最後一頁時,滾動中止的位置不固定。最後一頁中止的位置有時候靠屏幕左邊,有時靠右。從最後一頁往回滾動可能會有點奇怪(忽然加速)。解決辦法是增長一個 UICollectionViewCell 放到最後,cell 的寬度爲屏幕寬度減分頁寬度,使最後一頁滾動的中止位置都靠屏幕左邊。假設分頁數量(UICollectionViewCell 的數量)爲 numberOfItems,如下是 cell 的大小

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    switch indexPath.item {
    case numberOfItems:
        return CGSize(width: UIScreen.main.bounds.width - cellWidth, height: cellHeight)
    default:
        return CGSize(width: cellWidth, height: cellHeight)
    }
}

分頁較小

當分頁較小時,屏幕寬度能夠顯示好幾個分頁,就不能把滾動距離限制到相鄰分頁。直接判斷滾動終點離哪一個分頁比較近,以近的分頁爲終點。

private let cellWidth: CGFloat = 100
private let cellHeight: CGFloat = 100

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Destination x
    let x = targetContentOffset.pointee.x
    // Page width equals to cell width
    let pageWidth = cellWidth
    // Destination page index
    var index = Int(x / pageWidth)
    // Check whether to move to next page
    let divideX = CGFloat(index) * pageWidth + pageWidth * 0.5
    if x > divideX {
        // Should move to next page
        index += 1
    }
    // Move to destination
    targetContentOffset.pointee.x = pageWidth * CGFloat(index)
}

一樣須要在最後增長一個 cell,防止滾動到最後一頁出問題。假設屏幕寬度最多能容納 n 個 cell (n + 1 個就超出屏幕),那麼 cell 的寬度爲屏幕寬度減 n 個 cell 的寬度。如下是 cell 的大小

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    switch indexPath.item {
    case numberOfItems:
        let n = Int(UIScreen.main.bounds.width / cellWidth)
        let d = UIScreen.main.bounds.width - cellWidth * CGFloat(n)
        return CGSize(width: d, height: cellHeight)
    default:
        return CGSize(width: cellWidth, height: cellHeight)
    }
}

如今滾動效果的問題是,從鬆開手到中止滾動的時間太長。加上一句代碼就能解決

collectionView.decelerationRate = UIScrollViewDecelerationRateFast

decelerationRate 是 UIScrollView 的屬性,設置爲 UIScrollViewDecelerationRateFast,表示滾動鬆開手後減速更快(加速度與速度方向相反,加速度的絕對值增大),於是滾動會很快減速並中止。

UIScrollView + UICollectionView 分頁

若是必定要 UICollectionView 顯示分頁內容,而且徹底有 isPagingEnabled 爲 true 的分頁效果,能夠結合 UIScrollView 來實現。如下是大概思路。

把 UICollectionView 放在底部,正常顯示內容。把上文自定義的 PageScrollView 放在頂部,響應觸摸範圍爲 UICollectionView 的範圍,設置 UIScrollView 的 contentSize。觸摸發生在 scroll view 上。在 UIScrollViewDelegate 的 scrollViewDidScroll(_:) 方法中,讓 collection view 跟着 scroll view 滾動。若是要 collection view 響應選中 cell 等操做,須要寫其餘的代碼。

這個方法比較麻煩,要把對 scroll view 的手勢傳給 collection view,每次刷新數據都要從新設置 scroll view 的 contentSize。具體見 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo

轉載請註明出處:http://www.cnblogs.com/silence-cnblogs/p/6529728.html

相關文章
相關標籤/搜索