Why does my UITableView "jump" when inserting or removing a row? Why does my UITableView "jump" when inserting or removing a row? ios ios

Why does my UITableView "jump" when inserting or removing a row?


On iOS 11, UITableView uses estimated row height as default.

It leads to unpredictable behaviors when inserting/reloading or deleting rows because the UITableView has a wrong content size most of the time:

To avoid too many layout calculations, the tableView asks heightForRow only for each cellForRow call and remembers it (in normal mode, the tableView asks heightForRow for all the indexPaths of the tableView). The rest of the cells has a height equal to the estimatedRowHeight value until their corresponding cellForRow is called .

// estimatedRowHeight modecontentSize.height = numberOfRowsNotYetOnScreen * estimatedRowHeight + numberOfRowsDisplayedAtLeastOnce * heightOfRow// normal modecontentSize.height = heightOfRow * numberOfCells

I guess UIKit struggles to animate correctly the changes because of this trick.

One solution is to disable the estimatedRowHeight mode by setting estimatedRowHeight to 0 and implementing heightForRow for each of your cells.

Of course, if your cells have dynamic heights (with onerous layout calculations most of time so you used estimatedRowHeight for a good reason), you would have to find a way to reproduce the estimatedRowHeight optimization without compromising the contentSize of your tableView. Take a look at AsyncDisplayKit or UITableView-FDTemplateLayoutCell.

Another solution is to try to find a estimatedRowHeight which suits well. Since iOS 10, you can also try to use UITableView.automaticDimension. UIKit will find a value for you:

tableView.rowHeight = UITableView.automaticDimensiontableView.estimatedRowHeight = UITableView.automaticDimension

On iOS 11, it's already the default value.


I fixed jump by caching height of cell rows, as well as height of section footers and headers. Approach require to have unique cache identifier for sections and rows.

// Define cachesprivate 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 heightfunc 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 heightfunc 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 heightfunc 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 heightfunc 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:      ...   }}

Reusable class for caches can look like below:

#if os(iOS) || os(tvOS) || os(watchOS)import UIKit#elseif os(OSX)import AppKit#endifpublic 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

enter image description here


I don't know how to fix it correctly, but my solution works for me

// 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  }}