使用Autolayout實現UITableView的Cell動態布局和高度動態改變

原文地址:http://codingobjc.com/blog/2014/10/15/shi-yong-autolayoutshi-xian-uitableviewde-celldong-tai-bu-ju-he-ke-bian-xing-gao/index.html


本文翻譯自:stackoverflow

有人在stackoverflow上問了一個問題:

如何在UITableViewCell中使用Autolayout來實現Cell的內容和子視圖自動計算行高,并且保持平滑的滾動?

這個問題獲得了接近1000的支持和1100+的收藏,答案更是超過了1800+的支持,很詳細的說明了如何在iOS7和iOS8上實現UITableView的動態行高計算。答案對實現UICollectionView的動態行高也具有參考意義,所以在這里將這個答案翻譯了一下,希望對大家有所幫助。以下是答案的全文翻譯:

全文略長,不喜歡閱讀可以直接看示例代碼:

iOS8的示例代碼- iOS8以上才支持

iOS7的示例代碼- iOS7+

概念描述

不管在哪個iOS版本上進行開發,前兩步是必須的:

1、設置好布局約束

在UITableViewCell子類中,添加約束,使子視圖的邊緣與contentView的邊緣固定(pin)(最重要的是要有頂部和底部的邊距約束)。注意:不能將子視圖的邊緣設置成與cell的邊緣固定,只能設置為與contentView的邊緣固定!確保每個子視圖在垂直方向上的內容壓縮阻力(compression resistance)和內容吸附性約束(content hugging constraints)沒有被你添加的更高優先級的約束覆蓋,以使得這些子視圖的固有內容尺寸(intrinsic content size)來推動contentView的高度。(嗯?點擊英文中文。)

記住,重點是cell的子視圖與contentView要有垂直方向上的連結,讓它們能夠對contentView“施加壓力”,使contentView擴張以適合它們的尺寸。

下面用一個帶有一些子視圖的cell作為示例,展示了一些必要的約束(沒有展示全部的約束):

可以想象,當更多的文本被添加到“Multi-line body”那個label上面后,它就需要垂直地增高以適應文本,這實際上將強迫cell增加高度。(當然,前提是你需要把約束設置正確!)

設置正確的約束是使用Autolayout實現動態行高時最難也最重要的部分。如果你犯了一個錯誤,它可能使后面一切都無法工作——所以,不要著急,慢慢來!我建議你用代碼來設置約束,這樣你就完全知道每個約束被加到了什么地方,出問題的時候也更容易調試。特別是如果你利用好一些優秀的開源庫,使用代碼設置約束可以變得和使用Interface Builder設置約束一樣簡單,而且更加強大。這里有一個我設計、維護和使用的開源庫:https://github.com/smileyborg/PureLayout

如果你用代碼來設置約束,應該在UITableViewCell子類的updateConstraints方法里面一次性完成。注意,updateConstraints可能不止被調用一次,因此要避免重復添加相同的約束。在updateConstraints中,可以將添加約束的代碼包在一個if語句中(比如使用一個叫didSetupConstraints的布爾屬性,運行一次添加約束的代碼后就將其置為YES),以確保不重復添加相同的約束。另外,更新已有約束的代碼(比如調整約束的constant屬性),也應該將它們放置在updateConstraints中,但是要在didSetupConstraints條件語句的外面,這樣才能保證每次調用的時候都被執行。

譯注:上面這段updateConstraints中添加約束的描述,由于文章久遠,已經不合時宜。蘋果官方在WWDC2015 session219中已經給出了updateConstraints使用的新建議

2. 確立唯一的Cell重用標示符

為cell中每一組獨特的約束,使用一個唯一的cell重用標示符。也就是說,如果cell有不止一種布局,每一種布局都應當有其對應的重用標示符。(當cell有多種布局包含不同數量的子視圖的時候或者子視圖以不同的方式布局的時候,你就需要使用一個新的重用標示符。)

例如,要在一個cell中顯示一條email消息,可能會有4種不同的布局:第一種,只有主題;第二種,主題和正文;第三種,主題和圖片附件;第四種,主題、正文和圖片附件。每一種布局都需要完全不同的約束才能實現。因此,一旦cell被初始化并且約束被加到其中任意一種類型的cell上之后,cell應當得到一個唯一的重用標示符來指定該cell類型。這樣,當你dequeue重用cell的時候,該cell類型的約束已經添加好了,拿來即用。

注意,由于固有內容尺寸的不同,具有相同布局約束的cell仍然可能具有不同的高度!不要混淆了不同的布局(不同的約束)和由于不同的內容尺寸而計算出(通過相同的約束來計算)的不同的視圖frame這兩個概念,它們本質上是兩個完全不同的東西。(譯注:本段翻譯的不好,如果有疑惑,可以看看原文。)

