iOS開發中的Scroll View應用詳解

answerhuang的部落格發表於2014-12-01

可能你很難相信,UIScrollView和一個標準的UIView差異並不大,scroll view確實會多一些方法,但這些方法只是UIView一些屬性的表面而已。因此,要想弄懂UIScrollView是怎麼工作之前,你需要了解 UIView,特別是檢視渲染過程的兩步。

光柵化和組合

渲染過程的第一部分是眾所周知的光柵化,光柵化簡單的說就是產生一組繪圖指令並且生成一張圖片。比如繪製一個圓角矩形、帶圖片、標題居中的UIButtons。這些圖片並沒有被繪製到螢幕上去;取而代之的是,他們被自己的檢視保持著留到下一個步驟用。

一旦每個檢視都產生了自己的光柵化圖片,這些圖片便被一個接一個的繪製,併產生一個螢幕大小的圖片,這便是上文所說的組合。檢視層級(view hierarchy)對於組合如何進行扮演了很重要的角色:一個檢視的圖片被組合在它父檢視圖片的上面。然後,組合好的圖片被組合到父檢視的父檢視圖片上 面,就這樣。。。最終檢視層級最頂端是視窗(window),它組合好的圖片便是我們看到的東西了。

概念上,依次在每個檢視上放置獨立分層的圖片並最終產生一個圖片,單調的影像將會變得更容易理解,特別是如果你以前使用過像Photoshop這樣的工具。

現在,回想一下,每個檢視都有一個bounds和frame。當佈局一個介面時,我們需要處理檢視的frame。這允許我們放置並設定檢視的大小。 檢視的frame和bounds的大小總是一樣的,但是他們的origin有可能不同。弄懂這兩個工作原理是理解UIScrollView的關鍵。

在光柵化步驟中,檢視並不關心即將發生的組合步驟。也就是說,它並不關心自己的frame(這是用來放置檢視的影像)或自己在檢視層級中的位置(這 是決定組合的順序)。這時檢視只關心一件事就是繪製它自己的content。這個繪製發生在每個檢視的drawRect:方法中。

在drawRect:方法被呼叫前,會為檢視建立一個空白的圖片來繪製content。這個圖片的座標系統是檢視的bounds。幾乎每個檢視 bounds的origin都是{0,0}。因此,當在刪格化圖片左上角繪製一些東西的時候,你都會在bounds的origin({x:0,y:0}) 處繪製。在一個圖片右下角的地方繪製東西的時候,你都會繪製在{x:width, y:height}處。如果你的繪製超出了檢視的bounds,那麼超出的部分就不屬於刪格化圖片的部分了,並且會被丟棄。

在組合的步驟中,每個檢視將自己光柵化圖片組合到自己父檢視的光柵化圖片上面。檢視的frame決定了自己在父檢視中繪製的位置,frame的 origin表明了檢視光柵化圖片左上角相對父檢視光柵化圖片左上角的偏移量。所以,一個origin為{x:20,y:15}的frame所繪製的圖片 左邊距其父檢視20點,上邊距父檢視15點。因為檢視的frame和bounds矩形的大小總是一樣的,所以光柵化圖片組合的時候是畫素對齊的。這確保了 光柵化圖片不會被拉伸或縮小。

記住,我們才僅僅討論了一個檢視和它父檢視之間的組合操作。一旦這兩個檢視被組合到一起,組合的結果圖片將會和父檢視的父檢視進行組合。。。這是一個雪球效應。

考慮一下組合圖片背後的公式。檢視圖片的左上角會根據它frame的origin進行偏移,並繪製到父檢視的圖片上:

CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;  

CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;

我們可以通過幾個不同的frames看一下:

這樣做是有道理的。我們改變button的frame.origin後,它會改變自己相對紫色父檢視的位置。注意,如果我們移動button直到它 的一部分已經在紫色父檢視bounds的外面,當光柵化圖片被截去時這部分也將會通過同樣的繪製方式被截去。然而,技術上講,因為iOS處理組合方法的原 因,你可以將一個子檢視渲染在其父檢視的bounds之外,但是光柵化期間的繪製不可能超出一個檢視的bounds。

Scroll View的Content Offset

現在,我們所講的跟UIScrollView有什麼關係呢?一切都和它有關!考慮一種我們可以實現的滾動:我們有一個拖動時frame不斷改變的視 圖。這達到了相同的效果,對嗎?如果我拖動我的手指到右邊,那麼拖動的同時我增大檢視的origin.x,瞧,這貨就是scroll view。

當然,在scroll view中有很多具有代表性的檢視。為了實現這個平移功能,當使用者移動手指時,你需要時刻改變每個檢視的frames。當我們提出組合一個view的光柵化圖片到它父檢視什麼地方時,記住這個公式:

CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;  

CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;

我們減少Superview.bounds.origin的值(因為他們總是0)。但是如果他們不為0呢?我們用和前一個圖例相同的frames,但是我們改變了紫色檢視bounds的origin為{-30,-30}。得到下圖:

