UIScrollView實踐經驗
UIScrollView(包括它的子類UITableView和UICollectionView)是iOS開發中最常用也是最有意思的UI組件,大部分App的核心界面都是基於三者之一或三者的組合實現。
UIScrollView是UIKit中爲數不多能響應滑動手勢的view,相比自己用UIPanGestureReconizer實現一些基於滑動手勢的效果,用UIScrollView的優勢在於bounce和decelerate等特性可以讓App的用戶體驗與iOS系統的用戶體驗保持一致。
UIScrollView和Auto Layout
iPhone5剛出來的時候,大部分不支持橫屏的App都不需要做太多的適配工作,因爲屏幕寬度沒有變,tableView多個cell也不需要加code。但是在iPhone6和iPhone6 Plus發佈以後,多分辨率適配終於不再是Andriod開發的專利了。於是,從iOS6起就存在Auto Layout終於有用武之地。
關於AutoLayout的基本用法不再贅述,可以參考Ray Wenderlich上的教程。但UIScrollView在Auto Layout是一個很特殊的view,對於UISrollView的subview來說,它的leading/trailing/top/bottom space是相對於UIScrollView的contentSize而不是bounds來確定的,所以當你嘗試用UIScrollView和它subview的leading/trailing/top/bottom來相互決定大小的時候,就會出現[Has ambigous scrollable content width/height]的warning。正確的是用UIScrollView外部的view或UIScrollView本身的width/height確定subview的尺寸,進而確定contentSize。因爲UIScrollView本身的leading/trailing/top/bottom變得不好用,所以我習慣的做法是在UIScrollView和它原來的subviews之間增加一個contentView,這樣做的好處有:
a、不會在storyboard裏留下error/warning
b、爲subview提供leading/trailing/top/bottom,方便subview佈局
c、通過調整content view的size(可以是constraint的IBOutlet)來調整contentSize
d、不需要hard code與屏幕尺寸相關的代碼
e、更好地支持rotation
UIScrollViewDelegate
UIScrollViewDelegate是UIScrollView的delegate protocol,UIScrollView的功能都是通過它的delegate方法實現的。瞭解這些方法被觸發的條件及調用的順序對於使用UIScrollView是很有必要的,這裏主要講拖動相關的效果,所以zoom相關的方法跳過不提。拖動相關的delegate方法按調用順序分別是:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
這個方法在任何方式觸發contentOffset變化的時候都會被調用(包括用戶拖動,減速過程,直接通過代碼設置等),可以用於監控contentOffset的變化,並根據當前的contentOffset對其他view做出隨動調整。
- (void)scrollViewWillBeginDragging:(UISCrollView *)scrollView
用戶開始拖動scroll view的時候被調用
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
該方法從iOS5引入,在didEndDragging前被調用,當willEndDragging方法中velocity爲CGPointZero(結束拖動時兩個方向都沒有速度)時,didEndDragging中的de爲NO,即沒有減速過程,willBeginDecelerating和didEndDecelerating也不會被調用。反之,當velocity不爲CGPointZero時,scrollView會以velocity爲初始速度,減速直到targetContentOffset。值得注意的是,這裏的targetContentOffset是個指針,沒錯,可以改變減速運動的目的地,這在一些效果的實現時十分有用,實例章節中會具體提到它的用法,並和其他實現方式作比較。
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
在用戶結束拖動後被調用,decelerate爲YES時,結束拖動後會有減速過程。注,在didEndDragging之後,如果有減速過程,scroll view的dragging並不會立即置爲NO,而是要等到減速結束之後,所以這個dragging屬性的實際語義更接近scrolling。
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView
減速動畫開始前被調用。
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
減速動畫結束時被調用,這裏有一種特殊情況:當一次減速動畫尚未結束的時候再次drag scrollView,didEndDecelerating不會被調用,並且這時scrollView的dragging和decelerating屬性都是YES。新的dragging如果有加速度,那麼willBeginDecelerating會再一次被調用,然後纔是didEndDecelerating。如果沒有加速度,雖然willBeginDecelerating不會被調用,但前一次留下的didEndDecelerating會被調用,所以連續快速滾動一個scroll view時,delegate方法被調用的順序(不包含didScroll)可能是這樣的:
a、scrollViewWillBeginDragging:
b、scrollViewWillEndDragging: withVelocity: targetContentOffset:
c、scrollViewDidEndDragging: willDecelerate:
d、scrollViewWillBeginDecelerating:
e、scrollViewWillBeginDragging:
f、scrollViewWillEndDragging: withVelocity: targetContentOffset:
g、scrollViewDidEndDragging: willDecelerate:
h、scrollViewWillBeginDecelerating:
……
scrollViewWillBeginDragging
scrollViewWillEndDragging: withVelocity: targetContentOffset:
scrollViewDidEndDragging: willDecelerate:
scrollViewWillBeginDecelerating:
scrollViewDidEndDecelerating:
雖然很少因爲這個導致的bug,但是你需要知道這種很常見的用戶操作會導致中間狀態。例如你嘗試在UITableViewDataSource的tableView: cellForRowAtIndexPath: 方法中基於tableView的dragging和decelerating屬性判斷是在用戶拖拽還是減速過程中的話可能會誤判(見例1)。
Sample中的Delegate簡單輸出了一些Log,你可以快速瞭解這些方法的調用shu順序。
實例
下面通過一些實例,更詳細地演示和描述以上各delegate方法的用途。
1、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圖片的邏輯就是:
if (!self.userDragging && tableView.decelerating) {
cell.imageView.image = nil;
} else {
// code for loading image from network or disk
}
問題2:
這麼做的話,decelerate結束後,屏幕上的cell都是不帶圖片的,解決這個問題也不難,你需要一個形如loadImageForVisibleCells的方法,加載可見cell的圖片:
- (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只有圖片沒有文字。在iOS5引入了scrollViewWillEndDragging:withVelocity:targetContentOffset:方法後,配合SDWebImage,嘗試優化了一下這個方法以提升用戶體驗:
a、如果內存中有圖片的緩存,減速過程中也會加載該圖片
b、如果圖片屬於targetContentOffset能看到的cell,正常加載,這樣一來,快速滾動的最後一屏出來的過程中,用戶就能看到目標區域的圖片逐漸加載。
c、可以嘗試用類似fade in或者flip的效果緩解生硬的突然出現
核心代碼:
- (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];
}
是否需要加載圖片的邏輯:
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的作用。
分頁的幾種實現方式
利用UIScrollView有多種方法實現分頁,但是各自的效果和用途不盡相同,其中方法2和方法3的區別也正是一些同類App在模仿Glow的首頁Bubble翻轉效果時跟Glow體驗上的差距所在。這裏通過三種方法實現類似的一個場景。爲了區分每個例子的重點,本例沒有重用機制,重用相關內容見例子3。
2.1 pagingEnabled
這是系統提供的分頁方式,最簡單,但是有一些侷限性:
只能以frame size爲單位翻頁,減速動畫阻尼大,減速過程不超過一頁。需要一些hacking實現bleeding和padding(即頁與頁之間有padding,在當前頁可以看到前後頁的部分內容)。
Sample中Pagination有簡單實現bleeding和padding效果的代碼,主要的思路是:讓scrollView的寬度爲page寬度+padding,並且設置clipsToBounds爲NO。
這樣雖然能看到前後頁的內容,但是無法響應touch,所以需要另一個覆蓋期望的可觸摸區域的View來實現類似touch bridging的功能。
使用場景:上述侷限性同時也是這種實現方式的優點,比如一般App的引導頁,Calendar裏的月視圖,都可以用這種方式實現。
2.2 Snap
這種方法就是在didEndDragging且無減速動畫,或在減速動畫完成時,snap到一個整數頁。核心算法是通過當前contentOffset計算最近的整數頁及其對應的contentOffset,通過動畫snap到該頁。這個實現方法的效果都有個通病,就是最後的snap會在decelerate結束後才放生,總感覺很突兀。
2.3修改targetContentOffset
通過修改scrollViewEndDragging: withVelocity: targetContentOffset: 方法中targetContentOffset直接修改目標offset爲整數頁位置。其中核心代碼:
- (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到整數頁的場景。
重用
大部分的iOS開發應該都清楚UITableView的cell重用機制,這種重用機制減少了內存開銷也提高了performance,UIScrollView作爲UITableView的父類,在很多場景中也很適合應用重用機制(其實不只是UIScrollView,任何場景中會反覆出現的元素都應該適當地引入重用機制)。
參照UITableView的cell重用機制,總結重用機制如下:
a、維護一個重用隊列
b、當需要加入新的元素時,先嚐試從重用隊列獲取可重用元素(dequeue)並且從重用隊列移除
c、如果隊列爲空,新建元素
這些一般都在scrollViewDidScroll:方法中完成
實際使用中,需要注意的點是:
a、當重用對象爲view controller時,記得addChildViewController
b、當view或view controller被重用但其對應model發生變化的時候,需要及時清理重用前留下的內容
c、數據可以適當做緩存,在重用的時候嘗試從緩存中讀取數據甚至之前的狀態(如tableview的contentOffset),以得到更好的用戶體驗。
d、當on screen的元素數量可確定的時候,有時候可以提前init這些元素,不會在scroll過程共遇到因爲init開銷帶來的卡頓(尤其是以view controller爲重用對象的時候)
聯動/視差滾動
所謂聯動,就是當A滾動的時候,在scrollViewDidScroll:里根據A的contentOffset動態計算B的contentOffset並設給B。同樣對於非scrollview的C,也可以動態計算C的frame或是transform實現視差滾動或者其他高級動畫,這在現在許多應用中的引導頁面裏會被用到。