UIScrollView 和 UICollectionView 分頁效果


UIScrollView 和 UICollectionView 分頁效果

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

分頁大小等於 bounds 大小

如果分頁大小與 bounds 大小相等,把 UIScrollView 的 isPagingEnabled 屬性設置為 true 即可。此屬性的官方解釋

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 寬度或高度的整數倍。

分頁大小小於 bounds 大小

用 UIScrollView 和 UICollectionView 實現的方法不一樣,需要分別討論。

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

UIScrollView 分頁

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

  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。

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

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

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM