作開發也有一段時間了,經歷了第一次完成項目的激動,也經歷了每天調用系統的API的枯燥,因而就有了探索底層實現的想法。ios
在iOS開發中咱們會大量用到scrollView這個控件,咱們使用的tableView/collectionview/textView都繼承自它。scrollView的頻繁使用讓我對它的底層實現產生了興趣,它究竟是如何工做的?如何實現一個scrollView?讀完本篇博客,相信你必定也能夠本身實現一個簡易的scrollView。git
咱們首先來思考如下幾個問題:github
scrollView繼承自誰,它如何檢測到手指滑動?算法
scrollView如何實現滾動?緩存
scrollView裏的各類屬性是如何實現的?如contentSize/contentOffSet......網絡
經過一步步解決上邊的問題,咱們就能實現一個本身的scrollView。佈局
1 - (instancetype)initWithFrame:(CGRect)frame { 2 self = [super initWithFrame:frame]; 3 if (self) { 4 UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] init]; 5 [panGesture addTarget:self action:@selector(panGestureAction:)]; 6 [self addGestureRecognizer:panGesture]; 7 } 8 return self; 9 }
提到它們,你們都知道frame是相對於父視圖座標系來講本身的位置和尺寸,bounds相對於自身座標系來講的位置和尺寸,而且origin通常爲(0,0)。可是bounds的origin有什麼用處?改變它會出現什麼效果呢?post
當咱們嘗試改變bounds的origin時,咱們就會發現視圖自己沒有發生變化,可是它的子視圖的位置卻發生了變化,why???其實當咱們改變bounds的origin的時候,視圖自己位置沒有變化,可是因爲origin的值是基於自身的座標系,因此自身座標系的位置被咱們改變了。而子視圖的frame正是基於父視圖的座標系,當咱們更改父視圖bounds中origin的時候子視圖的位置就發生了變化,這就是實現scrollView的關鍵點!!!性能
是否是很好理解?
基於這點咱們很容易實現一個簡單的最初級版本的scrollView,代碼以下:優化
1 - (void)panGestureAction:(UIPanGestureRecognizer *)pan { 2 // 記錄每次滑動開始時的初始位置 3 if (pan.state == UIGestureRecognizerStateBegan) { 4 self.startLocation = self.bounds.origin; 5 NSLog(@"%@", NSStringFromCGPoint(self.startLocation)); 6 } 7 8 // 相對於初始觸摸點的偏移量 9 if (pan.state == UIGestureRecognizerStateChanged) { 10 CGPoint point = [pan translationInView:self]; 11 NSLog(@"%@", NSStringFromCGPoint(point)); 12 CGFloat newOriginalX = self.startLocation.x - point.x; 13 CGFloat newOriginalY = self.startLocation.y - point.y; 14 15 CGRect bounds = self.bounds; 16 bounds.origin = CGPointMake(newOriginalX, newOriginalY); 17 self.bounds = bounds; 18 } 19 }
經過contentSize限制scrollView的內部空間,實現代碼以下
1 if (newOriginalX < 0) { 2 newOriginalX = 0; 3 } else { 4 CGFloat maxMoveWidth = self.contentSize.width - self.bounds.size.width; 5 if (newOriginalX > maxMoveWidth) { 6 newOriginalX = maxMoveWidth; 7 } 8 } 9 if (newOriginalY < 0) { 10 newOriginalY = 0; 11 } else { 12 CGFloat maxMoveHeight = self.contentSize.height - self.bounds.size.height; 13 if (newOriginalY > maxMoveHeight) { 14 newOriginalY = maxMoveHeight; 15 } 16 }
經過contentOffset設置scrollView的初始偏移量,相信你們已經懂了如何設置偏移量了吧?沒錯咱們只需設置view自身bounds的origin是實現代碼以下:
1 - (void)setContentOffset:(CGPoint)contentOffset { 2 _contentOffset = contentOffset; 3 CGRect newBounds = self.bounds; 4 newBounds.origin = contentOffset; 5 self.bounds = newBounds; 6 }
防止scrollView的子視圖超出scrollView
1 self.layer.masksToBounds = YES;
UIScrollView還有不少其它強大的功能,以上咱們只是完成了一個特別簡單的scrollView,之後若是有時間我會對它進行完善。固然若是你有興趣,你徹底能夠對它進行擴展,下載地址放在這裏。同時我也會繼續研究UIKit中其它控件的底層實現,歡迎您的持續關注!
UIScrollView
(包括它的子類 UITableView
和 UICollectionView
)是 iOS 開發中最經常使用也是最有意思的 UI 組件,大部分 App 的核心界面都是基於三者之一或三者的組合實現。UIScrollView
是 UIKit
中爲數很少能響應滑動手勢的 view,相比本身用 UIPanGestureRecognizer
實現一些基於滑動手勢的效果,用 UIScrollView
的優點在於 bounce 和 decelerate 等特性可讓 App 的用戶體驗與 iOS 系統的用戶體驗保持一致。本文經過一些實例講解 UIScrollView
的特性和實際使用中的經驗。
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,這樣作的好處有:
IBOutlet
)來調整 contentSize
Sample 中的 AutoLayout 演示了 UIScrollView
+ Auto Layout 的例子。
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
該方法從 iOS 5 引入,在 didEndDragging 前被調用,當 willEndDragging 方法中 velocity
爲 CGPointZero
(結束拖動時兩個方向都沒有速度)時,didEndDragging 中的 decelerate
爲 NO
,即沒有減速過程,willBeginDecelerating 和 didEndDecelerating 也就不會被調用。反之,當 velocity
不爲 CGPointZero
時,scroll view 會以 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 scroll view,didEndDecelerating 不會被調用,而且這時 scroll view 的 dragging
和 decelerating
屬性都是 YES
。新的 dragging 若是有加速度,那麼 willBeginDecelerating 會再一次被調用,而後纔是 didEndDecelerating;若是沒有加速度,雖然 willBeginDecelerating 不會被調用,但前一次留下的 didEndDecelerating 會被調用,因此連續快速滾動一個 scroll view 時,delegate 方法被調用的順序(不含 didScroll)多是這樣的:
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 方法的用途。
雖然這種優化方式在如今的機能和網絡環境下可能看似不那麼必要,但在我最初看到這個方法是的 09 年(印象中是 Tweetie 做者在 08 年寫的 Blog,可能有誤),遙想 iPhone 3G/3GS 的機能,這個方法爲多圖的 table view 的性能帶來很大的提高,也成了個人祕密武器。而如今,在移動網絡環境下,你依然值得這麼作來爲用戶節省流量。
先說一下原文的思路:
問題 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 只有圖片沒有文字。在 iOS 5 引入了 scrollViewWillEndDragging: withVelocity: targetContentOffset:
方法後,配合 SDWebImage
,我嘗試再優化了一下這個方法以提高用戶體驗:
targetContentOffset
能看到的 cell,正常加載,這樣一來,快速滾動的最後一屏出來的的過程當中,用戶就能看到目標區域的圖片逐漸加載核心代碼:
- (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
的做用。本例完整的代碼見 Sample 中的 LazyLoad
利用 UIScrollView 有多種方法實現分頁,可是各自的效果和用途不盡相同,其中方法 2 和方法 3 的區別也正是一些同類 App 在模仿 Glow 的首頁 Bubble 翻轉效果時跟 Glow 體驗上的的差距所在(希望他們不會看到本文而且調整他們的實現方式)。本例經過三種方法實現類似的一個場景,你能夠經過安裝到手機上來感覺三種實現方式的不一樣用戶體驗。爲了區分每一個例子的重點,本例沒有重用機制,重用相關內容見例 3。
這是系統提供的分頁方式,最簡單,可是有一些侷限性:
Sample 中 Pagination 有簡單實現 bleeding 和 padding 效果的代碼,主要的思路是:
clipsToBounds
爲 NO
適用場景:上述侷限性同時也是這種實現方式的優勢,好比通常 App 的引導頁(教程),Calendar 裏的月視圖,均可以用這種方法實現。
這種方法就是在 didEndDragging 且無減速動畫,或在減速動畫完成時,snap 到一個整數頁。核心算法是經過當前 contentOffset 計算最近的整數頁及其對應的 contentOffset,經過動畫 snap 到該頁。這個方法實現的效果都有個通病,就是最後的 snap 會在 decelerate 結束之後才發生,總感受很突兀。
經過修改 scrollViewWillEndDragging: 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 到整數天的場景;還適用於每頁大小不一樣的狀況下 snap 到整數頁的場景(不作舉例,自行發揮,其實只須要修改計算目標 offset 的方法)。
完整代碼參見 Pagination
大部分的 iOS 開發應該都清楚 UITableView
的 cell 重用機制,這種重用機制減小了內存開銷也提升了 performance,UIScrollView
做爲 UITableView
的父類,在不少場景中也很適合應用重用機制(其實不僅是 UIScrollView
,任何場景中會反覆出現的元素都應該適當地引入重用機制)。
你能夠參照 UITableView
的 cell 重用機制,總結重用機制以下:
removeFromSuperview
並加入重用隊列(enqueue)scrollViewDidScroll:
方法中完成實際使用中,須要注意的點是:
addChildeViewController
例 2 中的場景很適合以 view 爲重用單位,本例新增一個以 view controller 爲重用對象的例子,該例子同時演示了聯動效果,具體見下個例子。
完整代碼參見 Reuse
上一個例子裏 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。
轉自:http://tech.glowing.com/cn/