UIScrollView
(包括它的子類 UITableView
和 UICollectionView
)是 iOS 開發中最常用也是最有意思的 UI 元件,大部分 App 的核心介面都是基於三者之一或三者的組合實現。UIScrollView
是 UIKit
中為數不多能響應滑動手勢的 view,相比自己用 UIPanGestureRecognizer
實現一些基於滑動手勢的效果,用 UIScrollView
的優勢在於 bounce 和 decelerate 等特性可以讓 App 的使用者體驗與 iOS 系統的使用者體驗保持一致。本文通過一些例項講解 UIScrollView
的特性和實際使用中的經驗。
UIScrollView 和 Auto Layout
iPhone 5 剛出來的時候,大部分不支援橫屏的 App 都不需要做太多的適配工作,因為螢幕寬度沒有變,table view 多個 cell 也不需要加 code。但是在 iPhone 6 和 iPhone 6 Plus 釋出以後,多解析度適配終於不再是 Android 開發的專利了。於是,從 iOS 6 起就存在的 Auto Layout 終於有了用武之地。
關於 Auto Layout 的基本用法不再贅述,可以參考 Ray Wenderlich 上的教程(Part 2)。但 UIScrollView
在 Auto Layout 是一個很特殊的 view,對於 UIScrollView
的 subview 來說,它的 leading/trailing/top/bottom space 是相對於 UIScrollView
的 contentSize 而不是 bounds 來確定的,所以當你嘗試用 UIScrollView
和它 subview 的 leading/trailing/top/bottom 來互相決定大小的時候,就會出現「Has ambiguous scrollable content width/height」的 warning。正確的姿勢是用 UIScrollView
外部的 view 或 UIScrollView
本身的 width/height 確定 subview 的尺寸,進而確定 contentSize
。因為 UIScrollView
本身的 leading/trailing/top/bottom 變得不好用,所以我習慣的做法是在 UIScrollView
和它原來的 subviews 之間增加一個 content view,這樣做的好處有:
- 不會在 storyboard 裡留下 error/warning
- 為 subview 提供 leading/trailing/top/bottom,方便 subview 的佈局
- 通過調整 content view 的 size(可以是 constraint 的
IBOutlet
)來調整contentSize
- 不需要 hard code 與螢幕尺寸相關的程式碼
- 更好地支援 rotation
Sample 中的 AutoLayout 演示了 UIScrollView
+ Auto Layout 的例子。
UIScrollViewDelegate
UIScrollViewDelegate
是 UIScrollView
的 delegate protocol,UIScrollView
有意思的功能都是通過它的 delegate 方法實現的。瞭解這些方法被觸發的條件及呼叫的順序對於使用 UIScrollView
是很有必要的,本文主要講拖動相關的效果,所以 zoom 相關的方法跳過不提,拖動相關的 delegate 方法按呼叫順序分別是:
1 |
- (void)scrollViewDidScroll:(UIScrollView *)scrollView |
這個方法在任何方式觸發 contentOffset
變化的時候都會被呼叫(包括使用者拖動,減速過程,直接通過程式碼設定等),可以用於監控 contentOffset
的變化,並根據當前的 contentOffset
對其他 view 做出隨動調整。
1 |
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView |
使用者開始拖動 scroll view 的時候被呼叫。
1 |
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset |
該方法從 iOS 5 引入,在 didEndDragging 前被呼叫,當 willEndDragging 方法中 velocity
為 CGPointZero
(結束拖動時兩個方向都沒有速度)時,didEndDragging 中的decelerate
為 NO
,即沒有減速過程,willBeginDecelerating 和 didEndDecelerating 也就不會被呼叫。反之,當 velocity
不為 CGPointZero
時,scroll view 會以 velocity
為初速度,減速直到 targetContentOffset
。值得注意的是,這裡的 targetContentOffset
是個指標,沒錯,你可以改變減速運動的目的地,這在一些效果的實現時十分有用,例項章節中會具體提到它的用法,並和其他實現方式作比較。
1 |
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate |
在使用者結束拖動後被呼叫,decelerate
為 YES
時,結束拖動後會有減速過程。注,在 didEndDragging 之後,如果有減速過程,scroll view 的 dragging 並不會立即置為 NO
,而是要等到減速結束之後,所以這個 dragging 屬性的實際語義更接近 scrolling。
1 |
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView |
減速動畫開始前被呼叫。
1 |
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView |
減速動畫結束時被呼叫,這裡有一種特殊情況:當一次減速動畫尚未結束的時候再次 drag scroll view,didEndDecelerating 不會被呼叫,並且這時 scroll view 的 dragging
和 decelerating
屬性都是 YES
。新的 dragging 如果有加速度,那麼 willBeginDecelerating 會再一次被呼叫,然後才是 didEndDecelerating;如果沒有加速度,雖然 willBeginDecelerating 不會被呼叫,但前一次留下的 didEndDecelerating 會被呼叫,所以連續快速滾動一個 scroll view 時,delegate 方法被呼叫的順序(不含 didScroll)可能是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
scrollViewWillBeginDragging: scrollViewWillEndDragging: withVelocity: targetContentOffset: scrollViewDidEndDragging: willDecelerate: scrollViewWillBeginDecelerating: scrollViewWillBeginDragging: scrollViewWillEndDragging: withVelocity: targetContentOffset: scrollViewDidEndDragging: willDecelerate: scrollViewWillBeginDecelerating: ... scrollViewWillBeginDragging: scrollViewWillEndDragging: withVelocity: targetContentOffset: scrollViewDidEndDragging: willDecelerate: scrollViewWillBeginDecelerating: scrollViewDidEndDecelerating: |
雖然很少有因為這個導致的 bug,但是你需要知道這種很常見的使用者操作會導致的中間狀態。例如你嘗試在 UITableViewDataSource
的 tableView:cellForRowAtIndexPath:
方法中基於 tableView 的 dragging 和 decelerating 屬性判斷是在使用者拖拽還是減速過程中的話可能會誤判(見例 1)。
Sample 中的 Delegate 簡單輸出了一些 Log,你可以快速瞭解這些方法的呼叫順序。
例項
下面通過一些例項,更詳細地演示和描述以上各 delegate 方法的用途。
1. Table View 中圖片載入邏輯的優化
雖然這種優化方式在現在的機能和網路環境下可能看似不那麼必要,但在我最初看到這個方法是的 09 年(印象中是 Tweetie 作者在 08 年寫的 Blog,可能有誤),遙想 iPhone 3G/3GS 的機能,這個方法為多圖的 table view 的效能帶來很大的提升,也成了我的祕密武器。而現在,在行動網路環境下,你依然值得這麼做來為使用者節省流量。
先說一下原文的思路:
- 當使用者手動 drag table view 的時候,會載入 cell 中的圖片;
- 在使用者快速滑動的減速過程中,不載入過程中 cell 中的圖片(但文字資訊還是會被載入,只是減少減速過程中的網路開銷和圖片載入的開銷);
- 在減速結束後,載入所有可見 cell 的圖片(如果需要的話);
問題 1:
前面提到,剛開始拖動的時候,dragging
為 YES
,decelerating
為 NO
;decelerate 過程中,dragging
和 decelerating
都為 YES
;decelerate 未結束時開始下一次拖動,dragging
和 decelerating
依然都為 YES
。所以無法簡單通過 table view 的 dragging
和 decelerating
判斷是在使用者拖動還是減速過程。
解決這個問題很簡單,新增一個變數如 userDragging
,在 willBeginDragging 中設為 YES
,didEndDragging 中設為 NO
。那麼 tableView: cellForRowAtIndexPath:
方法中,是否 load 圖片的邏輯就是:
1 2 3 4 5 |
if (!self.userDragging && tableView.decelerating) { cell.imageView.image = nil; } else { // code for loading image from network or disk } |
問題 2:
這麼做的話,decelerate 結束後,螢幕上的 cell 都是不帶圖片的,解決這個問題也不難,你需要一個形如 loadImageForVisibleCells
的方法,載入可見 cell 的圖片:
1 2 3 4 5 6 7 8 |
- (void)loadImageForVisibleCells { NSArray *cells = [self.tableView visibleCells]; for (GLImageCell *cell in cells) { NSIndexPath *indexPath = [self.tableView indexPathForCell:cell]; [self setupCell:cell withIndexPath:indexPath]; } } |
問題 3:
這個問題可能不容易被發現,在減速過程中如果使用者開始新的拖動,當前螢幕的 cell 並不會被載入(前文提到的呼叫順序問題導致),而且問題 1 的方案並不能解決問題 3,因為這些 cell 已經在屏上,不會再次經過 cellForRowAtIndexPath 方法。雖然不容易發現,但解決很簡單,只需要在 scrollViewWillBeginDragging:
方法裡也呼叫一次 loadImageForVisibleCells
即可。
再優化
上述方法在那個年代的確提升了 table view 的 performance,但是你會發現在減速過程最後最慢的那零點幾秒時間,其實還是會讓人等得有些心急,尤其如果你的 App 只有圖片沒有文字。在 iOS 5 引入了 scrollViewWillEndDragging: withVelocity: targetContentOffset:
方法後,配合 SDWebImage
,我嘗試再優化了一下這個方法以提升使用者體驗:
- 如果記憶體中有圖片的快取,減速過程中也會載入該圖片
- 如果圖片屬於
targetContentOffset
能看到的 cell,正常載入,這樣一來,快速滾動的最後一屏出來的的過程中,使用者就能看到目標區域的圖片逐漸載入 - 你可以嘗試用類似 fade in 或者 flip 的效果緩解生硬的突然出現(尤其是像本例這樣只有圖片的 App)
核心程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { self.targetRect = nil; [self loadImageForVisibleCells]; } - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { CGRect targetRect = CGRectMake(targetContentOffset->x, targetContentOffset->y, scrollView.frame.size.width, scrollView.frame.size.height); self.targetRect = [NSValue valueWithCGRect:targetRect]; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { self.targetRect = nil; [self loadImageForVisibleCells]; } |
是否需要載入圖片的邏輯:
1 2 3 4 5 6 7 8 9 10 11 |
BOOL shouldLoadImage = YES; if (self.targetRect && !CGRectIntersectsRect([self.targetRect CGRectValue], cellFrame)) { SDImageCache *cache = [manager imageCache]; NSString *key = [manager cacheKeyForURL:targetURL]; if (![cache imageFromMemoryCacheForKey:key]) { shouldLoadImage = NO; } } if (shouldLoadImage) { // load image } |
更值得高興的是,通過判斷是否 nil
,targetRect
同時起到了原來 userDragging
的作用。本例完整的程式碼見 Sample 中的 LazyLoad
2. 分頁的幾種實現方式
利用 UIScrollView 有多種方法實現分頁,但是各自的效果和用途不盡相同,其中方法 2 和方法 3 的區別也正是一些同類 App 在模仿 Glow 的首頁 Bubble 翻轉效果時跟 Glow 體驗上的的差距所在(但願他們不會看到本文並且調整他們的實現方式)。本例通過三種方法實現相似的一個場景,你可以通過安裝到手機上來感受三種實現方式的不同使用者體驗。為了區分每個例子的重點,本例沒有重用機制,重用相關內容見例 3。
2.1 pagingEnabled
這是系統提供的分頁方式,最簡單,但是有一些侷限性:
- 只能以 frame size 為單位翻頁,減速動畫阻尼大,減速過程不超過一頁
- 需要一些 hacking 實現 bleeding 和 padding(即頁與頁之間有 padding,在當前頁可以看到前後頁的部分內容)
Sample 中 Pagination 有簡單實現 bleeding 和 padding 效果的程式碼,主要的思路是:
- 讓 scroll view 的寬度為 page 寬度 + padding,並且設定
clipsToBounds
為NO
- 這樣雖然能看到前後頁的內容,但是無法響應 touch,所以需要另一個覆蓋期望的可觸控區域的 view 來實現類似 touch bridging 的功能
適用場景:上述侷限性同時也是這種實現方式的優點,比如一般 App 的引導頁(教程),Calendar 裡的月檢視,都可以用這種方法實現。
2.2 Snap
這種方法就是在 didEndDragging 且無減速動畫,或在減速動畫完成時,snap 到一個整數頁。核心演算法是通過當前 contentOffset 計算最近的整數頁及其對應的 contentOffset,通過動畫 snap 到該頁。這個方法實現的效果都有個通病,就是最後的 snap 會在 decelerate 結束以後才發生,總感覺很突兀。
2.3 修改 targetContentOffset
通過修改 scrollViewWillEndDragging: withVelocity: targetContentOffset:
方法中的 targetContentOffset
直接修改目標 offset 為整數頁位置。其中核心程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
- (CGPoint)nearestTargetOffsetForOffset:(CGPoint)offset { CGFloat pageSize = BUBBLE_DIAMETER + BUBBLE_PADDING; NSInteger page = roundf(offset.x / pageSize); CGFloat targetX = pageSize * page; return CGPointMake(targetX, offset.y); } - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { CGPoint targetOffset = [self nearestTargetOffsetForOffset:*targetContentOffset]; targetContentOffset->x = targetOffset.x; targetContentOffset->y = targetOffset.y; } |
適用場景:方法 2 和 方法 3 的原理近似,效果也相近,適用場景也基本相同,但方法 3 的體驗會好很多,snap 到整數頁的過程很自然,或者說使用者完全感知不到 snap 過程的存在。這兩種方法的減速過程流暢,適用於一屏有多頁,但需要按整數頁滑動的場景;也適用於如圖表中自動 snap 到整數天的場景;還適用於每頁大小不同的情況下 snap 到整數頁的場景(不做舉例,自行發揮,其實只需要修改計算目標 offset 的方法)。
完整程式碼參見 Pagination
3. 重用
大部分的 iOS 開發應該都清楚 UITableView
的 cell 重用機制,這種重用機制減少了記憶體開銷也提高了 performance,UIScrollView
作為 UITableView
的父類,在很多場景中也很適合應用重用機制(其實不只是 UIScrollView
,任何場景中會反覆出現的元素都應該適當地引入重用機制)。
你可以參照 UITableView
的 cell 重用機制,總結重用機制如下:
- 維護一個重用佇列
- 當元素離開可見範圍時,
removeFromSuperview
並加入重用佇列(enqueue) - 當需要加入新的元素時,先嚐試從重用佇列獲取可重用元素(dequeue)並且從重用佇列移除
- 如果佇列為空,新建元素
- 這些一般都在
scrollViewDidScroll:
方法中完成
實際使用中,需要注意的點是:
- 當重用物件為 view controller 時,記得
addChildeViewController
- 當 view 或 view controller 被重用但其對應 model 發生變化的時候,需要及時清理重用前留下的內容
- 資料可以適當做快取,在重用的時候嘗試從快取中讀取資料甚至之前的狀態(如 table view 的 contentOffset),以得到更好的使用者體驗
- 當 on screen 的元素數量可確定的時候,有時候可以提前 init 這些元素,不會在 scroll 過程中遇到因為 init 開銷帶來的卡頓(尤其是以 view controller 為重用物件的時候)
例 2 中的場景很適合以 view 為重用單位,本例新增一個以 view controller 為重用物件的例子,該例子同時演示了聯動效果,具體見下個例子。
完整程式碼參見 Reuse
4. 聯動/視差滾動
上一個例子裡 main scroll view 和 title view 裡的 scroll view 就是一個聯動的例子,所謂聯動,就是當 A 滾動的時候,在 scrollViewDidScroll:
里根據 A 的 contentOffset
動態計算 B 的 contentOffset
並設給 B。同樣對於非 scroll view 的 C,也可以動態計算 C 的 frame 或是 transform(Glow 的氣泡為例)實現視差滾動或者其他高階動畫,這在現在許多應用的引導頁面裡會被用到。
聯動/視差滾動部分原理上其實比較簡單,不再贅述,寫了個簡單的例子 Parallax。
寫在最後
不知不覺就寫了很多關於 UIScrollView
的內容,其實還有很多可寫,由於時間關係只好停筆。在我看來,UIScrollView
就好像提供了一個跳脫二維空間束縛的途徑,如果你有足夠的想象力,它能幫你實現更豐富的跳出平面束縛的使用者體驗。本來還準備寫一個綜合性的例子,但是由於時間關係還沒完成,後面有時間會繼續更新。
此外,例子中可能會有錯誤或可以改進的地方,歡迎在 GitHub 直接提 Issue 或 PR。