原文?理解 Scroll Views
可能你很難相信 UIScrollView 和一個(gè)標(biāo)準(zhǔn)的 UIView 差異并不大屁奏,scroll view 確實(shí)會(huì)多出一些方法,但這些方法只是和 UIView 的屬性很好的結(jié)合到一起了饼煞。因此,在要想弄懂 UIScrollView 是怎么工作之前泣特,你需要先了解一下 UIView基显,特別是視圖渲染的兩步過(guò)程。
光柵化和組合
渲染過(guò)程的第一部分是眾所周知的光柵化(rasterization)侥猬,光柵化簡(jiǎn)單的說(shuō)就是產(chǎn)生一組繪圖指令并且生成一張圖片例驹。比如繪制一個(gè)圓角矩形、帶圖片退唠、標(biāo)題居中的 UIButtons鹃锈。這些圖片并沒(méi)有被繪制到屏幕上去;取而代之的是瞧预,他們被自己的視圖保持著留到下一個(gè)步驟使用屎债。
一旦每個(gè)視圖都產(chǎn)生了自己的光柵化圖片仅政,這些圖片便被一個(gè)接一個(gè)的繪制,并產(chǎn)生一個(gè)屏幕大小的圖片盆驹,這便是上文所說(shuō)的組合圆丹。視圖層級(jí)(view hierarchy)對(duì)于組合如何進(jìn)行扮演了很重要的角色:一個(gè)視圖的圖片被組合在它父視圖的圖片上面。然后躯喇,組合好的圖片被組合到父視圖的父視圖圖片上面辫封。視圖層級(jí)最頂端是窗口(window),它組合好的圖片便是我們看到的東西了廉丽。
概念上倦微,依次在每個(gè)視圖上放置獨(dú)立分層的圖片并最終產(chǎn)生一個(gè)圖片,單調(diào)的圖像更容易被理解正压,特別是如果你以前使用過(guò)像 Photoshop 這樣的工具欣福。我們還有另外一篇文章詳細(xì)解釋了像素是如何繪制到屏幕上去的。
現(xiàn)在焦履,回想一下劣欢,每個(gè)視圖都有一個(gè) bounds 和 frame。當(dāng)布局一個(gè)界面時(shí)裁良,我們需要處理視圖的 frame。這允許我們放置并設(shè)置視圖的大小校套。視圖的 frame 和 bounds 的大小通常是一樣的(雖然可以被 transforms 改變)价脾,但是他們的 origin 經(jīng)常是不同的。弄懂這兩個(gè)工作原理是理解 UIScrollView 的關(guān)鍵笛匙。
在光柵化步驟中侨把,視圖并不關(guān)心即將發(fā)生的組合步驟。也就是說(shuō)妹孙,它并不關(guān)心自己的 frame (這是用來(lái)放置視圖的圖像)或自己在視圖層級(jí)中的位置(這是決定組合的順序)秋柄。這時(shí)視圖只關(guān)心一件事就是繪制它自己的 content。這個(gè)繪制發(fā)生在每個(gè)視圖的 drawRect: 方法中蠢正。
在 drawRect: 方法被調(diào)用前骇笔,會(huì)為視圖創(chuàng)建一個(gè)空白的圖片來(lái)繪制 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ì)被丟棄串慰。
在組合的步驟中偏塞,每個(gè)視圖將自己光柵化圖片組合到自己父視圖的光柵化圖片上面。視圖的 frame 決定了自己在父視圖中繪制的位置模庐,frame 的 origin 表明了視圖光柵化圖片左上角相對(duì)父視圖光柵化圖片左上角的偏移量烛愧。所以,一個(gè) origin 為 {x:20, y:15} 的 frame 所繪制的圖片左邊距其父視圖 20 點(diǎn)掂碱,上邊距父視圖 15 點(diǎn)怜姿。因?yàn)橐晥D的 frame 和 bounds 矩形的大小總是一樣的,所以光柵化圖片組合的時(shí)候是像素對(duì)齊的疼燥。這確保了光柵化圖片不會(huì)被拉伸或縮小沧卢。
記住,我們才僅僅討論了一個(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;
正如之前所說(shuō)的剥槐,如果一個(gè)視圖 bounds 的 origin 是 {0,0}唱歧。那么,我們得到這個(gè)公式:
CompositedPosition.x = View.frame.origin.x;
CompositedPosition.y = View.frame.origin.y;
我們可以通過(guò)幾個(gè)不同的 frames 看一下:
這樣做是有道理的粒竖,我們改變 button 的 frame.origin后颅崩,它會(huì)改變自己相對(duì)紫色父視圖的位置。注意蕊苗,如果我們移動(dòng) button 直到它的一部分已經(jīng)在紫色父視圖 bounds 的外面沿后,當(dāng)光柵化圖片被截去時(shí)這部分也將會(huì)通過(guò)同樣的繪制方式被截去。然而朽砰,技術(shù)上講尖滚,因?yàn)?iOS 處理組合方法的原因,你可以將一個(gè)子視圖渲染在其父視圖的 bounds 之外瞧柔,但是光柵化期間的繪制不可能超出一個(gè)視圖的 bounds熔掺。
Scroll View 的 Content Offset
現(xiàn)在我們所講的跟 UIScrollView 有什么關(guān)系呢?一切都和它有關(guān)非剃!考慮一種我們可以實(shí)現(xiàn)的滾動(dòng):我們有一個(gè)拖動(dòng)時(shí) frame 不斷改變的視圖置逻。這達(dá)到了相同的效果,對(duì)嗎备绽?如果我拖動(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}筹淫。得到下圖:
現(xiàn)在,巧妙的是通過(guò)改變這個(gè)紫色視圖的 bounds呢撞,它每一個(gè)單獨(dú)的子視圖都被移動(dòng)了损姜。事實(shí)上,這正是 scroll view 工作的原理殊霞。當(dāng)你設(shè)置它的 contentOffset 屬性時(shí)它改變 scroll view.bounds 的 origin摧阅。事實(shí)上,contentOffset 甚至不是實(shí)際存在的绷蹲。代碼看起來(lái)像這樣:
- (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ì)消失岩齿!
世界之窗:Content Size
現(xiàn)在太颤,最難的部分已經(jīng)過(guò)去了,我們?cè)倏纯?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}。既然沒(méi)有可滾動(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è)窗口:
當(dāng) content offset 為 {x:0, y:0} 時(shí)监徘,可見(jiàn)窗口的左上角在可滾動(dòng)區(qū)域的左上角處。這也是 content offset 的最小值吧碾;用戶不能再往可滾動(dòng)區(qū)域的左邊或上邊移動(dòng)了凰盔。那兒沒(méi)啥,別滾了倦春!
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;
用 Content Insets 對(duì)窗口稍作調(diào)整
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ū)域周圍的填充。
這咋一看好像沒(méi)什么用来颤。實(shí)際上汰扭,為什么不僅僅增加 content size 呢?除非沒(méi)辦法福铅,否則你需要避免改變scroll view 的 content size萝毛。想要知道為什么?想想一個(gè) table view(UItableView是UIScrollView 的子類滑黔,所以它有所有相同的屬性)笆包,table view 為了適應(yīng)每一個(gè)cell,它的可滾動(dòng)區(qū)域是通過(guò)精心計(jì)算的略荡。當(dāng)你滾動(dòng)經(jīng)過(guò) 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ì)允許用戶通過(guò) refresh control 中途停止?jié)L動(dòng)验毡,并且將 refresh control 的頂部彈回到視圖的頂部衡创。因此,你必須將 refresh control 放在可滾動(dòng)區(qū)域上方晶通。這將允許首先將 content offset 彈回第一行璃氢,而不是 refresh control。
但是等等狮辽,如果你通過(guò)滾動(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)被校正過(guò),所以 content offset 的最小值包含了完整的 refresh control树叽。當(dāng)刷新完成后舆蝴,content inset 恢復(fù)正常,content offset 也跟著適應(yīng)大小题诵,這里并不需要為content size 做數(shù)學(xué)計(jì)算洁仗。(這里可能比較難理解,建議看看 EGOTableViewPullRefresh 這樣的類庫(kù)就應(yīng)該明白了)
如何在自己的代碼中使用 content inset性锭?當(dāng)鍵盤在屏幕上時(shí)赠潦,有一個(gè)很好的用途:你想要設(shè)置一個(gè)緊貼屏幕的用戶界面。當(dāng)鍵盤出現(xiàn)在屏幕上時(shí)草冈,你損失了幾百個(gè)像素的空間她奥,鍵盤下面的東西全都被擋住了。
現(xiàn)在怎棱,scroll view 的 bounds 并沒(méi)有改變哩俭,content size 也并沒(méi)有改變(也不需要改變)。但是用戶不能滾動(dòng) scroll view拳恋》沧剩考慮一下之前一個(gè)公式:content offset 的最大值是 content size 和 bounds 的差。如果他們相等诅岩,現(xiàn)在 content offset 的最大值是 {x:0, y:0}.
現(xiàn)在開(kāi)始出絕招,將界面放入一個(gè) scroll view带膜。scroll view 的 content size 仍然和 scroll view 的 bounds 一樣大吩谦。當(dāng)鍵盤出現(xiàn)在屏幕上時(shí),你設(shè)置 content inset 的底部等于鍵盤的高度膝藕。
這允許在 content offset 的最大值下顯示滾動(dòng)區(qū)域外的區(qū)域式廷。可視區(qū)域的頂部在 scroll view bounds 的外面芭挽,因此被截取了(雖然它在屏幕之外了滑废,但這并沒(méi)有什么)蝗肪。
但愿這能讓你理解一些滾動(dòng)視圖內(nèi)部工作的原理,你對(duì)縮放感興趣蠕趁?好吧薛闪,我們今天不會(huì)談?wù)撍沁@兒有一個(gè)有趣的小竅門:檢查 viewForZoomingInScrollView: 方法返回視圖的 transform 屬性俺陋。你將再次發(fā)現(xiàn) scroll view 只是聰明的利用了 UIView 已經(jīng)存在的屬性豁延。