不要將擁有不同布局約束的cell丟到同一個重用池中(也就是使用相同的重用標示符),然后又在每次dequeue過后企圖將舊的約束移除后從頭開始重新添加約束。內部自動布局引擎并沒有被設計來可以處理大規模的約束更改,你會看到大量的性能問題。

iOS8適用 - Self-Sizing Cells

3. 啟用行高估算

在iOS8上,蘋果將許多在之前你比較難實現的東西都內置實現了。為了讓cell實現自適應(self-sizing),必須先將tableView的rowHeight屬性設置為常量UITableViewAutomaticDimension。然后,只需將tableView的estimatedRowHeight屬性設置為一個非零值即可開啟行高估算功能,例如:

12

self.tableView.rowHeight=UITableViewAutomaticDimension;self.tableView.estimatedRowHeight=44.0;// 設置為一個接近于行高“平均值”的數值

這樣就為tableView提供了一個還沒有被顯示在屏幕上的cell的臨時估算的行高。當cell即將滾入屏幕范圍內的時候,會計算出真實的高度。為了確定每一行的實際高度,tableView會自動讓每個cell基于其contentView的已知固定寬度(tableView的寬度減去其他額外的,像section index或accessoryView這些寬度)和被添加到contentView及其子視圖上的布局約束來計算contentView的高度。真實的行高被計算出來之后,舊的估算的行高會被更新為這個真實的行高(并且其他任何需要對tableView的contentSize或contentOffset的更改都自動替你完成)。

一般來說,行高的估算值不需要太精確——它只是用來修正tableView中滾動條的尺寸的,當你在屏幕上滑動cell的時候,即使估算值不準確,tableView還是能很好地調節滾動條。將tableView的estimatedRowHeight屬性設置成(在viewDidLoad或類似的方法中)一個接近于行高“平均值”的常量值即可。僅在行高極端變化的時候(比如相差一個數量級),滾動過程中才會產生滾動條的“跳躍”現象。這個時候,你才需要考慮實現tableView:estimatedHeightForRowAtIndexPath:方法,為每一行返回一個更精確的估算值。

iOS7支持(自己實現cell尺寸自適應功能)

3. 完成一個完整的布局過程 & 獲得行高

首先,實例化一個離屏(offscreen)的cell實例,為每個重用標示符實例化一個與之對應的cell實例,這些cell實例嚴格的僅用于高度計算。(離屏表示cell的引用被存儲在view controller的一個屬性或實例變量之中,并且這個cell絕對不會被用作tableView:cellForRowAtIndexPath:方法的返回值顯示在屏幕上。)接下來,這個cell的內容(例如,文本、圖片等等)還必須被配置為與顯示在table view中的內容完全一樣。

然后,強制cell立即更新子視圖的布局,再在cell的contentView上調用systemLayoutSizeFittingSize:方法以計算出cell所需的高度。使用UILayoutFittingCompressedSize參數得到適合cell中所有內容所需的最小尺寸。然后將其高度作為tableView:heightForRowAtIndexPath:方法的返回值返回給table view。

4. 使用估算的行高

如果你的table view超過幾十行,你會發現在第一次加載table view的時候會卡住主線程。因為,在第一次加載的過程中,會對每一行調用tableView:heightForRowAtIndexPath:方法(為了計算滾動條的尺寸)。

iOS7中,你可以(也絕對應當)使用table view的estimatedRowHeight屬性。這樣會為還不在屏幕范圍內的cell提供一個臨時估算的行高值。然后,當這些cell即將要滾入屏幕范圍內的時候,真實的行高值會被計算出來(通過tableView:heightForRowAtIndexPath:方法),估算的行高會被替換掉。

一般來說,行高的估算值不需要太精確——它只是用來修正tableView中滾動條的尺寸的,當你在屏幕上滑動cell的時候,即使估算值不準確,tableView還是能很好地調節滾動條。將tableView的estimatedRowHeight屬性設置成(在viewDidLoad或類似的方法中)一個接近于行高“平均值”的常量值即可。僅在行高極端變化的時候(比如相差一個數量級),滾動過程中才會產生滾動條的“跳躍”現象。這個時候,你才需要考慮實現tableView:estimatedHeightForRowAtIndexPath:方法,為每一行返回一個更精確的估算值。

5. 緩存行高(如果需要)

如果上面提到的你都做了,但是tableView:heightForRowAtIndexPath:的性能仍然慢的不可接受。非常不幸,這個時候你需要給行高做一些緩存(這是蘋果的工程師們給出的改進建議)。大體的思路是,第一次計算時讓自動布局引擎解析布局約束計算行高,然后將計算出來的行高緩存起來,之后所有對該cell的高度請求都返回緩存值。當然,還要保證任何導致cell高度變化的情況發生時都要清除緩存的行高——這通常發生在cell的內容變化時或其他重大事件發生的時候(比如用戶調節了動態類型文本大小(Dynamic Type text size)的滑動條)。

iOS7示例代碼(包含詳細的注釋)

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687

