返回

UITableView展开动画Cell没了?原因与3种修复方案

IOS

UITableView 折叠展开动画:为什么我的 Cell 消失了?如何修复?

UITableView 做内容的折叠和展开是个挺常见的需求。理论上,改改 UITableView 的高度约束,加个动画,噌的一下,应该就搞定了。但有时候,事情没那么简单。就像下面这位朋友遇到的:折叠动画看着还行,一展开,Cell 全没了!

func collapseExpandRoomSection() {
     isRoomCollapsed = !isRoomCollapsed
     // totalTableHeight 是预先计算好的表格完全展开时的高度
     tableHeightConstraint.constant = isRoomCollapsed ? 0.0 : totalTableHeight
     UIView.animateWithDuration(0.3) { // Swift 2.x 写法,现在是 UIView.animate(withDuration:animations:completion:)
       self.view.layoutIfNeeded()
     }
 }

代码看着挺直观,逻辑也没啥大毛病。那问题出在哪呢?咱们来捋一捋。

一、Cell 去哪儿了:问题根源剖析

咱们先想想 UITableView 是怎么工作的。它很懒,只创建和渲染当前屏幕上看得见的,或者马上要看得见的那些 Cell。当你把 UITableView 的高度约束(tableHeightConstraint.constant)改成 0 时,UITableView 会觉得:“OK,我没地方展示内容了,啥都不用画了。” 这时候,它内部的 Cell 可能会被回收,或者干脆就不再关心它们的状态了。

问题就出在展开的时候。你把高度约束恢复了,比如设成 totalTableHeight。通过 UIView.animateself.view.layoutIfNeeded(),视图的frame确实是按照动画变大了。但是,UITableView 本身并不知道它需要重新加载数据并显示 Cell 啊!它可能还沉浸在“我高度是0,不用干活”的状态里。你只是改变了它的“容器”大小,并没有明确告诉它:“喂,醒醒,有活干了,赶紧把你那些 Cell 给我显示出来!”

简单说,UITableView 的布局更新了,但是它的数据源和 Cell 的渲染没有被触发。结果就是,一个空空如也的 UITableView 展现在你面前。

二、让 Cell 回来:解决方案走起

知道了问题根源,解决起来就对症下药了。核心思路就是:在 UITableView 的高度恢复后,明确告诉它需要重新加载并显示数据。

方案一:动画完成时,Reload!

最直接粗暴,也往往有效的方法,就是在动画完成的回调里,手动调用 tableView.reloadData()

1. 原理与作用

tableView.reloadData() 会强制 UITableView 重新查询它的数据源 (dataSource) 方法,比如 numberOfRowsInSectioncellForRowAt 等。这样一来,它就知道该显示多少 Cell,以及每个 Cell 该长什么样。当动画执行完毕,UITableView 的 frame 已经达到了目标高度,此时 reloadData() 就能确保 Cell 被正确创建和布局。

2. 代码示例 (Swift 5+)

假设你的 UITableViewmyTableView,高度约束叫 tableViewHeightConstraint

class MyViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet weak var myTableView: UITableView!
    @IBOutlet weak var tableViewHeightConstraint: NSLayoutConstraint!

    var isRoomCollapsed = true
    var items: [String] = ["床", "衣柜", "书桌", "椅子", "空调"] // 示例数据
    var totalTableHeight: CGFloat = 0.0 // 预先计算或动态获取

    override func viewDidLoad() {
        super.viewDidLoad()
        myTableView.dataSource = self
        myTableView.delegate = self
        myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") // 注册Cell

        // 初始化时计算完整高度 (重要!)
        // 如果是固定行高
        // totalTableHeight = CGFloat(items.count) * 44.0 // 假设行高44
        // 如果是自动行高,这个计算会复杂些,咱们后面细说
        // 先假设 totalTableHeight 已经有了正确的值
        updateTableHeight(animated: false) // 初始化折叠状态
    }

    // 示例:如何计算动态行高表格的 totalTableHeight
    // 这个方法应该在数据加载完毕后,并且表格初次布局后调用以获得准确contentSize
    // 或者,你可以在数据变化后,先reloadData,再layoutIfNeeded,然后读取contentSize.height
    func calculateAndStoreTotalHeight() {
        // 确保数据是最新的
        // myTableView.reloadData() // 可能需要先reloadData确保contentSize计算正确
        // self.view.layoutIfNeeded() // 强制布局更新
        // self.totalTableHeight = myTableView.contentSize.height
        // 这里简化,假设你知道内容的总高度
        if !items.isEmpty {
            self.totalTableHeight = CGFloat(items.count) * 44.0 + myTableView.contentInset.top + myTableView.contentInset.bottom
             // 如果有 section header/footer,也要算进去
        } else {
            self.totalTableHeight = 0
        }
    }


    @IBAction func toggleButtonTapped(_ sender: UIButton) {
        isRoomCollapsed.toggle()
        updateTableHeight(animated: true)
    }

    func updateTableHeight(animated: Bool) {
        // 在展开前,确保 totalTableHeight 是最新的。
        // 如果内容是动态的,这一步很关键。
        if !isRoomCollapsed {
            calculateAndStoreTotalHeight() // 确保 totalTableHeight 有值
        }

        let targetHeight = isRoomCollapsed ? 0.0 : totalTableHeight
        tableViewHeightConstraint.constant = targetHeight

        if animated {
            UIView.animate(withDuration: 0.3, animations: {
                self.view.layoutIfNeeded() // 执行约束动画
            }) { [weak self] (completed) in
                guard let self = self, completed else { return }
                // 动画完成后,如果是展开状态,并且高度大于0,则刷新数据
                if !self.isRoomCollapsed && self.tableViewHeightConstraint.constant > 0 {
                    // 这一步是关键!
                    self.myTableView.reloadData()
                    
                    // 可选:如果 cell 很少,或者就是想确保最后滚动到顶部
                    if !self.items.isEmpty {
                       // self.myTableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
                    }
                }
            }
        } else {
            self.view.layoutIfNeeded() // 非动画,直接应用约束
            if !isRoomCollapsed && tableViewHeightConstraint.constant > 0 {
                myTableView.reloadData() // 非动画也需要刷新
            }
        }
    }

    // MARK: - UITableViewDataSource
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // 注意:即使表格高度为0,这里也应该返回实际的数据量
        // reloadData() 时会用到
        // 或者,如果折叠时就不想让它持有数据,可以在折叠时清空数据源数组,展开时再填充
        // 但为了简单,我们先假设数据源一直存在
        return isRoomCollapsed ? 0 : items.count // 或者就 items.count, 由 reloadData 控制是否绘制
        // 更好的做法是:这里永远返回 items.count, 动画和高度控制显隐
        // 如果 return isRoomCollapsed ? 0 : items.count,
        // 那么展开时,reloadData后,numberOfRowsInSection 才会有值,它才能画出 cell
        // return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = items[indexPath.row]
        return cell
    }
    
    // MARK: - UITableViewDelegate (可选)
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 44.0 // 示例固定行高
    }
}

3. 额外建议与进阶

  • totalTableHeight 的计算: 这是个坎。

    • 固定行高: 总高度 = 行数 * 行高 + SectionHeader高度 + SectionFooter高度 + contentInset.top + contentInset.bottom。这个比较简单,可以在数据加载后直接算出来。
    • 动态行高 (UITableView.automaticDimension): 这个就麻烦点了。UITableViewcontentSize.height 是它自己根据内容算出来的。你可能需要在数据加载完 (reloadData())、并且 UITableView 完成一次布局 (layoutIfNeeded()) 之后,才能取到准确的 contentSize.height 作为 totalTableHeight。这意味着,你可能需要一个稍微复杂点的流程来更新高度:
      1. 用户点击展开。
      2. 更新数据源(如果之前是空的)。
      3. 调用 myTableView.reloadData()
      4. 调用 myTableView.layoutIfNeeded() (或者 self.view.layoutIfNeeded()) 来强制 UITableView 立即计算其 contentSize
      5. 读取 myTableView.contentSize.height 作为 totalTableHeight
      6. 设置 tableViewHeightConstraint.constant = totalTableHeight
      7. 执行动画 UIView.animate(...) { self.view.layoutIfNeeded() }
        这个流程看起来有点绕,因为获取最终高度和执行动画之间可能需要一次“预计算”。
  • performBatchUpdates 代替 reloadData
    如果你的展开/折叠不涉及整个表格数据的重新加载,而是比如新增/删除了几行,使用 tableView.performBatchUpdates(_:completion:) 配合 tableView.insertRows(at:with:)tableView.deleteRows(at:with:) 会有更平滑的自带行展开/收起动画效果,并且性能可能更好。
    但是,如果仅仅是像问题那样,改变 UITableView 整体的高度约束来实现显示/隐藏所有内容,reloadData() 简单有效。

  • 避免不必要的 reloadData
    如果在折叠时,UITableView 的数据源并没有清空,Cell 只是因为高度为0而不可见。那么在展开动画完成后,有时仅仅是 reloadData() 就能解决。但如果折叠时你清空了数据源数组 (比如 items = []),那么展开时,不仅要恢复高度,还要把数据源数组重新填充,然后再 reloadData()

方案二:活用 UITableViewAutomaticDimensioncontentSize

