为啥我的 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 在插入或删除行时会“跳跃”?的主要内容,如果未能解决你的问题,请参考以下文章

为啥删除 UITableView 行时会出现错误?

如何在 UITableView 中插入和删除行时模拟折纸

当用户点击加号按钮插入行时 UITableView 响应

UITableView 在插入更多行时滚动到顶部

添加/删除 UITableView 行时如何防止文本字段变空?

删除 UITableView 行时删除通知(Swift)