如何在UITableViewCell中使用Autolayout來實現Cell的內容和子視圖自動計算行高,並且能夠保持平滑滾動的?
這個問題得到了300+的支持和450+的收藏,答案得到了730+的支持,很詳細的說明了如何在iOS7和iOS8上實現UITableView的動態行高功能,並且這個答案對實現UICollectionView的動態行高也具有參考意義。所以在這裡將這個答案翻譯了一下,希望對大家有所幫助。以下是答案的全文翻譯:
不管你是在哪個iOS版本上做開發,以下步驟中的前兩個步驟都是必須的:
在UITableViewCell
子類中,添加布局約束,使得cell子視圖的邊緣固定(pin)到cell的contentView的邊緣(最重要的是要有頂部和底部的邊距約束條件)。注意:不要將子視圖的邊距約束固定到cell本身上了,只能固定到cell的contentView上!確保每個子視圖垂直方向上的內容壓縮阻力(compression resistance)和吸附性約束(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
條件語句的外面,這樣才可以確保每次調用的時候都會被執行。
在cell裡面,為每一組特定的約束條件,使用一個特定的cell重用標示符。換句話說,如果cell有多種不同的布局,每一種布局應當有其對應的重用標示符。(當cell有多種不同數量的子視圖的時候,或者子視圖以一種獨特的方式布局的時候,這些情況下你就需要使用一個新的重用標示符。)
例如,要在一個cell中顯示一條email消息,可能會有4種獨特的布局:第一種,只有主題的消息;第二種,帶主題和正文的消息;第三種,帶主題和圖片附件的消息;第四種,帶主題、正文和圖片附件的消息。每一種布局都需要完全不同的布局約束才能實現。因此,一旦cell被初始化並且布局約束被加到其中任意一種類型的cell上後,cell應當得到一個唯一的重用標示符來指定該cell類型。這就意味著,當你dequeue重用一個cell的時候,該類型cell的布局約束已經添加好了,可以直接使用。
注意,由於固有內容尺寸的不同,具有相同布局約束的cell仍然可能具有不同的高度!不要混淆了布局(不同的約束)和由不同內容尺寸而計算出(通過相同的布局約束來計算)的不同視圖frame這兩個概念,它們根本是完全不同的兩個東西。
不要將擁有不同布局約束條件的cell丟到同一個重用池當中(也就是使用相同的重用標示符),然後又在每次dequeue過後將舊的約束移除,又從頭開始重新添加約束。自動布局引擎內部並沒有被設計來可以處理大規模的約束更改,你會看到大量的性能問題。在iOS8上,蘋果將許多在iOS8之前比較難實現的東西都內置實現了。為了讓cell實現self-sizing的機制,必須先將tableView的rowHeight
屬性設置為常量UITableViewAutomaticDimension
。然後,只需將tableView的estimatedRowHeight
屬性設置為非零值即可開啟行高估算功能,例如:
1 2
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:
方法,為每一行返回一個更精確的估算值。
首先,為每一個cell都初始化一個離屏(offscreen)實例,為每個重用標示符實例化一個與之對應的cell實例,這些cell完全用於高度計算。(離屏表示cell的引用被存儲在view controller的一個屬性或實例變量之中,並且這個cell絕對不會被用作tableView:cellForRowAtIndexPath:
方法的返回值以實際呈現在屏幕上。)接著,這個cell的內容(例如,文本、圖片等等)還必須和會被顯示在table view中的內容完全一致。
然後,強制cell立即更新子視圖的布局,再用cell的contentView
調用systemLayoutSizeFittingSize:
方法計算出cell所需的高度是多少。使用UILayoutFittingCompressedSize
參數可以得到適合cell中所有內容所需的最小尺寸。然後其高度就可以作為tableView:heightForRowAtIndexPath:
方法的返回值。
如果你的table view超過了幾十行,你會發現自動布局約束的解決方式在第一次加載table view的時候會迅速地卡住主線程。因為,在第一次加載過程中,每一行都會調用tableView:heightForRowAtIndexPath:
方法(為了計算滾動條的尺寸)。
iOS7中,你可以(也絕對應該)使用table view的estimatedRowHeight
屬性。這樣會為還不在屏幕范圍內的cell提供一個臨時估算的行高值。然後,當這些cell即將要滾入屏幕范圍內的時候,真實的行高值會被計算出來(通過tableView:heightForRowAtIndexPath:
方法),估算的行高就會被替換掉。
一般來說,行高估算值不需要太精確——它只是被用來修正tableView中滾動條的大小的,當你在屏幕上滑動cell的時候,即便估算值不准確,tableView還是能很好地調節滾動條。將tableView的estimatedRowHeight
屬性設置成(在viewDidLoad
或類似的方法中)一個接近於“平均”行高的常量值即可。只有行高變化很極端的時候(比如相差一個數量級),才會在滾動時產生滾動條“跳躍”的現象。這個時候,你才應當實現tableView:estimatedHeightForRowAtIndexPath:
方法,為每一行返回一個更精確的估算值。
如果上面提到的你都做了,但是tableView:heightForRowAtIndexPath:
的性能仍然慢的不可接受。非常不幸,你需要給行高做一些緩存(這是蘋果的工程師們給出的改進建議)。大體的思路是,第一次計算時讓自動布局引擎解析約束條件,然後將計算出的行高緩存起來,以後所有對該cell的高度的請求都返回緩存值。當然,關鍵還要確保任何會導致cell高度變化的情況發生時你都清除了緩存的行高——這通常發生在cell的內容變化時或其他重大事件發生時(比如用戶調節了動態類型文本大小(Dynamic Type text size)的滑動條)。
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { // 判斷indexPath對應cell的重用標示符, // 取決於特定的布局需求(可能只有一個,也或者有多個) NSString *reuseIdentifier = ...; // 取出重用標示符對應的cell。 // 注意,如果重用池(reuse pool)裡面沒有可用的cell,這個方法會初始化並返回一個全新的cell, // 因此不管怎樣,此行代碼過後,你會可以得到一個布局約束已經完全准備好,可以直接使用的cell。 UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier]; // 用indexPath對應的數據內容來配置cell,例如: // cell.textLabel.text = someTextForThisCell; // ... // 確保cell的布局約束被設置好了,因為它可能剛剛才被創建好。 // 使用下面兩行代碼,前提是假設你已經在cell的updateConstraints方法中設置好了約束: [cell setNeedsUpdateConstraints]; [cell updateConstraintsIfNeeded]; // 如果你使用的是多行的UILabel,不要忘了,preferredMaxLayoutWidth需要設置正確。 // 如果你沒有在cell的-[layoutSubviews]方法中設置,就在這裡設置。 // 例如: // cell.multiLineLabel.preferredMaxLayoutWidth = CGRectGetWidth(tableView.bounds); return cell; } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { // 判斷indexPath對應cell的重用標示符, NSString *reuseIdentifier = ...; // 從cell字典中取出重用標示符對應的cell。如果沒有,就創建一個新的然後存儲在字典裡面。 // 警告:不要調用table view的dequeueReusableCellWithIdentifier:方法,因為這會導致cell被創建了但是又未曾被tableView:cellForRowAtIndexPath:方法返回,會造成內存洩露! UITableViewCell *cell = [self.offscreenCells objectForKey:reuseIdentifier]; if (!cell) { cell = [[YourTableViewCellClass alloc] init]; [self.offscreenCells setObject:cell forKey:reuseIdentifier]; } // 用indexPath對應的數據內容來配置cell,例如: // cell.textLabel.text = someTextForThisCell; // ... // 確保cell的布局約束被設置好了,因為它可能剛剛才被創建好。 // 使用下面兩行代碼,前提是假設你已經在cell的updateConstraints方法中設置好了約束: [cell setNeedsUpdateConstraints]; [cell updateConstraintsIfNeeded]; // 將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行代碼前手動設置!) [cell setNeedsLayout]; [cell layoutIfNeeded]; // 得到cell的contentView需要的真實高度 CGFloat height = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height; // 要為cell的分割線加上額外的1pt高度。因為分隔線是被加在cell底邊和contentView底邊之間的。 height += 1.0f; return height; } // 注意:除非行高極端變化並且你已經明顯的覺察到了滾動時滾動條的“跳躍”現象,你才需要實現此方法;否則,直接用tableView的estimatedRowHeight屬性即可。 - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { // 以必需的最小計算量,返回一個實際高度數量級之內的估算行高。 // 例如: // if ([self isTallCellAtIndexPath:indexPath]) { return 350.0f; } else { return 40.0f; } }