-(UITableViewCell*)tableView:(UITableView*)tableViewcellForRowAtIndexPath:(NSIndexPath*)indexPath{// 判斷indexPath對應cell的重用標示符,// 取決于特定的布局需求(可能只有一個,也或者有多個)NSString*reuseIdentifier=...;// 取出重用標示符對應的cell。// 注意,如果重用池(reuse pool)里面沒有可用的cell,這個方法會初始化并返回一個全新的cell,// 因此無論怎樣,此行代碼過后,你會得到一個布局約束已經完全準備好,可以直接使用的cell。UITableViewCell*cell=[tableViewdequeueReusableCellWithIdentifier:reuseIdentifier];// 用indexPath對應的數據內容來配置cell,例如:// cell.textLabel.text = someTextForThisCell;// ...// 確保cell的布局約束已經被設置好,因為它可能剛剛才被創建。// 假設你已經在cell的updateConstraints方法中設置好了約束,使用下面兩行代碼:[cellsetNeedsUpdateConstraints];[cellupdateConstraintsIfNeeded];// 如果你使用了多行的UILabel,不要忘了給label設置正確的preferredMaxLayoutWidth值。// 如果你沒有在cell的layoutSubviews方法中設置,就需要在這里設置。例如:// cell.multiLineLabel.preferredMaxLayoutWidth = CGRectGetWidth(tableView.bounds);returncell;}-(CGFloat)tableView:(UITableView*)tableViewheightForRowAtIndexPath:(NSIndexPath*)indexPath{// 判斷indexPath對應cell的重用標示符,NSString*reuseIdentifier=...;// 從緩存字典中取出重用標示符對應的cell。如果沒有,就創建一個新的然后存儲在字典里面。// 警告:不要調用table view的dequeueReusableCellWithIdentifier:方法,因為這會導致cell被創建了但是又未曾被tableView:cellForRowAtIndexPath:方法返回,會造成內存泄露!// 譯注:原文這里說的dequeueReusableCellWithIdentifier:會造成內存泄漏的說法是錯誤的,并不會造成內存泄漏。UITableViewCell*cell=[self.offscreenCellsobjectForKey:reuseIdentifier];if(!cell){cell=[[YourTableViewCellClassalloc]init];[self.offscreenCellssetObject:cellforKey:reuseIdentifier];}// 用indexPath對應的數據內容來配置cell,例如:// cell.textLabel.text = someTextForThisCell;// ...// 確保cell的布局約束已經被設置好,因為它可能剛剛才被創建。// 假設你已經在cell的updateConstraints方法中設置好了約束,使用下面兩行代碼:[cellsetNeedsUpdateConstraints];[cellupdateConstraintsIfNeeded];// 將cell的寬度設置為與tableView的寬度一樣。// 這點很重要。// 如果cell的高度取決于table view的寬度(例如,多行的UILabel通過單詞換行等方式換行),// 那么這使得對于不同寬度的table view,我們都可以基于其寬度而得到cell的高度。// 但是,我們不需要在-[tableView:cellForRowAtIndexPath]方法中做相同的處理(設置寬度),// 因為,cell被用到table view中的時候,這一步是自動完成的。// 也要注意,某些情況下,cell的最終寬度可能不等于table view的寬度。// 例如當table view的右邊顯示了section index的時候,必須要減去這個寬度。cell.bounds=CGRectMake(0.0f,0.0f,CGRectGetWidth(tableView.bounds),CGRectGetHeight(cell.bounds));// 觸發cell的布局過程,會基于布局約束計算所有視圖的frame。// (注意,你必須在cell的layoutSubviews方法中給多行的UILabel設置好preferredMaxLayoutWidth值;// 或者在下面2行代碼前手動設置!)[cellsetNeedsLayout];[celllayoutIfNeeded];// 得到cell的contentView需要的真實高度CGFloatheight=[cell.contentViewsystemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;// 為cell的分割線加上額外的1pt高度。因為分隔線是被加在cell底邊與contentView底邊之間的。height+=1.0f;returnheight;}// 注意:除非行高極端變化并且你已經明顯的覺察到了滾動時滾動條的“跳躍”現象,你才需要實現此方法;否則,直接用tableView的estimatedRowHeight屬性即可。-(CGFloat)tableView:(UITableView*)tableViewestimatedHeightForRowAtIndexPath:(NSIndexPath*)indexPath{// 以最小計算量,返回實際高度數量級之內的一個行高估算值。// 例如://if([selfisTallCellAtIndexPath:indexPath]){return350.0f;}else{return40.0f;}}

示例項目

iOS8的示例代碼- iOS8以上才支持

iOS7的示例代碼- iOS7+

最后,推薦兩個相關的開源庫:

PureLayout:原文作者使用和開源的布局庫,用代碼寫布局約束的時候很方便。

UITableView-CellHeightCalculation:根據本文思路封裝的UITableView動態行高計算和行高緩存庫,由本人開源和維護。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容