可能你很難相信 UIScrollView 和一個(gè)標(biāo)準(zhǔn)的 UIView 差異并不大,scroll view 確實(shí)會(huì)多出一些方法,但這些方法只是和 UIView 的屬性很好的結(jié)合到一起了。因此,在要想弄懂 UIScrollView 是怎么工作之前,你需要先了解一下 UIView,特別是視圖渲染的兩步過程。
渲染過程的第一部分是眾所周知的光柵化(rasterization
),光柵化簡單的說就是產(chǎn)生一組繪圖指令并且生成一張圖片。比如繪制一個(gè)圓角矩形、帶圖片、標(biāo)題居中的 UIButtons。這些圖片并沒有被繪制到屏幕上去;取而代之的是,他們被自己的視圖保持著留到下一個(gè)步驟使用。
一旦每個(gè)視圖都產(chǎn)生了自己的光柵化圖片,這些圖片便被一個(gè)接一個(gè)的繪制,并產(chǎn)生一個(gè)屏幕大小的圖片,這便是上文所說的組合。視圖層級(view hierarchy)對于組合如何進(jìn)行扮演了很重要的角色:一個(gè)視圖的圖片被組合在它父視圖的圖片上面。然后,組合好的圖片被組合到父視圖的父視圖圖片上面。視圖層級最頂端是窗口(window),它組合好的圖片便是我們看到的東西了。
概念上,依次在每個(gè)視圖上放置獨(dú)立分層的圖片并最終產(chǎn)生一個(gè)圖片,單調(diào)的圖像更容易被理解,特別是如果你以前使用過像 Photoshop 這樣的工具。我們還有另外一篇文章詳細(xì)解釋了像素是如何繪制到屏幕上去的。
現(xiàn)在,回想一下,每個(gè)視圖都有一個(gè) bounds 和 frame。當(dāng)布局一個(gè)界面時(shí),我們需要處理視圖的 frame。這允許我們放置并設(shè)置視圖的大小。視圖的 frame 和 bounds 的大小總是一樣的,但是他們的 origin 有可能不同。弄懂這兩個(gè)工作原理是理解 UIScrollView 的關(guān)鍵。
在光柵化步驟中,視圖并不關(guān)心即將發(fā)生的組合步驟。也就是說,它并不關(guān)心自己的 frame (這是用來放置視圖的圖像)或自己在視圖層級中的位置(這是決定組合的順序)。這時(shí)視圖只關(guān)心一件事就是繪制它自己的 content。這個(gè)繪制發(fā)生在每個(gè)視圖的 drawRect:
方法中。
在 drawRect:
方法被調(diào)用前,會(huì)為視圖創(chuàng)建一個(gè)空白的圖片來繪制 content。這個(gè)圖片的坐標(biāo)系統(tǒng)是視圖的 bounds。幾乎每個(gè)視圖 bounds 的 origin 都是 {0,0}。因此,當(dāng)在光柵化圖片左上角繪制一些東西的時(shí)候,你都會(huì)在 bounds 的 origin {x:0, y:0} 處繪制。在一個(gè)圖片右下角的地方繪制東西的時(shí)候,你都會(huì)繪制在 {x:width, y:height} 處。如果你的繪制超出了視圖的 bounds,那么超出的部分就不屬于光柵化圖片的部分了,并且會(huì)被丟棄。
http://wiki.jikexueyuan.com/project/objc/images/3-3.png" alt="" />
在組合的步驟中,每個(gè)視圖將自己光柵化圖片組合到自己父視圖的光柵化圖片上面。視圖的 frame 決定了自己在父視圖中繪制的位置,frame 的 origin 表明了視圖光柵化圖片左上角相對父視圖光柵化圖片左上角的偏移量。所以,一個(gè) origin 為 {x:20, y:15} 的 frame 所繪制的圖片左邊距其父視圖 20 點(diǎn),上邊距父視圖 15 點(diǎn)。因?yàn)橐晥D的 frame 和 bounds 矩形的大小總是一樣的,所以光柵化圖片組合的時(shí)候是像素對齊的。這確保了光柵化圖片不會(huì)被拉伸或縮小。
http://wiki.jikexueyuan.com/project/objc/images/3-4.png" alt="" />
記住,我們才僅僅討論了一個(gè)視圖和它父視圖之間的組合操作。一旦這兩個(gè)視圖被組合到一起,組合的結(jié)果圖片將會(huì)和父視圖的父視圖進(jìn)行組合,這是一個(gè)雪球效應(yīng)。
考慮一下組合圖片背后的公式。視圖圖片的左上角會(huì)根據(jù)它 frame 的 origin 進(jìn)行偏移,并繪制到父視圖的圖片上:
CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;
CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;
正如之前所說的,如果一個(gè)視圖 bounds 的 origin 是 {0,0}。那么,我們得到這個(gè)公式:
CompositedPosition.x = View.frame.origin.x;
CompositedPosition.y = View.frame.origin.y;
我們可以通過幾個(gè)不同的 frames 看一下:
http://wiki.jikexueyuan.com/project/objc/images/3-5.png" alt="" />
這樣做是有道理的,我們改變 button 的 frame.origin
后,它會(huì)改變自己相對紫色父視圖的位置。注意,如果我們移動(dòng) button 直到它的一部分已經(jīng)在紫色父視圖 bounds 的外面,當(dāng)光柵化圖片被截去時(shí)這部分也將會(huì)通過同樣的繪制方式被截去。然而,技術(shù)上講,因?yàn)?iOS 處理組合方法的原因,你可以將一個(gè)子視圖渲染在其父視圖的 bounds 之外,但是光柵化期間的繪制不可能超出一個(gè)視圖的 bounds。
現(xiàn)在我們所講的跟 UIScrollView 有什么關(guān)系呢?一切都和它有關(guān)!考慮一種我們可以實(shí)現(xiàn)的滾動(dòng):我們有一個(gè)拖動(dòng)時(shí) frame 不斷改變的視圖。這達(dá)到了相同的效果,對嗎?如果我拖動(dòng)我的手指到右邊,那么拖動(dòng)的同時(shí)我增大視圖的 origin.x
,瞧,這貨就是 scroll view。
當(dāng)然,在 scroll view 中有很多具有代表性的視圖。為了實(shí)現(xiàn)這個(gè)平移功能,當(dāng)用戶移動(dòng)手指時(shí),你需要時(shí)刻改變每個(gè)視圖的 frames。當(dāng)我們提到組合一個(gè) view 的光柵化圖片到它父視圖什么地方時(shí),記住這個(gè)公式:
CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;
CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;
我們減少 Superview.bounds.origin
的值(因?yàn)樗麄兛偸?)。但是如果他們不為0呢?我們用和前一個(gè)圖例相同的 frames,但是我們改變了紫色視圖 bounds 的 origin 為 {-30, -30}。得到下圖:
http://wiki.jikexueyuan.com/project/objc/images/3-6.png" alt="" />
現(xiàn)在,巧妙的是通過改變這個(gè)紫色視圖的 bounds,它每一個(gè)單獨(dú)的子視圖都被移動(dòng)了。事實(shí)上,這正是 scroll view 工作的原理。當(dāng)你設(shè)置它的 contentOffset 屬性時(shí)它改變 scroll view.bounds
的 origin。事實(shí)上,contentOffset 甚至不是實(shí)際存在的。代碼看起來像這樣:
- (void)setContentOffset:(CGPoint)offset
{
CGRect bounds = [self bounds];
bounds.origin = offset;
[self setBounds:bounds];
}
注意前一個(gè)圖例,只要足夠的改變 bounds 的 origin,button 將會(huì)超出紫色視圖和 button 組合成的圖片的范圍。這也是當(dāng)你足夠的移動(dòng) scroll view 時(shí),一個(gè)視圖會(huì)消失!
現(xiàn)在,最難的部分已經(jīng)過去了,我們再看看 UIScrollView 另一個(gè)屬性:contentSize。 scroll view 的 content size 并不會(huì)改變其 bounds 的任何東西,所以這并不會(huì)影響 scroll view 如何組合自己的子視圖。反而,content size 定義了可滾動(dòng)區(qū)域。scroll view 的默認(rèn) content size 為 {w:0, h:0}。既然沒有可滾動(dòng)區(qū)域,用戶是不可以滾動(dòng)的,但是 scroll view 仍然會(huì)顯示其 bounds 范圍內(nèi)所有的子視圖。 當(dāng) content size 設(shè)置為比 bounds 大的時(shí)候,用戶就可以滾動(dòng)視圖了。你可以認(rèn)為 scroll view 的 bounds 為可滾動(dòng)區(qū)域上的一個(gè)窗口:
http://wiki.jikexueyuan.com/project/objc/images/3-7.png" alt="" />
當(dāng) content offset 為 {x:0, y:0} 時(shí),可見窗口的左上角在可滾動(dòng)區(qū)域的左上角處。這也是 content offset 的最小值;用戶不能再往可滾動(dòng)區(qū)域的左邊或上邊移動(dòng)了。那兒沒啥,別滾了!
content offset 的最大值是 content size 和 scroll view size 的差(不同于 content size 和scroll view的 bounds 大小)。這也在情理之中:從左上角一直滾動(dòng)到右下角,用戶停止時(shí),滾動(dòng)區(qū)域右下角邊緣和滾動(dòng)視圖 bounds 的右下角邊緣是齊平的。你可以像這樣記下 content offset 的最大值:
contentOffset.x = contentSize.width - bounds.size.width;
contentOffset.y = contentSize.height - bounds.size.height;
contentInset 屬性可以改變 content offset 的最大和最小值,這樣便可以滾動(dòng)出可滾動(dòng)區(qū)域。它的類型為 UIEdgeInsets,包含四個(gè)值:{top,left,bottom,right}。當(dāng)你引進(jìn)一個(gè) inset 時(shí),你改變了 content offset 的范圍。比如,設(shè)置 content inset 頂部值為 10,則允許 content offset 的 y 值達(dá)到 10。這介紹了可滾動(dòng)區(qū)域周圍的填充。
http://wiki.jikexueyuan.com/project/objc/images/3-8.png" alt="" />
這咋一看好像沒什么用。實(shí)際上,為什么不僅僅增加 content size 呢?除非沒辦法,否則你需要避免改變scroll view 的 content size。想要知道為什么?想想一個(gè) table view(UItableView是UIScrollView 的子類,所以它有所有相同的屬性),table view 為了適應(yīng)每一個(gè)cell,它的可滾動(dòng)區(qū)域是通過精心計(jì)算的。當(dāng)你滾動(dòng)經(jīng)過 table view 的第一個(gè)或最后一個(gè) cell 的邊界時(shí),table view將 content offset 彈回并復(fù)位,所以 cells 又一次恰到好處的緊貼 scroll view 的 bounds。
當(dāng)你想要使用 UIRefreshControl 實(shí)現(xiàn)拉動(dòng)刷新時(shí)發(fā)生了什么?你不能在 table view 的可滾動(dòng)區(qū)域內(nèi)放置 UIRefreshControl,否則,table view 將會(huì)允許用戶通過 refresh control 中途停止?jié)L動(dòng),并且將 refresh control 的頂部彈回到視圖的頂部。因此,你必須將 refresh control 放在可滾動(dòng)區(qū)域上方。這將允許首先將 content offset 彈回第一行,而不是 refresh control。
但是等等,如果你通過滾動(dòng)足夠多的距離初始化 pull-to-refresh 機(jī)制,因?yàn)?table view 設(shè)置了 content inset,這將允許 content offset 將 refresh control 彈回到可滾動(dòng)區(qū)域。當(dāng)刷新動(dòng)作被初始化時(shí),content inset 已經(jīng)被校正過,所以 content offset 的最小值包含了完整的 refresh control。當(dāng)刷新完成后,content inset 恢復(fù)正常,content offset 也跟著適應(yīng)大小,這里并不需要為content size 做數(shù)學(xué)計(jì)算。(這里可能比較難理解,建議看看 EGOTableViewPullRefresh 這樣的類庫就應(yīng)該明白了)
如何在自己的代碼中使用 content inset?當(dāng)鍵盤在屏幕上時(shí),有一個(gè)很好的用途:你想要設(shè)置一個(gè)緊貼屏幕的用戶界面。當(dāng)鍵盤出現(xiàn)在屏幕上時(shí),你損失了幾百個(gè)像素的空間,鍵盤下面的東西全都被擋住了。
現(xiàn)在,scroll view 的 bounds 并沒有改變,content size 也并沒有改變(也不需要改變)。但是用戶不能滾動(dòng) scroll view??紤]一下之前一個(gè)公式:content offset 的最大值是 content size 和 bounds 的差。如果他們相等,現(xiàn)在 content offset 的最大值是 {x:0, y:0}.
現(xiàn)在開始出絕招,將界面放入一個(gè) scroll view。scroll view 的 content size 仍然和 scroll view 的 bounds 一樣大。當(dāng)鍵盤出現(xiàn)在屏幕上時(shí),你設(shè)置 content inset 的底部等于鍵盤的高度。
http://wiki.jikexueyuan.com/project/objc/images/3-9.png" alt="" />
這允許在 content offset 的最大值下顯示滾動(dòng)區(qū)域外的區(qū)域。可視區(qū)域的頂部在 scroll view bounds 的外面,因此被截取了(雖然它在屏幕之外了,但這并沒有什么)。
但愿這能讓你理解一些滾動(dòng)視圖內(nèi)部工作的原理,你對縮放感興趣?好吧,我們今天不會(huì)談?wù)撍?,但是這兒有一個(gè)有趣的小竅門:檢查 viewForZoomingInScrollView:
方法返回視圖的 transform 屬性。你將再次發(fā)現(xiàn) scroll view 只是聰明的利用了 UIView 已經(jīng)存在的屬性。
相關(guān)鏈接(強(qiáng)烈推薦):