这个问题发生在iOS15,就一个很常见的需求:批量编辑列表,编辑状态可多选Cell,切换编辑状态时要动态刷新可视区域的Cell。
粗略设计稿如下
因为tableView.reloadData()不方便实现动效,想必大家首先会选择用tableView.visibleCells做局部动态刷新,我这几年就是这么做的。
@objc private func editButtonItemClicked() {
state = state == .normal ? .editing : .normal
changeRightBarButton(animated: true)
selectedIndexPaths.removeAll()
let isEditing = state == .editing
tableView.visibleCells.forEach {
if let cell = $0 as? Cell {
cell.markEditing(isEditing, selected: false, animated: true)
}
}
}
但是最近在iOS15设备上发现了奇奇怪怪的BUG,顶部和底部有个别Cell没有被刷新状态,滚动后就露出了马脚,有个别Cell保留了切换之前的状态。而这个问题不会在iOS14及以下系统出现,只在iOS15上出现。问题的表现可以看下面的动图,或者克隆仓库跑代码看看 github.com/XiFengLang/…。
排查一遍代码后并没有发现什么问题,最后借助lookin调试UI才发现了问题。如下图所示,在屏幕的外面还有2-3个隐藏的Cell(iOS15一般是2个,iOS14及以下系统一般是1个),但是tableView.visibleCells取到的Cell都是那些没隐藏的(可视区域内的)Cell,不包括其它几个隐藏的Cell。而实际上这几个隐藏的Cell可能已经添加到了TableView上,并且代理方法tableView(_:cellForRowAt:)已经执行到对应的IndexPath,只是隐藏状态没有显示出现罢了。
比如截图(iOS15的)中的section: 1 row: 4和section: 3 row: 3,刚好位于屏幕的顶部和底部外边,iOS14及以下系统也有类似的准备机制,会在外边提前准备一个隐藏的Cell用于显示,但在iOS15这个动作似乎提前执行了。在iOS14及以下系统,这些隐藏的Cell,tableView(_:cellForRowAt:)还没执行到响应的IndexPath,cell和indexPath是没有关联的;而在iOS15,屏幕顶部和底部外边的2个隐藏Cell,很有可能已经绑定了IndexPath,也就是tableView(_:cellForRowAt:)可能已经执行到响应的IndexPath,nil != tableView.indexPath(for: cell) && nil != tableView.cellForRow(at: indexPath)。
为此我在tableView(_:didSelectRowAt:)方法中加了一些断言,来测试这些隐藏的Cell有没有绑定IndexPath、对比所有绑定IndexPath的Cell和tableView.visibleCells是否相同以及其它的断言判断,个别断言会在iOS15出现失败,但是在iOS14及以下系统不会出现任何的问题。
忽略 do {} ,加上只为了隔离代码,没其它目的
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
do {
if selectedIndexPaths.contains(indexPath) {
selectedIndexPaths.remove(indexPath)
} else {
selectedIndexPaths.insert(indexPath)
}
tableView.reloadRows(at: [indexPath], with: .none)
}
var mountedIndexPaths = [IndexPath]()
for section in (0..<tableView.numberOfSections) {
for row in (0..<tableView.numberOfRows(inSection: section)) {
let indexPath = IndexPath(row: row, section: section)
/// 这些Cell`可能`是通过执行`tableView(_:cellForRowAt:)`代理方法复用并添加到TableView上的
/// 代理方法 `tableView(_:cellForRowAt:)` `可能`已经执行到对应的IndexPath,Cell数据已经更新
/// `nil != tableView.indexPath(for: cell) && nil != tableView.cellForRow(at: indexPath)`
///
if nil != tableView.cellForRow(at: indexPath) {
mountedIndexPaths.append(indexPath)
}
}
}
let cells = tableView.subviews.filter({ $0 is Cell })
let mountedCells = cells.filter { cell in
let indexPath = tableView.indexPath(for: cell as! Cell)
if cell.isHidden {
/// iOS12/13/14,TableView上隐藏的Cell,`tableView(_:cellForRowAt:)`都还没执行到对应的IndexPath
/// 即`nil != tableView.cellForRow(at: indexPath)`, 还可通过Lookin插件看Cell.titleLabel.text
/// iOS15 TableView上隐藏的Cell,`可能``tableView(_:cellForRowAt:)`已经执行到对应的IndexPath
/// 即`nil != tableView.indexPath(for: cell) && nil != tableView.cellForRow(at: indexPath)`,
/// 还可通过Lookin插件看Cell.titleLabel.text
/// 之所以说“可能”,就有出现3个隐藏Cell,但是有2个已经走代理方法,1个没走代理方法
print("Cell已添加到tableView上,isHidden(\(true)),at indexPath:\(indexPath as Any)")
}
return nil != indexPath
}
do {
// 等同 tableView.indexPathsForVisibleRows
let visibleCells = tableView.visibleCells
let visibleIndexPaths = visibleCells.compactMap { cell in
tableView.indexPath(for: cell)
}
let subtracting = Set(mountedIndexPaths).subtracting(visibleIndexPaths)
// MARK: - Maybe failed in iOS15
assert(subtracting.isEmpty, "异常1")
// MARK: - Maybe failed in iOS15
assert(mountedCells.count == visibleCells.count, "异常1.1")
}
do {
if let visibleIndexPaths = tableView.indexPathsForVisibleRows {
let subtracting = Set(mountedIndexPaths).subtracting(visibleIndexPaths)
// MARK: - Maybe failed in iOS15
assert(subtracting.isEmpty, "异常2")
}
}
do {
let subtracting1 = Set(mountedIndexPaths).subtracting(tableView.mountedIndexPaths)
assert(subtracting1.isEmpty, "异常3.1")
let indexPaths = tableView.mountedCells.compactMap { cell in
tableView.indexPath(for: cell)
}
let subtracting2 = Set(mountedIndexPaths).subtracting(indexPaths)
assert(subtracting2.isEmpty, "异常3.2")
}
if let visibleIndexPaths = tableView.indexPathsForVisibleRows {
let subtracting = Set(mountedIndexPaths).subtracting(visibleIndexPaths)
subtracting.enumerated().forEach {
if let cell = tableView.cellForRow(at: $0.element),
tableView === cell.superview {
print("Cell已添加到tableView上,isHidden(\(cell.isHidden)), section: \($0.element.section) row: \($0.element.row)")
} else {
assert(false, "异常")
}
}
}
print("Congratulations! 🎉")
}
tableView(_:cellForRowAt:)已经执行到对应的IndexPath,数据已经更新cell.superView === tableViewnil != tableView.indexPath(for: cell)nil != tableView.cellForRow(at: indexPath)cell.isHidden == true
通过测试后发现,在iOS15系统,tableView.visibleCells + 满足以上所有条件的Cells 才是 我们想要刷新的Cells,iOS14及以下系统tableView.visibleCells就是我们想要刷新的Cells。
之所以说要满足所有条件,因为有个别隐藏的Cell并没有关联indexPath,也就是tableView(_:cellForRowAt:)还没有执行对应的indexPath,此时nil == tableView.indexPath(for: cell) and nil == tableView.cellForRow(at: indexPath),这种Cell是TableView内部出于某种优化机制添加上的,我们不需要手动刷新这种Cell,因为还没走对应的tableView(_:cellForRowAt:),以后滚动列表时会通过tableView(_:cellForRowAt:)刷新到这些Cell。
目前发现只会在屏幕的顶部或底部存在2个关联IndexPath且隐藏的Cell,所以只要在indexPathsForVisibleRows基础上往前新增1行,再往后加1行即可,也就是下面的mountedIndexPaths,具体可以看代码。
本文涉及到截图、视频、示例代码都已经放到Github github.com/XiFengLang/…,感兴趣的可以下载下来跑一下项目。
目前只测试了iOS12、iOS13、iOS14和iOS15,iOS11及之前的系统没测。
extension UITableView {
private typealias CellTuple = (indexPath: IndexPath, cell: UITableViewCell)
/// 返回上一个`已添加到TableView上的Cell和indexPath`
private func mountedCell(before indexPath: IndexPath) -> CellTuple? {
let sectionCount = numberOfSections
guard sectionCount > 0 else { return nil }
// 当前indexPath是本组的第1行,取前1组的最后1行
if indexPath.row == 0, indexPath.section > 0 {
for section in (0...indexPath.section - 1).reversed() {
let rowCount = numberOfRows(inSection: section)
if rowCount > 0 {
let targetIndexPath = IndexPath(row: rowCount - 1, section: section)
if let cell = cellForRow(at: targetIndexPath) {
return (targetIndexPath, cell)
}
return nil
}
}
} else if indexPath.row > 0 {
// 取本组的前面1行
let targetIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section)
if let cell = cellForRow(at: targetIndexPath) {
return (targetIndexPath, cell)
}
}
return nil
}
/// 返回下一个`已添加到TableView上的Cell和indexPath`
private func mountedCell(after indexPath: IndexPath) -> CellTuple? {
let sectionCount = numberOfSections
var rowCount = numberOfRows(inSection: indexPath.section)
guard sectionCount > 0 && rowCount > 0 else { return nil }
// 是本组最后1行,并且不是最后一组,取下1组的第1行
if indexPath.row == rowCount - 1, indexPath.section < sectionCount - 1 {
for section in (indexPath.section + 1..<sectionCount) {
rowCount = numberOfRows(inSection: section)
if rowCount > 0 {
let targetIndexPath = IndexPath(row: 0, section: section)
if let cell = cellForRow(at: targetIndexPath) {
return (targetIndexPath, cell)
}
return nil
}
}
} else if indexPath.row < rowCount - 1 {
// 取本组的下1行
let targetIndexPath = IndexPath(row: indexPath.row + 1, section: indexPath.section)
if let cell = cellForRow(at: targetIndexPath) {
return (targetIndexPath, cell)
}
}
return nil
}
public var mountedIndexPaths: [IndexPath] {
guard var indexPaths = indexPathsForVisibleRows, !indexPaths.isEmpty
else { return [] }
if #available(iOS 15.0, *) {
if let previous = mountedCell(before: indexPaths.first!) {
indexPaths.insert(previous.indexPath, at: 0)
}
if let next = mountedCell(after: indexPaths.last!) {
indexPaths.append(next.indexPath)
}
}
return indexPaths
}
public var mountedCells: [UITableViewCell] {
var visibleCells = self.visibleCells
guard !visibleCells.isEmpty else { return visibleCells }
if #available(iOS 15.0, *) {
if let indexPath = indexPath(for: visibleCells.first!),
let previous = mountedCell(before: indexPath) {
visibleCells.insert(previous.cell, at: 0)
}
if let indexPath = indexPath(for: visibleCells.last!),
let next = mountedCell(after: indexPath) {
visibleCells.append(next.cell)
}
}
return visibleCells
}
}