如果你正在使用自适应行高(rowHeight = UITableView.automaticDimension),那么可以让 UITableView 自己管理高度,你只需要控制它是否有内容即可。

1. 原理与作用

UITableView 的行高设置为 UITableView.automaticDimension,并且其本身的高度约束不是一个固定值 (比如,它的上下左右都钉在父视图上,或者其高度约束优先级较低,允许被 intrinsicContentSize "撑开"),那么 UITableView 的高度会由其 contentSize 决定。
折叠时,让 UITableView 的数据源返回0行。展开时,恢复数据源的行数。然后通过 reloadData()performBatchUpdates 来更新 UITableView,它的 contentSize 就会相应变化。如果 UITableView 的外部约束允许它自由伸缩,它的 frame 就会跟着 contentSize 变。

若你仍希望通过一个明确的 tableViewHeightConstraint 来控制高度,那么你可以:

  1. 更新数据源 (折叠时0行,展开时N行)。
  2. 调用 myTableView.reloadData()
  3. 强制布局:myTableView.layoutIfNeeded() (或者 self.view.layoutIfNeeded())。
  4. 获取高度:let newHeight = myTableView.contentSize.height
  5. 更新约束:tableViewHeightConstraint.constant = newHeight
  6. 动画生效:UIView.animate(...) { self.view.layoutIfNeeded() }

2. 代码示例 (基于方案一稍作修改)

假设你有一个数据模型 Room,它包含一个 items 数组。

class MyDynamicHeightViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet weak var myTableView: UITableView!
    @IBOutlet weak var tableViewHeightConstraint: NSLayoutConstraint! // 这个约束依然存在

    var isRoomCollapsed = true
    var allItems: [String] = ["大床房", "双床房", "行政套房", "豪华大床"]
    var displayedItems: [String] = [] //实际显示的数据

    override func viewDidLoad() {
        super.viewDidLoad()
        myTableView.dataSource = self
        myTableView.delegate = self
        myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")

        myTableView.rowHeight = UITableView.automaticDimension
        myTableView.estimatedRowHeight = 44 // 给个估算值,提高性能

        // 初始化状态
        updateDisplayedItems()
        updateTableHeightConstraint(animated: false)
    }

    @IBAction func toggleButtonTapped(_ sender: UIButton) {
        isRoomCollapsed.toggle()
        updateDisplayedItems() // 先更新数据源
        updateTableHeightConstraint(animated: true) // 然后更新高度并动画
    }
    
    func updateDisplayedItems() {
        displayedItems = isRoomCollapsed ? [] : allItems
    }

    // 这个函数变得更关键,因为它在数据变化后,动画变化前被调用
    func updateTableHeightConstraint(animated: Bool) {
        // 1. 重新加载数据,让 UITableView 内部知道内容变了
        //    注意: numberOfRowsInSection 会根据 displayedItems 返回正确数量
        myTableView.reloadData()

        // 2. 强制 UITableView 立即布局,这样 contentSize 才是准确的
        //    这一步是精髓,确保 contentSize 反映了 reloadData 之后的内容
        myTableView.layoutIfNeeded() 

        // 3. 获取基于当前内容的准确高度
        let targetHeight = isRoomCollapsed ? 0.0 : myTableView.contentSize.height
        // 防止 contentSize 在数据为空时可能不为0 (比如有 sectionHeader/Footer 但没有 row)
        // 不过若 displayedItems 为空, numberOfRows 为0,通常 contentSize 也为0 (除非有header/footer)
        if displayedItems.isEmpty && targetHeight > 0 && isRoomCollapsed {
             // tableViewHeightConstraint.constant = 0 // 确保是0
        } else {
             tableViewHeightConstraint.constant = targetHeight
        }
       

        if animated {
            UIView.animate(withDuration: 0.3, animations: {
                self.view.layoutIfNeeded() // 动画应用约束变化
            })
            // 注意:这里 completion block 里可能不需要再 reloadData 了,
            // 因为高度计算前已经 reloadData 过了。
        } else {
            self.view.layoutIfNeeded()
        }
    }

    // MARK: - UITableViewDataSource
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return displayedItems.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = displayedItems[indexPath.row]
        // 确保cell支持自适应高度,比如label的numberOfLines = 0,并且有正确的上下约束
        return cell
    }
}

