【问题标题】: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 - 天哪,就是这样!您能否提供此答案,以便我将其标记为正确?

标签: ios uitableview


【解决方案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你的表视图。看看AsyncDisplayKitUITableView-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,效果很好。我的单元格很高,但没有那么高,不到一个屏幕高度。我在这里得到了答案:stackoverflow.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
                        }
                }
                

                【讨论】:

                  猜你喜欢
                  • 1970-01-01
                  • 1970-01-01
                  • 2017-12-06
                  • 2015-09-09
                  • 1970-01-01
                  • 2014-06-18
                  • 2016-02-21
                  • 1970-01-01
                  • 2011-07-23
                  相关资源
                  最近更新 更多