現在,巧妙的是通過改變這個紫色檢視的bounds,它每一個單獨的子檢視都被移動了。事實上,這正是一個scroll view工作的原理。當你設定它的contentOffset屬性時:它改變scroll view.bounds的origin。事實上,contentOffset甚至不是實際存在的。程式碼看起來像這樣:

- (void)setContentOffset:(CGPoint)offset  
{  
    CGRect bounds = [self bounds];  
    bounds.origin = offset;  
    [self setBounds:bounds];  
}

注意:前一個圖例,只要足夠的改變bounds的origin,button將會超出紫色檢視和button組合成的圖片的範圍。這也是當你足夠的移動scroll view時,一個檢視會消失!

世界之窗:Content Size

現在,最難的部分已經過去了,我們再看看UIScrollView另一個屬性:contentSize。scroll view的content size並不會改變其bounds的任何東西,所以這並不會影響scroll view如何組合自己的子檢視。反而,content size定義了可滾動區域。scroll view的預設content size為{w:0,h:0}。既然沒有可滾動區域,使用者是不可以滾動的,但是scroll view任然會顯示其bounds範圍內所有的子檢視。

當content size設定為比bounds大的時候,使用者就可以滾動檢視了。你可以認為scroll view的bounds為可滾動區域上的一個視窗:

當content offset為{x:0,y:0}時,可見視窗的左上角在可滾動區域的左上角處。這也是content offset的最小值;使用者不能再往可滾動區域的左邊或上邊移動了。那兒沒啥,別滾了!

content offset的最大值是content size和scroll view size的差。這也在情理之中:從左上角一直滾動到右下角,使用者停止時,滾動區域右下角邊緣和滾動檢視bounds的右下角邊緣是齊平的。你可以像這樣記 下content offset的最大值:

contentOffset.x = contentSize.width - bounds.size.width;  
contentOffset.y = contentSize.height - bounds.size.height;

用Content Insets對視窗稍作調整

contentInset屬性可以改變content offset的最大和最小值,這樣便可以滾動出可滾動區域。它的型別為UIEdgeInsets,包含四個值: {top,left,bottom,right}。當你引進一個inset時,你改變了content offset的範圍。比如,設定content inset頂部值為10,則允許content offset的y值達到10。這介紹了可滾動區域周圍的填充。

這咋一看好像沒什麼用。實際上,為什麼不僅僅增加content size呢?除非沒辦法,否則你需要避免改變scroll view的content size。想要知道為什麼?想想一個table view(UItableView是UIScrollView的子類,所以它有所有相同的屬性),table view為了適應每一個cell,它的可滾動區域是通過精心計算的。當你滾動經過table view的第一個或最後一個cell的邊界時,table view將content offset彈回並復位,所以cells又一次恰到好處的緊貼scroll view的bounds。

當你想要使用UIRefreshControl實現拉動重新整理時發生了什麼?你不能在table view的可滾動區域內放置UIRefreshControl,否則,table view將會允許使用者通過refresh control中途停止滾動,並且將refresh control的頂部彈回到檢視的頂部。因此,你必須將refresh control放在可滾動區域上方。這將允許首先將content offset彈回第一行,而不是refresh control。

但是等等,如果你通過滾動足夠多的距離初始化pull-to-refresh機制,因為table view設定了content inset,這將允許content offset將refresh control彈回到可滾動區域。當重新整理動作被初始化時,content inset已經被校正過,所以content offset的最小值包含了完整的refresh control。當重新整理完成後,content inset恢復正常,content offset也跟著適應大小,這裡並不需要為content size做數學計算。(這裡可能比較難理解,建議看看EGOTableViewPullRefresh這樣的類庫就應該明白了)

如何在自己的程式碼中使用content inset?當鍵盤在螢幕上時,有一個很好的用途:你想要設定一個緊貼螢幕的使用者介面。當鍵盤出現在螢幕上時,你損失了幾百個畫素的空間,鍵盤下面的東西全都被擋住了。

現在,scroll view的bounds並沒有改變,content size也並沒有改變(也不需要改變)。但是使用者不能滾動scroll view。考慮一下之前一個公式:content offset的最大值並不同於content size和bounds的大小。如果他們相等,現在content offset的最大值是{x:0,y:0}.

現在開始出絕招,將介面放入一個scroll view。scroll view的content size仍然和scroll view的bounds一樣大。當鍵盤出現在螢幕上時,你設定content inset的底部等於鍵盤的高度。

這允許在content offset的最大值下顯示滾動區域外的區域。可視區域的頂部在scroll view bounds的外面,因此被擷取了(雖然它在螢幕之外了,但這並沒有什麼)。

但願這能讓你理解一些滾動檢視內部工作的原理,你對縮放感興趣?好吧,我們今天不會談論它,但是這兒有一個有趣的小竅門:檢查 viewForZoomingInScrollView:方法返回檢視的transform屬性。你將再次發現scroll view只是聰明的利用了UIView已經存在的屬性。

相關文章