UIScrollView實踐經驗

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實現視差滾動或者其他高級動畫,這在現在許多應用中的引導頁面裏會被用到。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章