为啥我的 UITableView 在插入或删除行时会“跳跃”?
Posted
技术标签:
【中文标题】为啥我的 UITableView 在插入或删除行时会“跳跃”?【英文标题】:Why does my UITableView "jump" when inserting or removing a row?为什么我的 UITableView 在插入或删除行时会“跳跃”? 【发布时间】:2018-01-08 17:15:57 【问题描述】:(很高兴接受 Swift 或 Objective-C 中的答案)
我的表格视图有几个部分,当按下按钮时,我想在第 0 部分的末尾插入一行。再次按下按钮,我想删除同一行。我几乎可以工作的代码如下所示:
// model is an array of mutable arrays, one for each section
- (void)pressedAddRemove:(id)sender
self.adding = !self.adding; // this is a BOOL property
self.navigationItem.rightBarButtonItem.title = (self.adding)? @"Remove" : @"Add";
// if adding, add an object to the end of section 0
// tell the table view to insert at that index path
[self.tableView beginUpdates];
NSMutableArray *sectionArray = self.model[0];
if (self.adding)
NSIndexPath *insertionPath = [NSIndexPath indexPathForRow:sectionArray.count inSection:0];
[sectionArray addObject:@];
[self.tableView insertRowsAtIndexPaths:@[insertionPath] withRowAnimation:UITableViewRowAnimationAutomatic];
// if removing, remove the object from the end of section 0
// tell the table view to remove at that index path
else
NSIndexPath *removalPath = [NSIndexPath indexPathForRow:sectionArray.count-1 inSection:0];
[sectionArray removeObject:[sectionArray lastObject]];
[self.tableView deleteRowsAtIndexPaths:@[removalPath] withRowAnimation:UITableViewRowAnimationAutomatic];
[self.tableView endUpdates];
这有时会正常运行,但有时不会,具体取决于滚动表视图的位置:
第 0 部分在最顶部,contentOffset.y == 0:效果很好,行已插入,第 0 部分下方的内容向下动画 第 0 部分不可见,因为表格滚动过去:效果很好,新行下方的可见内容向下动画,就好像在其上方插入了一行一样。 但是:如果表格视图滚动一点,那么第 0 部分的一部分是可见的:它工作错误。在单个帧中,表格视图中的所有内容都会向上跳跃(内容偏移量增加)然后,通过动画,插入新行并且表格视图内容向下滚动(内容偏移量减少)。一切都到了应有的位置,但这个过程看起来非常糟糕,一开始就出现了单帧“跳跃”。我可以使用“Debug->Toggle Slow Animations”在慢动作模拟器中看到这种情况。删除时反向出现同样的问题。
我发现偏移量跳转的大小与表格滚动到第 0 部分的距离有关:当偏移量很小时跳转很小。当滚动接近第 0 部分总高度的 一半 时,跳跃变得更大(问题在这里最糟糕,跳跃 == 部分高度的一半)。进一步滚动,跳跃变得更小。当表格滚动到只有极少量的第 0 部分仍然可见时,跳转很小。
您能帮我理解这是为什么以及如何解决吗?
【问题讨论】:
您使用具有动态高度的单元格吗?还是您没有在 ios 11 中禁用动态高度单元格? @GaétanZ - 这很有趣......不。我实现heightForRowAtIndexPath
并为不同配置的单元格回答不同的高度。
您是否在 iOS 11 上将estimatesRowHeight 设置为0?
@GaétanZ - 不,我现在就试试。谢谢。
@GaétanZ - 天哪,就是这样!您能否提供此答案,以便我将其标记为正确?
【参考方案1】:
在 iOS 11 上,UITableView 使用估计的行高作为默认值。
在插入/重新加载或删除行时会导致不可预知的行为,因为 UITableView 大部分时间都有错误的内容大小:
为避免过多的布局计算,tableView 仅对每个cellForRow
调用询问heightForRow
并记住它(在正常模式下,tableView 向heightForRow
询问 tableView 的所有 indexPaths)。其余单元格的高度等于estimatedRowHeight
的值,直到它们对应的cellForRow
被调用。
// estimatedRowHeight mode
contentSize.height = numberOfRowsNotYetOnScreen * estimatedRowHeight + numberOfRowsDisplayedAtLeastOnce * heightOfRow
// normal mode
contentSize.height = heightOfRow * numberOfCells
我猜 UIKit 正因为这个技巧而难以正确地为更改设置动画。
一种解决方案是通过将estimatedRowHeight 设置为0 并为每个单元格实现heightForRow
来禁用estimatedRowHeight
模式。
当然,如果您的单元格具有动态高度(大部分时间进行繁重的布局计算,因此您使用 estimatedRowHeight
是有充分理由的),您必须找到一种方法来重现 estimatedRowHeight
优化而不影响 contentSize你的表视图。看看AsyncDisplayKit 或UITableView-FDTemplateLayoutCell。
另一种解决方案是尝试找到适合的estimatedRowHeight
。从iOS 10开始,你也可以尝试使用UITableView.automaticDimension
。 UIKit 会为你找到一个值:
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = UITableView.automaticDimension
在 iOS 11 上,它已经是默认值了。
【讨论】:
谢谢!也许添加:因此,将估计设置为 0 以强制表格视图调用 heightForRow? @user1272965 +1 是的,如果使用tableView(_:heightForRowAt:)
,那绝对是一个很好的解决方案。使用estimatedRowHeight
的默认值(UITableViewAutomaticDimension
),tableView会跳转,因为它使用estimatedRowHeight
而不是调用heightForRow。 hwvrThere are performance implications to using heightForRowAt instead of rowHeight. Every time a tableview is displayed, it calls heightForRowAt on the delegate for each of its rows, which can result in a significant performance problem with table views having a large number of rows (approximately 1000 or more).
我将 estimatedRowHeight
设置为 1000,效果很好。我的单元格很高,但没有那么高,不到一个屏幕高度。我在这里得到了答案:***.com/a/56576833/826946。如果它对您有用,请去那里并为该答案投票!【参考方案2】:
我通过缓存单元格行的高度以及部分页脚和页眉的高度来修复 jump。方法需要为部分和行提供唯一的缓存标识符。
// Define caches
private lazy var sectionHeaderHeights = SmartCache<NSNumber>(type: type(of: self))
private lazy var sectionFooterHeights = SmartCache<NSNumber>(type: type(of: self))
private lazy var cellRowHeights = SmartCache<NSNumber>(type: type(of: self))
// Cache section footer height
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView?
let section = sections[section]
switch section
case .general:
let view = HeaderFooterView(...)
view.sizeToFit(width: tableView.bounds.width)
sectionFooterHeights.set(cgFloat: view.bounds.height, forKey: section.cacheID)
return view
case .something:
...
// Cache cell height
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath)
let section = sections[indexPath.section]
switch section
case .general:
cellRowHeights.set(cgFloat: cell.bounds.height, forKey: section.cacheID)
case .phones(let items):
let item = items[indexPath.row]
cellRowHeights.set(cgFloat: cell.bounds.height, forKey: section.cacheID + item.cacheID)
case .something:
...
// Use cached section footer height
func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat
let section = sections[section]
switch section
default:
return sectionFooterHeights.cgFloat(for: section.cacheID) ?? 44
case .something:
...
// Use cached cell height
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat
let section = sections[indexPath.section]
switch section
case .general:
return cellRowHeights.cgFloat(for: section.cacheID) ?? 80
case .phones(let items):
let item = items[indexPath.row]
return cellRowHeights.cgFloat(for: section.cacheID + item.cacheID) ?? 120
case .something:
...
缓存的可重用类如下所示:
#if os(iOS) || os(tvOS) || os(watchOS)
import UIKit
#elseif os(OSX)
import AppKit
#endif
public class SmartCache<ObjectType: AnyObject>: NSCache<NSString, AnyObject>
public extension SmartCache
public convenience init(name: String)
self.init()
self.name = name
public convenience init(type: AnyObject.Type)
self.init()
name = String(describing: type)
public convenience init(limit: Int)
self.init()
totalCostLimit = limit
extension SmartCache
public func isObjectCached(key: String) -> Bool
let value = object(for: key)
return value != nil
public func object(for key: String) -> ObjectType?
return object(forKey: key as NSString) as? ObjectType
public func object(for key: String, _ initialiser: () -> ObjectType) -> ObjectType
let existingObject = object(forKey: key as NSString) as? ObjectType
if let existingObject = existingObject
return existingObject
else
let newObject = initialiser()
setObject(newObject, forKey: key as NSString)
return newObject
public func object(for key: String, _ initialiser: () -> ObjectType?) -> ObjectType?
let existingObject = object(forKey: key as NSString) as? ObjectType
if let existingObject = existingObject
return existingObject
else
let newObject = initialiser()
if let newObjectInstance = newObject
setObject(newObjectInstance, forKey: key as NSString)
return newObject
public func set(object: ObjectType, forKey key: String)
setObject(object, forKey: key as NSString)
extension SmartCache where ObjectType: NSData
public func data(for key: String, _ initialiser: () -> Data) -> Data
let existingObject = object(forKey: key as NSString) as? NSData
if let existingObject = existingObject
return existingObject as Data
else
let newObject = initialiser()
setObject(newObject as NSData, forKey: key as NSString)
return newObject
public func data(for key: String) -> Data?
return object(forKey: key as NSString) as? Data
public func set(data: Data, forKey key: String)
setObject(data as NSData, forKey: key as NSString)
extension SmartCache where ObjectType: NSNumber
public func float(for key: String, _ initialiser: () -> Float) -> Float
let existingObject = object(forKey: key as NSString)
if let existingObject = existingObject
return existingObject.floatValue
else
let newValue = initialiser()
let newObject = NSNumber(value: newValue)
setObject(newObject, forKey: key as NSString)
return newValue
public func float(for key: String) -> Float?
return object(forKey: key as NSString)?.floatValue
public func set(float: Float, forKey key: String)
setObject(NSNumber(value: float), forKey: key as NSString)
public func cgFloat(for key: String) -> CGFloat?
if let value = float(for: key)
return CGFloat(value)
else
return nil
public func set(cgFloat: CGFloat, forKey key: String)
set(float: Float(cgFloat), forKey: key)
#if os(iOS) || os(tvOS) || os(watchOS)
public extension SmartCache where ObjectType: UIImage
public func image(for key: String) -> UIImage?
return object(forKey: key as NSString) as? UIImage
public func set(value: UIImage, forKey key: String)
if let cost = cost(for: value)
setObject(value, forKey: key as NSString, cost: cost)
else
setObject(value, forKey: key as NSString)
private func cost(for image: UIImage) -> Int?
if let bytesPerRow = image.cgImage?.bytesPerRow, let height = image.cgImage?.height
return bytesPerRow * height // Cost in bytes
return nil
private func totalCostLimit() -> Int
let physicalMemory = ProcessInfo.processInfo.physicalMemory
let ratio = physicalMemory <= (1024 * 1024 * 512 /* 512 Mb */ ) ? 0.1 : 0.2
let limit = physicalMemory / UInt64(1 / ratio)
return limit > UInt64(Int.max) ? Int.max : Int(limit)
#endif
【讨论】:
【参考方案3】:我不知道如何正确修复它,但我的解决方案对我有用
// hack: for fix jumping of tableView as for tableView difficult to calculate height of cells
tableView.hackAgainstJumping
if oldIsFolded
tableView.insertRows(at: indexPaths, with: .fade)
else
tableView.deleteRows(at: indexPaths, with: .fade)
extension UITableView
func hackAgainstJumping(_ block: () -> Void)
self.contentInset.bottom = 300
block()
self.contentInset.bottom = 0
【讨论】:
【参考方案4】:保存估计的行高
private var cellHeight = [Int:CGFloat]()
override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath)
cellHeight[indexPath.row] = cell.frame.self.height
override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat
if let height = cellHeight[indexPath.row]
return height
return tableView.estimatedRowHeight
修复滚动原点 Y
let indexPath = IndexPath(row: INDEX, section: 0)
tableView.beginUpdates()
tableView.insertRows(at: [indexPath], with: .fade)
tableView.endUpdates()
tableView.setContentOffset(tableView.contentOffset, animated: false)
【讨论】:
【参考方案5】:尝试禁用 UIView 动画,对我来说它有效。
UIView.setAnimationsEnabled(false)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
UIView.setAnimationsEnabled(true)
【讨论】:
此方法将禁用tableview动画,即插入或删除动画。 是的,它确实会禁用 tableView 动画,但它会阻止 tableview 的“跳跃”行为。【参考方案6】:这发生在一个有多个部分的 UITableView 上,但没有定义这些部分的标题高度或视图应该是什么。添加以下委托方法为我修复了它 - 希望它有所帮助!
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat
return 0
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView?
return nil
【讨论】:
【参考方案7】:@GaétanZ 的解决方案不对我有用(IOS12),但他的概念是正确的..
所以我做了下一个合乎逻辑的步骤:
如果表格内容不知道单元格有多高,那么在插入单元格后让我们“继续向下滚动”
private func insertBottomBubble(withCompletionHandler completion: (() -> Void)?)
let bottomIndexPath = IndexPath(row: cbModelViewController!.viewModelsCount - 1, section: 0)
CATransaction.begin()
CATransaction.setAnimationDuration(0.9)
CATransaction.setCompletionBlock
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1)
self.scrollToBottom(withCompletionHandler: completion)
tableView.insertRows(at: [bottomIndexPath], with: isLeft == true ? .left : .right)
self.scrollToBottom(withCompletionHandler: nil) // no jump, keep it down :D
CATransaction.commit()
func scrollToBottom(withCompletionHandler completion: (() -> Void)?)
let bottomMessageIndexPath = IndexPath(row: tableView.numberOfRows(inSection: 0) - 1, section: 0)
UIView.animate(withDuration: 0.45,
delay: TimeInterval(0),
options: UIView.AnimationOptions.curveEaseInOut,
animations:
self.tableView.scrollToRow(at: bottomMessageIndexPath, at: .bottom, animated: false)
,
completion: success in
if success
completion?()
)
仅测试 iOS 12
【讨论】:
【参考方案8】:如果单元格的高度变化很大,则会出现此问题。 Vlad 的解决方案效果很好。但难以实施。我建议一种更简单的方法。在大多数情况下,它会对您有所帮助。
将变量private var cellHeightCache = [IndexPath: CGFloat]()
添加到您的控制器。并实现两个委托方法:
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat
return cellHeightCache[indexPath] ?? 44
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath)
cellHeightCache[indexPath] = cell.bounds.height
【讨论】:
【参考方案9】:每个人所说的估计行高都是正确的。所以考虑到所有这些,这就是这个想法:
将每一行的高度存储在数据结构中(我选择一个字典),然后将字典中的值用于 heightForRowAtIndexPath 和estimateHeightForRowAtIndexPath 方法
所以问题是,如果您使用动态标签大小,如何获得行高。很简单,只要用 willDisplayCell 方法找到单元格框
这是我的总工作版本,很抱歉目标 c...它只是我现在正在处理的项目:
为你的字典声明一个属性:
@property (strong) NSMutableDictionary *dictionaryCellHeights;
初始化字典:
self.dictionaryCellHeights = [[NSMutableDictionary alloc]init];
捕捉高度:
-(void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath
NSNumber *height = [NSNumber numberWithDouble:cell.frame.size.height];
NSString *rowString = [NSString stringWithFormat:@"%d", indexPath.row];
[self.dictionaryCellHeights setObject:height forKey:rowString];
使用高度:
-(CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
NSNumber *height = [self getRowHeight:indexPath.row];
if (height == nil)
return UITableViewAutomaticDimension;
return height.doubleValue;
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
NSNumber *height = [self getRowHeight:indexPath.row];
if (height == nil)
return UITableViewAutomaticDimension;
return height.doubleValue;
-(NSNumber*)getRowHeight: (int)row
NSString *rowString = [NSString stringWithFormat:@"%d", row];
return [self.dictionaryCellHeights objectForKey:rowString];
那么在插入行的时候:
[self.tableViewTouchActivities performBatchUpdates:^
[self.tableViewTouchActivities insertRowsAtIndexPaths:toInsertIndexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
completion:^(BOOL finished)
[self.tableViewTouchActivities finishInfiniteScroll];
];
*note - 我正在使用这个库进行无限滚动 https://github.com/pronebird/UIScrollView-InfiniteScroll/blob/master/README.md
【讨论】:
【参考方案10】:对于我的工作,关闭 tableview 行、部分、标题的自动估计 我使用 heightForRowAt
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
if indexPath.row % 2 == 1
if arrowsVisible
return 20
return 5
【讨论】:
以上是关于为啥我的 UITableView 在插入或删除行时会“跳跃”?的主要内容,如果未能解决你的问题,请参考以下文章