Почему мой UITableView "прыгает" при вставке или удалении строки?

(Счастлив принять ответ в 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, скачок крошечный.

Можете ли вы помочь мне понять, почему это и как исправить?

Ответ 1

На iOS 11 UITableView использует приблизительную высоту строки по умолчанию.

Это приводит к непредсказуемому поведению при вставке/перезагрузке или удалении строк, поскольку в большинстве случаев UITableView имеет неправильный размер содержимого:

Чтобы избежать слишком большого количества вычислений компоновки, tableView запрашивает heightForRow только для каждого вызова cellForRow и запоминает его (в обычном режиме tableView запрашивает heightForRow все indexPaths tableView). Высота остальных ячеек равна значению estimatedRowHeight до тех пор, пока не будет вызван соответствующий им cellForRow.

// estimatedRowHeight mode
contentSize.height = numberOfRowsNotYetOnScreen * estimatedRowHeight + numberOfRowsDisplayedAtLeastOnce * heightOfRow

// normal mode
contentSize.height = heightOfRow * numberOfCells

Одно из решений состоит в том, чтобы отключить режим estimatedRowHeight, установив для параметра valuesRowHeight значение 0 и внедрив heightForRow для каждой из ваших ячеек.

Конечно, если ваши ячейки имеют динамическую высоту (с обременительными вычислениями компоновки большую часть времени, поэтому вы использовали estimatedRowHeight по уважительной причине), вам придется найти способ воспроизвести оптимизацию estimatedRowHeight без ущерба для contentSize вашего tableView., Посмотрите на AsyncDisplayKit или UITableView-FDTemplateLayoutCell.

Другое решение - попытаться найти estimatedRowHeight, который подходит. Начиная с iOS 10, вы также можете попробовать использовать UITableView.automaticDimension. UIKit найдет для вас значение:

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

На iOS 11 это уже значение по умолчанию.

Ответ 2

Я не знаю, как это исправить правильно, но мое решение работает для меня

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

Ответ 3

Я фиксировал прыжок, кэшируя высоту строк ячеек, а также высоту нижних колонтитулов и заголовков разделов. Для подхода требуется наличие уникального идентификатора кэша для разделов и строк.

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

enter image description here

Ответ 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

Это происходило для меня в UITableView, который имел несколько разделов, но никаких определений для высоты заголовка или представления для этих разделов не было. Добавление следующих методов делегата исправило это для меня - надеюсь, что это поможет!

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

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    return nil
}

Ответ 6

@Решение GaétanZ не сработало для меня (IOS12), но он прав.

Итак, я сделал следующий логический шаг:

Содержимое таблицы IF не знает, насколько высока ячейка. THEN позволяет просто "продолжать прокрутку" вниз. ВПРАВО ПОСЛЕ вставки ячейки

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

Ответ 7

Эта проблема возникает, если высота ячеек сильно варьируется. Решение Vlads прекрасно работает. Но сложно реализовать. Я предлагаю более простой способ. Это поможет вам в большинстве случаев.

Добавьте переменную 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
}