Irbisa · Шпаргалка

UICollectionView и diffable data source

Compositional layout, diffable data source, регистрация ячеек, content configuration, префетч

Middle-разработчик · 23 вопроса

  1. 01

    Когда выбирать UICollectionView, а не UITableView?

    Collection view выбирают всегда, когда экран не сводится к одной вертикальной колонке строк на всю ширину: сетки, карусели, смешанные секции.

  2. 02

    Товары без скидки после небольшого скролла начинают показывать бейдж SALE: что не так с этим кодом?

    override func collectionView(_ collectionView: UICollectionView,                             cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {  let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ProductCell",                                                for: indexPath) as! ProductCell  let product = products[indexPath.item]  cell.titleLabel.text = product.title  if product.isOnSale {    cell.saleBadge.isHidden = false    cell.priceLabel.textColor = .systemRed  }  return cell}

    Ячейка переиспользуется, а код выставляет бейдж и красную цену только в ветке true: ячейка, которая последней показывала товар со скидкой, сохраняет и то и другое, когда её достают из очереди для обычного товара.

  3. 03

    Найдите баг: свайп для удаления заметки роняет приложение с NSInternalInconsistencyException.

    override func tableView(_ tableView: UITableView,                        commit editingStyle: UITableViewCell.EditingStyle,                        forRowAt indexPath: IndexPath) {  guard editingStyle == .delete else { return }  tableView.deleteRows(at: [indexPath], with: .automatic)  notes.remove(at: indexPath.row)  storage.save(notes)}

    deleteRows вызывается до того, как модель уменьшилась: во время обновления таблица спрашивает у data source новое число строк, всё ещё получает старое и бросает NSInternalInconsistencyException с «Invalid update: invalid number of rows in section 0».

  4. 04

    При быстром скролле сетки фотографий ячейки на мгновение показывают картинку предыдущего элемента: в чём причина и как это исправить?

    Ячейка из очереди представляет собой переиспользованный объект, на котором ещё висит старая картинка, а запрос, запущенный для её предыдущего элемента, позже дописывает в неё результат.

    final class PhotoCell: UICollectionViewCell {  let imageView = UIImageView()  private var loadTask: Task<Void, Never>?  private var photoID: Photo.ID?   override func prepareForReuse() {…
  5. 05

    Найдите баг: после прихода новых песен нажатие на звёздочку в строке иногда добавляет в избранное другую песню.

    override func tableView(_ tableView: UITableView,                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {  let cell = tableView.dequeueReusableCell(withIdentifier: "Song", for: indexPath) as! SongCell  cell.titleLabel.text = songs[indexPath.row].title  cell.starButton.tag = indexPath.row  cell.starButton.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside)  return cell} @objc private func starTapped(_ sender: UIButton) {  songs[sender.tag].isFavorite.toggle()} func songArrived(_ song: Song) {  songs.insert(song, at: 0)  tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)}

    Индекс строки копируется в starButton.tag при конфигурации ячейки, а insertRows не переконфигурирует ячейки, которые уже на экране, поэтому после появления песни сверху каждая видимая звёздочка хранит старый индекс и переключает песню на строку выше.

  6. 06

    Как сделать сетку в три колонки на UICollectionViewFlowLayout и когда переходить на compositional layout?

    Flow layout раскладывает элементы в строку и переносит на следующую, поэтому сетка задаётся просто шириной элемента, посчитанной из доступной ширины: вычесть отступы секции и промежутки, разделить на число колонок и округлить вниз.

    final class GridViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {  private static let spacing: CGFloat = 8  private let columns: CGFloat = 3   init() {    let layout = UICollectionViewFlowLayout()…
  7. 07

    Зачем нужен diffable data source вместо reloadData или performBatchUpdates?

    Потому что вы описываете всё состояние снапшотом, а вставки, удаления и перемещения UIKit вычисляет сам.

    enum Section { case main } final class ContactsViewController: UIViewController {  private var collectionView: UICollectionView!  private var dataSource: UICollectionViewDiffableDataSource<Section, Contact.ID>!  private var contacts: [Contact.ID: Contact] = [:]…
  8. 08

    Что класть в diffable-снапшот (структуры модели или только их id) и что ломается, если класть структуры?

    Только стабильные id.

    struct Message: Identifiable, Hashable {  let id: UUID  var text: String  var isRead: Bool}…
  9. 09

    Переключение звёздочки «избранное» через reloadItems заставляет обложку мигать и убирает клавиатуру из текстового поля в той же строке: что нужно было вызвать?

    snapshot.reconfigureItems([id]): он заново запускает регистрацию ячейки на той же ячейке, что на экране, а reloadItems выбрасывает эту ячейку и достаёт из очереди новую.

  10. 10

    Что заменяют CellRegistration и contentConfiguration в современном collection view и что они дают?

    Потому что оба убирают ошибки, которые допускал старый подход: регистрация связывает класс ячейки с типом элемента на этапе компиляции, а content configuration делает внешний вид ячейки значением, которое вы присваиваете, а не набором свойств, которые вы меняете.

    private lazy var rowRegistration =  UICollectionView.CellRegistration<UICollectionViewListCell, Todo.ID> { [weak self] cell, _, id in    guard let todo = self?.store[id] else { return }    var content = cell.defaultContentConfiguration()   // a value: copy, change, assign    content.text = todo.title    content.secondaryText = todo.due.formatted(date: .abbreviated, time: .omitted)…
  11. 11

    Этот data source компилируется без предупреждений: что произойдёт при запуске и как это исправить?

    dataSource = UICollectionViewDiffableDataSource<Section, Contact.ID>(collectionView: collectionView) {  collectionView, indexPath, id in  let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Contact.ID> {    cell, _, id in    var content = cell.defaultContentConfiguration()    content.text = ContactStore.shared.name(for: id)    cell.contentConfiguration = content  }  return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)}

    На iOS 15 и новее на первом же dequeue вылетит исключение: UIKit обнаруживает регистрацию, созданную внутри cell provider.

  12. 12

    Что дают item, group и section в compositional layout и относительно чего на самом деле считается .fractionalWidth(1.0)?

    Item задаёт место под одну ячейку, group выстраивает items вдоль оси, section повторяет свою группу и добавляет отступы и прокрутку, а layout содержит секции.

    func makeLayout() -> UICollectionViewCompositionalLayout {  UICollectionViewCompositionalLayout { _, environment in    let columns = environment.container.effectiveContentSize.width > 700 ? 4 : 2     // 1.0 of its GROUP, not of the screen.    let item = NSCollectionLayoutItem(layoutSize: .init(…
  13. 13

    Лента самоподстраивающихся ячеек дёргается при скролле, а индикатор прокрутки всё время меняет размер: в чём причина?

    Пока ячейка не измерена, layout учитывает в размере контента её оценочную высоту.

    func makeLayout() -> UICollectionViewCompositionalLayout {  // Both item and group estimated: this is what turns on measuring.  let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),                                    heightDimension: .estimated(120))  // near the real average  let item = NSCollectionLayoutItem(layoutSize: size)  let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])…
  14. 14

    Дизайн хочет вид inset-grouped таблицы с разделителями и swipe-to-delete, а экран уже сделан на collection view: что использовать?

    UICollectionLayoutListConfiguration: она превращает compositional layout или одну его секцию в строки в стиле таблицы с разделителями, хедерами, свайп-действиями и аксессуарами.

    private func makeLayout() -> UICollectionViewLayout {  var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)   // Stored on the layout for the screen's lifetime: capture self weakly.  config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in    // Resolve the index path to an identifier now; it may be stale later.…
  15. 15

    Пользователь пролистывает сотню строк, а запросы картинок продолжают приходить для ячеек, давно ушедших с экрана: что пропустил код префетча?

    collectionView(_:cancelPrefetchingForItemsAt:): без него каждый быстрый скролл забивает очереди загрузки и декодирования работой для элементов, на которые никто не посмотрит, и видимые строки ждут за ней.

    @MainActorfinal class FeedViewController: UIViewController, UICollectionViewDataSourcePrefetching {  private var dataSource: UICollectionViewDiffableDataSource<Int, Post.ID>!  private let images = ImageCache()  // Keyed by the stable id: index paths move with every snapshot.  private var tasks: [Post.ID: Task<Void, Never>] = [:]…
  16. 16

    Этот код бесконечной ленты иногда загружает одну страницу дважды, а потом падает с ошибкой дублирующихся идентификаторов: что не так и как сделать правильно?

    func collectionView(_ collectionView: UICollectionView,                    willDisplay cell: UICollectionViewCell,                    forItemAt indexPath: IndexPath) {  guard indexPath.item == posts.count - 1 else { return }  Task {    let page = try await api.fetchPosts(page: currentPage + 1)    currentPage += 1    posts += page    var snapshot = dataSource.snapshot()    snapshot.appendItems(page.map(\.id))    await dataSource.apply(snapshot)  }}

    Ничто не мешает второму запросу, пока первый ещё идёт.

  17. 17

    Как вставить горизонтально прокручиваемый collection view в ячейку таблицы и что ломается при переиспользовании?

    Дайте каждой ячейке свой collection view и свой data source, передавайте элементы строки при конфигурации и сохраняйте горизонтальное смещение каждой строки по её id, иначе из-за переиспользования одна строка окажется прокрученной туда, где оставили другую, а прокрученная строка при возврате сбросится.

    final class ShelfCell: UITableViewCell {  let books = UICollectionView(frame: .zero, collectionViewLayout: ShelfCell.makeLayout())  // Created in init(style:reuseIdentifier:) with the cell's own registration.  private var dataSource: UICollectionViewDiffableDataSource<Int, Book.ID>!  private(set) var shelfID: Shelf.ID?…
  18. 18

    Перестановка перетаскиванием выглядит правильно, пока следующее обновление модели не вернёт строку на прежнее место: что упустил обработчик перестановки?

    UIKit переместил ячейку, а data source обновил свой снапшот, но новый порядок никто не записал в вашу модель, и следующий снапшот, построенный из модели, возвращает строку назад.

    // The snapshot holds ids; the model is an order array plus a store.private var order: [Item.ID] = []private var store: [Item.ID: Item] = [:] func configureReordering() {  dataSource.reorderingHandlers.canReorderItem = { [weak self] id in…
  19. 19

    Нужна скруглённая тонированная подложка под всей секцией и маленький бейдж, свисающий с угла ячейки: какие объекты layout это дают?

    Decoration item для фона секции и привязанный supplementary item для бейджа.

    let badge = NSCollectionLayoutSupplementaryItem(  layoutSize: .init(widthDimension: .absolute(20), heightDimension: .absolute(20)),  elementKind: "badge",  containerAnchor: NSCollectionLayoutAnchor(edges: [.top, .trailing],                                            fractionalOffset: CGPoint(x: 0.3, y: -0.3)))let item = NSCollectionLayoutItem(…
  20. 20

    Горизонтальная карусель с постраничной прокруткой должна увеличивать центральную карточку во время свайпа: как это сделать в compositional layout?

    Поставьте секции orthogonalScrollingBehavior = .groupPagingCentered и масштабируйте карточки в visibleItemsInvalidationHandler: он вызывается на каждом кадре прокрутки этой секции с видимыми элементами и текущим смещением.

    func carouselSection() -> NSCollectionLayoutSection {  let item = NSCollectionLayoutItem(layoutSize: .init(    widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))   // 0.75 of the width, so the neighbouring cards peek in on both sides.  let group = NSCollectionLayoutGroup.horizontal(…
  21. 21

    Вы написали свой UICollectionViewLayout для «ступенчатой» сетки, и скролл ползёт: какой метод делает слишком много?

    layoutAttributesForElements(in:): UIKit вызывает его снова и снова, пока пользователь скроллит, поэтому версия, которая считает фреймы или обходит все элементы, делает каждый шаг скролла O(число элементов).

    final class StaggeredLayout: UICollectionViewLayout {  var itemHeights: [CGFloat] = []  private let columns = 2  private let maxItemHeight: CGFloat = 400                      // upper bound for the search  private var cache: [UICollectionViewLayoutAttributes] = []    // sorted by frame.minY  private var byItem: [UICollectionViewLayoutAttributes] = []   // in item order…
  22. 22

    Живая лента из десяти тысяч строк на мгновение замирает при каждом обновлении из сокета: на что тратит время apply и что менять?

    Внутри apply: он хеширует и сравнивает каждый идентификатор, чтобы найти изменения, а потом анимирует каждое изменение, причём на главном потоке.

    @MainActorfinal class FeedController {  private var dataSource: UICollectionViewDiffableDataSource<Int, Message.ID>!  private let store = MessageStore()   // an actor: merging and sorting run off main  private var pending: [Message] = []  private var flushTask: Task<Void, Never>?…
  23. 23

    Вам достался UITableViewDelegate на 600 строк, и его нужно перевести на compositional list: что переносить первым и как доказать, что ничего не сломалось?

    Сначала выделите модель строк (то, по чему делает switch метод cellForRowAt, становится идентификатором элемента в снапшоте) и покройте тестами эту чистую функцию «состояние → строки» до того, как поменяется хоть один тип UIKit.