3. 进阶使用与思考

  • KVO 观察 contentSize 有一种更“自动”的方式,就是通过 KVO (Key-Value Observing) 观察 myTableView.contentSize 的变化。一旦 contentSize 变了,就自动更新 tableViewHeightConstraint.constant。这种方式在某些场景下很优雅,但要小心处理,避免不必要的重复更新或动画循环。

    // 在 viewDidLoad 或初始化时添加观察者
    // myTableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
    // 记得在 deinit 中移除
    // deinit {
    //    myTableView.removeObserver(self, forKeyPath: "contentSize")
    // }
    
    // override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    //    if keyPath == "contentSize", let newSize = change?[.newKey] as? CGSize {
    //        tableViewHeightConstraint.constant = newSize.height
    //        UIView.animate(withDuration: 0.3) { // 可能需要更复杂的动画控制逻辑
    //            self.view.layoutIfNeeded()
    //        }
    //    } else {
    //        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
    //    }
    // }
    

    直接在 observeValue 里做动画需要小心,因为它可能因为 reloadDatalayoutIfNeeded 被多次触发。通常更稳妥的是在数据更新操作完成后,主动去读取 contentSize 来更新约束,就像上面示例代码那样。

  • 动画同步问题:UITableView 本身的内容变化 (比如 insertRows/deleteRows) 产生动画,同时你又在改变它的高度约束并产生动画,这两个动画需要协调好。使用 performBatchUpdates 时,它会把内部的 Cell 动画和外部的布局动画(如果都放在同一个 UIView.animate block里的话)协调起来。上面 MyDynamicHeightViewController 示例中,我们先 reloadDatalayoutIfNeeded 来确定高度,然后用一个 UIView.animate 来统一执行约束动画,这样逻辑相对清晰。

  • 数据管理: 决定是只清空displayedItemsnumberOfRowsInSection返回0,还是在折叠时也清除tableView的实际items,取决于你的内存考虑和具体需求。对于大多数情况,只控制displayedItems(或isRoomCollapsed标志来影响numberOfRowsInSection的返回值)就够了。

方案三:隐藏与显示,简单粗暴但有效

如果折叠效果只是单纯的不显示,并不要求高度逐渐变为0的动画,那么可以直接操作 UITableViewisHidden 属性或者 alpha 属性。

1. 原理与作用

  • myTableView.isHidden = true:简单直接,表格立刻消失,不占空间。
  • myTableView.alpha = 0.0:表格变透明,但仍占据空间。配合高度约束动画可以做到淡入淡出同时改变高度。

这种方式,表格内部的 Cell 和数据都还在,只是看不见了。当你再把它显示出来时,Cell 天然就在那里。

2. 代码示例

func collapseExpandRoomSectionWithHidden() {
    isRoomCollapsed = !isRoomCollapsed
    
    // 如果只是隐藏,可能不需要动高度约束,或者根据isHidden状态调整高度
    // 假设我们依然用高度约束配合alpha
    tableViewHeightConstraint.constant = isRoomCollapsed ? 0.0 : totalTableHeight
    
    UIView.animate(withDuration: 0.3) {
        // self.myTableView.alpha = self.isRoomCollapsed ? 0.0 : 1.0 // 控制透明度
        self.view.layoutIfNeeded() // 高度动画
    }
    // 这种情况下,通常就不需要在 completion 里 reloadData 了,因为Cell没丢
    // myTableView.isHidden = isRoomCollapsed // 如果单纯用 isHidden,这一句就够了
}

如果你的 totalTableHeight 依赖 contentSize,并且你在折叠时,数据源仍然饱满,那么仅仅是 alpha = 0 再恢复,contentSize 不会变,Cell 也都在。如果 totalTableHeight 被设为0,那么即使 alpha 是1,你也看不到Cell,这时候就需要上面方案一或方案二的逻辑。

纯粹用 isHidden 切换是最简单的,但就没有了高度变化的动画。如果你需要高度动画,同时 Cell 不消失,确保展开时 totalTableHeight 计算正确,且必要时 reloadData 还是保险的。

总结一下

UITableView 折叠展开时 Cell 消失,多数情况是高度变了,但 UITableView 没有被“唤醒”去重新绘制它的内容。

  1. 首选修复 :在高度动画的完成回调 (completion block) 中调用 tableView.reloadData()。这是针对原始问题最直接的解决办法。
  2. 现代做法 (自适应高度) :如果你用 UITableView.automaticDimension,那么让 UITableView 根据内容自己决定高度。你需要做的就是:
    • 更新数据源 (展开时有数据,折叠时数据为空数组或返回0行)。
    • 调用 tableView.reloadData()
    • 调用 tableView.layoutIfNeeded() 强制立即布局以更新 contentSize
    • 读取 tableView.contentSize.height 来设置你的高度约束。
    • 执行约束动画。
  3. 计算 totalTableHeight :准确计算 totalTableHeight 至关重要。对于动态内容,这往往需要在 reloadData()layoutIfNeeded() 之后进行。

选哪种方案,看你的具体需求、表格复杂度以及是否使用自适应行高。但记住核心:UITableView 需要被明确告知何时刷新它的内容。