UIScrollView (包括它的子類 UITableView和UICollectionView)是iOS開發(fā)中最有擴(kuò)展性的UI控件虽另。UIScrollView 是 UIKit 中為數(shù)不多能響應(yīng)滑動(dòng)手勢的 view捂刺,相比自己用UIPanGestureRecognizer 實(shí)現(xiàn)一些基于滑動(dòng)手勢的效果族展,用 UIScrollView 的優(yōu)勢在于 bounce 和 decelerate 等特性可以讓 App 的用戶體驗(yàn)與 iOS 系統(tǒng)的用戶體驗(yàn)保持一致贵涵。本文通過一些實(shí)例講解 UIScrollView 的特性和實(shí)際使用中的經(jīng)驗(yàn)宾茂。
UIScrollView 和 Auto Layout
UIScrollView 在 Auto Layout 是一個(gè)很特殊的 view拴还,對(duì)于 UIScrollView 的 subview 來說自沧,它的 leading/trailing/top/bottom space 是相對(duì)于 UIScrollView 的 contentSize 而不是 bounds 來確定的树瞭,所以當(dāng)你嘗試用 UIScrollView 和它 subview 的 leading/trailing/top/bottom 來互相決定大小的時(shí)候拇厢,就會(huì)出現(xiàn)「Has ambiguous scrollable content width/height」的 warning。正確的姿勢是用 UIScrollView 外部的 view 或 UIScrollView 本身的 width/height 確定 subview 的尺寸晒喷,進(jìn)而確定 contentSize孝偎。因?yàn)?UIScrollView 本身的 leading/trailing/top/bottom 變得不好用,所以我習(xí)慣的做法是在 UIScrollView 和它原來的 subviews 之間增加一個(gè) content view凉敲,這樣做的好處有:
不會(huì)在 storyboard 里留下 error/warning
為 subview 提供 leading/trailing/top/bottom衣盾,方便 subview 的布局
通過調(diào)整 content view 的 size(可以是 constraint 的 IBOutlet)來調(diào)整 contentSize
不需要 hard code 與屏幕尺寸相關(guān)的代碼
更好地支持 rotation
UIScrollViewDelegate
UIScrollViewDelegate 是 UIScrollView 的 delegate protocol,UIScrollView 有意思的功能都是通過它的 delegate 方法實(shí)現(xiàn)的爷抓。了解這些方法被觸發(fā)的條件及調(diào)用的順序?qū)τ谑褂?UIScrollView 是很有必要的势决,本文主要講拖動(dòng)相關(guān)的效果果复,所以 zoom 相關(guān)的方法跳過不提走搁,拖動(dòng)相關(guān)的 delegate 方法按調(diào)用順序分別是:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
這個(gè)方法在任何方式觸發(fā) contentOffset 變化的時(shí)候都會(huì)被調(diào)用(包括用戶拖動(dòng),減速過程曲稼,直接通過代碼設(shè)置等),可以用于監(jiān)控 contentOffset 的變化清女,并根據(jù)當(dāng)前的 contentOffset 對(duì)其他 view 做出隨動(dòng)調(diào)整。
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
用戶開始拖動(dòng) scroll view 的時(shí)候被調(diào)用。
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
該方法從 iOS 5 引入父泳,在 didEndDragging 前被調(diào)用,當(dāng) willEndDragging 方法中 velocity 為 CGPointZero(結(jié)束拖動(dòng)時(shí)兩個(gè)方向都沒有速度)時(shí)前翎,didEndDragging 中的 decelerate 為 NO脾歇,即沒有減速過程藕各,willBeginDecelerating 和 didEndDecelerating 也就不會(huì)被調(diào)用。反之激况,當(dāng) velocity 不為 CGPointZero 時(shí)誉碴,scroll view 會(huì)以 velocity 為初速度代咸,減速直到 targetContentOffset呐芥。值得注意的是思瘟,這里的 targetContentOffset 是個(gè)指針滨攻,沒錯(cuò)光绕,你可以改變減速運(yùn)動(dòng)的目的地诞帐,這在一些效果的實(shí)現(xiàn)時(shí)十分有用停蕉,實(shí)例章節(jié)中會(huì)具體提到它的用法慧起,并和其他實(shí)現(xiàn)方式作比較驯绎。
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
在用戶結(jié)束拖動(dòng)后被調(diào)用剩失,decelerate 為 YES 時(shí)拴孤,結(jié)束拖動(dòng)后會(huì)有減速過程演熟。注兄纺,在 didEndDragging 之后估脆,如果有減速過程疙赠,scroll view 的 dragging 并不會(huì)立即置為 NO圃阳,而是要等到減速結(jié)束之后捍岳,所以這個(gè) dragging 屬性的實(shí)際語義更接近 scrolling祟同。
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView
減速動(dòng)畫開始前被調(diào)用。
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
減速動(dòng)畫結(jié)束時(shí)被調(diào)用砖顷,這里有一種特殊情況:當(dāng)一次減速動(dòng)畫尚未結(jié)束的時(shí)候再次 drag scroll view滤蝠,didEndDecelerating 不會(huì)被調(diào)用物咳,并且這時(shí) scroll view 的 dragging 和 decelerating 屬性都是 YES览闰。新的 dragging 如果有加速度压鉴,那么 willBeginDecelerating 會(huì)再一次被調(diào)用击蹲,然后才是 didEndDecelerating歌豺;如果沒有加速度,雖然 willBeginDecelerating 不會(huì)被調(diào)用轮听,但前一次留下的 didEndDecelerating 會(huì)被調(diào)用血巍,所以連續(xù)快速滾動(dòng)一個(gè) scroll view 時(shí)述寡,delegate 方法被調(diào)用的順序(不含 didScroll)可能是這樣的:
scrollViewWillBeginDragging:
scrollViewWillEndDragging: withVelocity: targetContentOffset:
scrollViewDidEndDragging: willDecelerate:
scrollViewWillBeginDecelerating:
scrollViewWillBeginDragging:
scrollViewWillEndDragging: withVelocity: targetContentOffset:
scrollViewDidEndDragging: willDecelerate:
scrollViewWillBeginDecelerating:
...
scrollViewWillBeginDragging:
scrollViewWillEndDragging: withVelocity: targetContentOffset:
scrollViewDidEndDragging: willDecelerate:
scrollViewWillBeginDecelerating:
scrollViewDidEndDecelerating:
雖然很少有因?yàn)檫@個(gè)導(dǎo)致的 bug,但是你需要知道這種很常見的用戶操作會(huì)導(dǎo)致的中間狀態(tài)螟炫。例如你嘗試在 UITableViewDataSource 的 tableView:cellForRowAtIndexPath: 方法中基于 tableView 的 dragging 和 decelerating 屬性判斷是在用戶拖拽還是減速過程中的話可能會(huì)誤判。
實(shí)例
下面通過一些實(shí)例然评,更詳細(xì)地演示和描述以上各 delegate 方法的用途碗淌。
- Table View 中圖片加載邏輯的優(yōu)化
雖然這種優(yōu)化方式在現(xiàn)在的機(jī)能和網(wǎng)絡(luò)環(huán)境下可能看似不那么必要亿眠,但在我最初看到這個(gè)方法是的 09 年(印象中是 Tweetie 作者在 08 年寫的 Blog,可能有誤),遙想 iPhone 3G/3GS 的機(jī)能,這個(gè)方法為多圖的 table view 的性能帶來很大的提升,也成了我的秘密武器山卦。而現(xiàn)在账蓉,在移動(dòng)網(wǎng)絡(luò)環(huán)境下,你依然值得這么做來為用戶節(jié)省流量箱玷。
先說一下原文的思路:
當(dāng)用戶手動(dòng) drag table view 的時(shí)候,會(huì)加載 cell 中的圖片舶得;
在用戶快速滑動(dòng)的減速過程中,不加載過程中 cell 中的圖片(但文字信息還是會(huì)被加載珠插,只是減少減速過程中的網(wǎng)絡(luò)開銷和圖片加載的開銷)捻撑;
在減速結(jié)束后,加載所有可見 cell 的圖片(如果需要的話)江解;
問題 1:
前面提到鳖枕,剛開始拖動(dòng)的時(shí)候宾符,dragging 為 YES,decelerating 為 NO哄褒;decelerate 過程中,dragging 和 decelerating 都為 YES罚舱;decelerate 未結(jié)束時(shí)開始下一次拖動(dòng),dragging 和 decelerating 依然都為 YES包个。所以無法簡單通過 table view 的 dragging 和 decelerating 判斷是在用戶拖動(dòng)還是減速過程。
解決這個(gè)問題很簡單糯而,添加一個(gè)變量如 userDragging熄驼,在 willBeginDragging 中設(shè)為 YES,didEndDragging 中設(shè)為 NO祭芦。那么 tableView: cellForRowAtIndexPath: 方法中胃夏,是否 load 圖片的邏輯就是:
if (!self.userDragging && tableView.decelerating) {
cell.imageView.image = nil;
} else {
// code for loading image from network or disk
}
問題 2:
這么做的話侮叮,decelerate 結(jié)束后囊榜,屏幕上的 cell 都是不帶圖片的砂沛,解決這個(gè)問題也不難,你需要一個(gè)形如 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:
這個(gè)問題可能不容易被發(fā)現(xiàn),在減速過程中如果用戶開始新的拖動(dòng)得问,當(dāng)前屏幕的 cell 并不會(huì)被加載(前文提到的調(diào)用順序問題導(dǎo)致),而且問題 1 的方案并不能解決問題 3漓骚,因?yàn)檫@些 cell 已經(jīng)在屏上,不會(huì)再次經(jīng)過 cellForRowAtIndexPath 方法叉信。雖然不容易發(fā)現(xiàn)硅急,但解決很簡單,只需要在 scrollViewWillBeginDragging: 方法里也調(diào)用一次 loadImageForVisibleCells 即可荚板。
再優(yōu)化
上述方法在那個(gè)年代的確提升了 table view 的 performance,但是你會(huì)發(fā)現(xiàn)在減速過程最后最慢的那零點(diǎn)幾秒時(shí)間免绿,其實(shí)還是會(huì)讓人等得有些心急,尤其如果你的 App 只有圖片沒有文字辽故。在 iOS 5 引入了 scrollViewWillEndDragging: withVelocity: targetContentOffset: 方法后,配合 SDWebImage彤枢,我嘗試再優(yōu)化了一下這個(gè)方法以提升用戶體驗(yàn):
如果內(nèi)存中有圖片的緩存,減速過程中也會(huì)加載該圖片
如果圖片屬于 targetContentOffset 能看到的 cell业栅,正常加載,這樣一來帮孔,快速滾動(dòng)的最后一屏出來的的過程中,用戶就能看到目標(biāo)區(qū)域的圖片逐漸加載
你可以嘗試用類似 fade in 或者 flip 的效果緩解生硬的突然出現(xiàn)(尤其是像本例這樣只有圖片的 App)
核心代碼:
- (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 同時(shí)起到了原來 userDragging 的作用幢妄。
2. 分頁的幾種實(shí)現(xiàn)方式
利用 UIScrollView 有多種方法實(shí)現(xiàn)分頁,但是各自的效果和用途不盡相同忍法,其中方法 2 和方法 3 的區(qū)別也正是一些同類 App 在模仿 Glow 的首頁 Bubble 翻轉(zhuǎn)效果時(shí)跟 Glow 體驗(yàn)上的的差距所在(但愿他們不會(huì)看到本文并且調(diào)整他們的實(shí)現(xiàn)方式)羹蚣。本例通過三種方法實(shí)現(xiàn)相似的一個(gè)場景咽弦,你可以通過安裝到手機(jī)上來感受三種實(shí)現(xiàn)方式的不同用戶體驗(yàn)。為了區(qū)分每個(gè)例子的重點(diǎn)闹蒜,本例沒有重用機(jī)制,重用相關(guān)內(nèi)容見例 3砌烁。
2.1 pagingEnabled
這是系統(tǒng)提供的分頁方式疏唾,最簡單,但是有一些局限性:
只能以 frame size 為單位翻頁顿天,減速動(dòng)畫阻尼大,減速過程不超過一頁
需要一些 hacking 實(shí)現(xiàn) bleeding 和 padding(即頁與頁之間有 padding鸟缕,在當(dāng)前頁可以看到前后頁的部分內(nèi)容)
Sample 中 Pagination 有簡單實(shí)現(xiàn) bleeding 和 padding 效果的代碼,主要的思路是:
讓 scroll view 的寬度為 page 寬度 + padding番甩,并且設(shè)置 clipsToBounds 為 NO
這樣雖然能看到前后頁的內(nèi)容,但是無法響應(yīng) touch,所以需要另一個(gè)覆蓋期望的可觸摸區(qū)域的 view 來實(shí)現(xiàn)類似 touch bridging 的功能
適用場景:上述局限性同時(shí)也是這種實(shí)現(xiàn)方式的優(yōu)點(diǎn)牺汤,比如一般 App 的引導(dǎo)頁(教程),Calendar 里的月視圖追迟,都可以用這種方法實(shí)現(xiàn)。
2.2 Snap
這種方法就是在 didEndDragging 且無減速動(dòng)畫厢绝,或在減速動(dòng)畫完成時(shí),snap 到一個(gè)整數(shù)頁。核心算法是通過當(dāng)前 contentOffset 計(jì)算最近的整數(shù)頁及其對(duì)應(yīng)的 contentOffset娄周,通過動(dòng)畫 snap 到該頁。這個(gè)方法實(shí)現(xiàn)的效果都有個(gè)通病掷酗,就是最后的 snap 會(huì)在 decelerate 結(jié)束以后才發(fā)生且轨,總感覺很突兀。
2.3 修改 targetContentOffset
通過修改 scrollViewWillEndDragging: withVelocity: targetContentOffset: 方法中的 targetContentOffset 直接修改目標(biāo) offset 為整數(shù)頁位置至朗。其中核心代碼:
- (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 的體驗(yàn)會(huì)好很多祸挪,snap 到整數(shù)頁的過程很自然,或者說用戶完全感知不到 snap 過程的存在。這兩種方法的減速過程流暢公黑,適用于一屏有多頁,但需要按整數(shù)頁滑動(dòng)的場景朝蜘;也適用于如圖表中自動(dòng) snap 到整數(shù)天的場景步做;還適用于每頁大小不同的情況下 snap 到整數(shù)頁的場景(不做舉例煮剧,自行發(fā)揮,其實(shí)只需要修改計(jì)算目標(biāo) offset 的方法)。
3. 重用
大部分的 iOS 開發(fā)應(yīng)該都清楚 UITableView 的 cell 重用機(jī)制驱还,這種重用機(jī)制減少了內(nèi)存開銷也提高了 performance闷沥,UIScrollView 作為 UITableView 的父類戳粒,在很多場景中也很適合應(yīng)用重用機(jī)制(其實(shí)不只是 UIScrollView奄妨,任何場景中會(huì)反復(fù)出現(xiàn)的元素都應(yīng)該適當(dāng)?shù)匾胫赜脵C(jī)制)树枫。
你可以參照 UITableView 的 cell 重用機(jī)制奔誓,總結(jié)重用機(jī)制如下:
- 維護(hù)一個(gè)重用隊(duì)列
- 當(dāng)元素離開可見范圍時(shí)体谒,removeFromSuperview 并加入重用隊(duì)列(enqueue)
- 當(dāng)需要加入新的元素時(shí),先嘗試從重用隊(duì)列獲取可重用元素(dequeue)并且從重用隊(duì)列移除
- 如果隊(duì)列為空,新建元素
- 這些一般都在 scrollViewDidScroll: 方法中完成
實(shí)際使用中,需要注意的點(diǎn)是:
- 當(dāng)重用對(duì)象為 view controller 時(shí)樟蠕,記得 addChildeViewController
- 當(dāng) view 或 view controller 被重用但其對(duì)應(yīng) model 發(fā)生變化的時(shí)候吓懈,需要及時(shí)清理重用前留下的內(nèi)容
- 數(shù)據(jù)可以適當(dāng)做緩存,在重用的時(shí)候嘗試從緩存中讀取數(shù)據(jù)甚至之前的狀態(tài)(如 table view 的 contentOffset),以得到更好的用戶體驗(yàn)
- 當(dāng) on screen 的元素?cái)?shù)量可確定的時(shí)候温兼,有時(shí)候可以提前 init 這些元素吝羞,不會(huì)在 scroll 過程中遇到因?yàn)?init 開銷帶來的卡頓(尤其是以 view controller 為重用對(duì)象的時(shí)候)
例 2 中的場景很適合以 view 為重用單位,本例新增一個(gè)以 view controller 為重用對(duì)象的例子,該例子同時(shí)演示了聯(lián)動(dòng)效果判族,具體見下個(gè)例子。
4. 聯(lián)動(dòng)/視差滾動(dòng)
上一個(gè)例子里 main scroll view 和 title view 里的 scroll view 就是一個(gè)聯(lián)動(dòng)的例子,所謂聯(lián)動(dòng)合冀,就是當(dāng) A 滾動(dòng)的時(shí)候,在 scrollViewDidScroll: 里根據(jù) A 的 contentOffset 動(dòng)態(tài)計(jì)算 B 的 contentOffset 并設(shè)給 B朝抖。同樣對(duì)于非 scroll view 的 C砌滞,也可以動(dòng)態(tài)計(jì)算 C 的 frame 或是 transform(Glow 的氣泡為例)實(shí)現(xiàn)視差滾動(dòng)或者其他高級(jí)動(dòng)畫,這在現(xiàn)在許多應(yīng)用的引導(dǎo)頁面里會(huì)被用到华畏。