31

(Happy to accept an answer in Swift or Objective-C)

My table view has a few sections, and when a button is pressed, I want to insert a row at the end of section 0. Pressing the button again, I want delete that same row. My almost working code looks like this:

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

This behaves properly sometimes, but sometimes not, depending on where the table view is scrolled:

  • Section 0 at the very top, contentOffset.y == 0: Works great, the row is inserted and the stuff below section 0 animates downward
  • Section 0 invisible, because the table is scrolled past it: Works great, the visible content below the new row animates downward as if a row was inserted above it.
  • BUT: if the table view is scrolled a little, so that part of section 0 is visible: it works wrong. In a single frame, all of the content in the table view jumps up (content offset increases) Then, with animation, the new row gets inserted and the table view content scrolls back down (content offset decreases). Everything ends up where it should be, but the process looks super bad with that single frame "jump" at the start.

I can see this happen in slow-motion the simulator with "Debug->Toggle Slow Animations". The same problem occurs in reverse on the deletion.

I've found that the size of the jump in offset is related to the how far into section 0 the table is scrolled: the jump tiny when the offset is tiny. The jump gets bigger as the scrolling approaches half of section 0 total height (the problem is at it's worst here, jump == half the section height). Scrolling further, the jump gets smaller. When the table is scrolled so that only a tiny amount of section 0 is still visible, the jump is tiny.

Can you help me understand why this is and how to fix?

user1272965
  • 2,814
  • 8
  • 29
  • 49

10 Answers10

43

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 mode
contentSize.height = numberOfRowsNotYetOnScreen * estimatedRowHeight + numberOfRowsDisplayedAtLeastOnce * heightOfRow

// normal mode
contentSize.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.automaticDimension
tableView.estimatedRowHeight = UITableView.automaticDimension

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

GaétanZ
  • 4,870
  • 1
  • 23
  • 30
  • 6
    Thanks! Maybe add: therefore, set estimate to 0 to force the table view to call heightForRow? – user1272965 Jan 08 '18 at 18:06
  • @user1272965 +1 Yes, thats definitely a great solution if one is using `tableView(_:heightForRowAt:)`. With the default value (`UITableViewAutomaticDimension `) of `estimatedRowHeight`, the tableView will jump because it uses the `estimatedRowHeight` instead of calling heightForRow. Hwvr`There 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).` – j3141592653589793238 Aug 03 '18 at 10:53
  • I set `estimatedRowHeight` to 1000 and it worked great. My cells are tall, but not that tall, less than one screen height. I got that answer here: https://stackoverflow.com/a/56576833/826946 . If it works for you, go over there and upvote that answer! – Andy Weinstein May 05 '20 at 13:30
4

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
  }
}
Leonif
  • 466
  • 4
  • 17
4

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 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:
      ...
   }
}

Reusable class for caches can look like below:

#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

enter image description here

Vlad
  • 6,402
  • 1
  • 60
  • 74
3

Save estimated row heights

    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

Fix scroll origin Y

    let indexPath = IndexPath(row: INDEX, section: 0)
    tableView.beginUpdates()
    tableView.insertRows(at: [indexPath], with: .fade)
    tableView.endUpdates()
    tableView.setContentOffset(tableView.contentOffset, animated: false)
Serg Smyk
  • 613
  • 5
  • 11
1

Try to disable UIView animation, for me it works.

UIView.setAnimationsEnabled(false)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
UIView.setAnimationsEnabled(true)
Bohdan Boch
  • 59
  • 1
  • 5
0

This was happening for me on a UITableView that had multiple sections, but no definitions for what it's header height or view should be for those sections. Adding the following delegate methods fixed it for me - hope it helps!

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return 0
}

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    return nil
}
Alex Koshy
  • 1,613
  • 15
  • 17
0

@GaétanZ's solution did not work for me (IOS12) but he's concept is right..

SO I did the next logical step:

IF table content does not know how tall is the cell THEN lets just “keep on scrolling" down RIGHT AFTER inserting the cell

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 tested only

Yaro
  • 1,222
  • 11
  • 15
0

This problem occurs if the height of the cells varies greatly. Vlad’s solution works great. But difficult to implement. I suggest a simpler way. It will help you in most cases.

Add a variable private var cellHeightCache = [IndexPath: CGFloat]() to your controller. And implement two delegate methods:

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
}
dronpopdev
  • 797
  • 10
  • 13
0

What everyone is saying about estimated row heights is true. So taking all of that into consideration here's the idea:

Store the heights of each row in a data structure (I choose a dictionary), then use that value from the dictionary for heightForRowAtIndexPath AND estimatedHeightForRowAtIndexPath methods

So the question is, how to get the row height if you are using dynamic label sizing. Well simple, just use the willDisplayCell method to find the cell frame

Here is my total working version, and sorry for the objective c...its just the project I'm working on right now:

declare a property for your dictionary:

@property (strong) NSMutableDictionary *dictionaryCellHeights;

init the dictionary:

self.dictionaryCellHeights = [[NSMutableDictionary alloc]init];

capture height:

-(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];
}

use height:

-(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];
}

Then when inserting the rows:

[self.tableViewTouchActivities performBatchUpdates:^{
             [self.tableViewTouchActivities insertRowsAtIndexPaths:toInsertIndexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
        } completion:^(BOOL finished){
            [self.tableViewTouchActivities finishInfiniteScroll];
        }];

*note - I'm using this library for infiniteScrolling https://github.com/pronebird/UIScrollView-InfiniteScroll/blob/master/README.md

MobileMon
  • 8,341
  • 5
  • 56
  • 75
0

for me work, turn off automatic estimate for tableview rows, section, headers and I use heightForRowAt

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        if indexPath.row % 2 == 1 {
            if arrowsVisible {
                return 20
            }
            return 5
        }
}
Vít Zadina
  • 168
  • 8