UIScrollView深度解析

關於scrollView的思考

  在iOS開發中我們會大量用到scrollView這個控件,我們使用的tableView/collectionview/textView都繼承自它。scrollView的頻繁使用讓我對它的底層實現產生了興趣,它到底是如何工作的?如何實現一個scrollView?讀完本篇博客,相信你一定也可以自己實現一個簡易的scrollView。

我們首先來思考以下幾個問題:

(1). scrollView繼承自誰,它如何檢測到手指滑動?

(2). scrollView如何實現滾動?

(3). scrollView裏的各種屬性是如何實現的?如contentSize/contentOffSet……

通過一步步解決上邊的問題,我們就能實現一個自己的scrollView。

一步一步實現scrollView

1. 毫無疑問我們都知道scrollView繼承自UIView,檢測手指滑動應該是在view上放置了一個手勢識別,實現代碼如下:

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] init];
        [panGesture addTarget:self action:@selector(panGestureAction:)];
        [self addGestureRecognizer:panGesture];
    }
    return self;
}

2. 要搞清楚第二個問題,首先我們必須理解frame和bounds的概念。

  提到它們,大家都知道frame是相對於父視圖座標系來說自己的位置和尺寸,bounds相對於自身座標系來說的位置和尺寸,並且origin一般爲(0,0)。但是bounds的origin有什麼用處?改變它會出現什麼效果呢?

  當我們嘗試改變bounds的origin時,我們就會發現視圖本身沒有發生變化,但是它的子視圖的位置卻發生了變化,why???其實當我們改變bounds的origin的時候,視圖本身位置沒有變化,但是由於origin的值是基於自身的座標系,所以自身座標系的位置被我們改變了。而子視圖的frame正是基於父視圖的座標系,當我們更改父視圖bounds中origin的時候子視圖的位置就發生了變化,這就是實現scrollView的關鍵點!!!

  是不是很好理解?
  基於這點我們很容易實現一個簡單的最初級版本的scrollView,代碼如下:

- (void)panGestureAction:(UIPanGestureRecognizer *)pan {
    // 記錄每次滑動開始時的初始位置
    if (pan.state == UIGestureRecognizerStateBegan) {
        self.startLocation = self.bounds.origin;
        NSLog(@"%@", NSStringFromCGPoint(self.startLocation));
    }

    // 相對於初始觸摸點的偏移量
    if (pan.state == UIGestureRecognizerStateChanged) {
        CGPoint point = [pan translationInView:self];
        NSLog(@"%@", NSStringFromCGPoint(point));
        CGFloat newOriginalX = self.startLocation.x - point.x;
        CGFloat newOriginalY = self.startLocation.y - point.y;

        CGRect bounds = self.bounds;
        bounds.origin = CGPointMake(newOriginalX, newOriginalY);
        self.bounds = bounds;
    }
}

3. 理解了上邊內容的關鍵點,下邊我們將對我們實現的scrollView做一個簡單的優化。

  通過contentSize限制scrollView的內部空間,實現代碼如下

if (newOriginalX < 0) {
    newOriginalX = 0;
} else {
    CGFloat maxMoveWidth = self.contentSize.width - self.bounds.size.width;
    if (newOriginalX > maxMoveWidth) {
        newOriginalX = maxMoveWidth;
    }
}
if (newOriginalY < 0) {
    newOriginalY = 0;
} else {
    CGFloat maxMoveHeight = self.contentSize.height - self.bounds.size.height;
    if (newOriginalY > maxMoveHeight) {
        newOriginalY = maxMoveHeight;
    }
}

  通過contentOffset設置scrollView的初始偏移量,相信大家已經懂了如何設置偏移量了吧?沒錯我們只需設置view自身bounds的origin是實現

- (void)setContentOffset:- - (CGPoint)contentOffset {
    _contentOffset = contentOffset;
    CGRect newBounds = self.bounds;
    newBounds.origin = contentOffset;
    self.bounds = newBounds;
 }

  防止scrollView的子視圖超出scrollView

self.layer.masksToBounds = YES;


UIScrollView 實踐經驗

  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


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

  該方法從 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:  

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