Как стать автором
Обновить

Регулировка contentOffset с помощью UICollectionViewLayout

Разработка под iOS *Swift *
Из песочницы

Один из распространенных UI элементов в iOS является UICollectionView.

Часто при построении таких коллекций возникает необходимость обновления данных, например добавления новых ячеек в коллекцию.

Рассмотрим простой пример - список новостей, вертикальный UICollectionView. При пролистывании списка вниз, необходимо подгружать старые новости. В данном случае все просто - нужно обновить данные и выполнить один из методов:

func reloadData()
func insertItems(at indexPaths: [IndexPath])

Таким образом, визуально, видимая пользователем область экрана останется на том же месте и контент появляется внизу.

Теперь усложним задачу. Есть необходимость добавлять контент сверху. В таком случае, из-за особенностей работы UIScrollView, видимая пользователем область экрана сдвинется вверх на высоту контента. Для многих задач это нормальное поведение, например, для той же ленты новостей. Произойдет обновление контента, т.е. экран пользователя окажется сразу на свежих данных.

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

В случае любого обновления UICollectionView - contentOffset не меняется, именно по этому происходит "сдвиг" видимой пользователем области экрана.

Чтобы это не происходило, необходимо расчитывать новый contentOffset, который будет актуален после обновления коллекции и с учетом этого обновления.

В случае когда происходит только добавление элементов группой, например, подгрузка части истории, то зная высоту группы добавляемых ячеек, необходимо прибавить это значение к contentOffset:

var currentContentOffset = collectionView.contentOffset
collectionView.insertItems(at: addedIndexPaths) // or reloadData()
currentContentOffset.y += addedContentHeight
collectionView.contentOffset = currentContentOffset

Теперь усложним задачу. При обновлении коллекции могут использоваться разные операции:

collectionView.performBatchUpdates({
  collectionView.deleteItems(at: deletedIndexPaths)
  collectionView.insertItems(at: insertedIndexPaths)
  collectionView.reloadItems(at: reloadedIndexPaths)
}, completion: nil)

Особенно если использовать DifferenceKit или аналоги.

В таком случае любое изменение высоты контента, который расположен выше текущего contentOffset, будет приводить к "сдвигам".

Можно решать эту проблему, рассчитывая разницу высот контента после обновления и прибавляя к текущему contentOffset, но задача усложняется тем, что дополнительно нужно еще определять - какой контент учитывать, а какой нет. Такая необходимость возникает из-за того, что обновление/удаление/добавление ячеек, находящихся "внизу", т.е. ниже текущего contentOffset или ниже видимой части экрана, учитывать не нужно.

Одно из решений - использовать UICollectionViewLayout.

Суть решения заключается в том, что нам так же необходимо посчитать разницу, на которую нужно отрегулировать contentOffset. Но можно не расчитывать размер целевых ячеек, а использовать frame определенной ячейки, например, первой видимой, и просто получить ее frame до и после обновления. Разница, например, Y координат для вертикальной коллекции, и будет тем самым диффом, который необходимо добавить к contentOffset.

Здесь есть важный нюанс - для получения frame ячейки нужен indexPath, который, естественно, поменяется после обновления. И тут как раз пригодится метод prepare(forCollectionViewUpdates:), в котором можно получить операции обновления коллекции.

override open func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
  guard
      updateItems.count > 0 else {
         return
  }
        
  let previousContentOffset = collectionView.contentOffset
  let diff = offsetDifference(
      for: updateItems
  )
  guard diff.x != 0 || diff.y != 0 else {
      return
  }
  offset = CGPoint(
      x: previousContentOffset.x + diff.x,
      y: previousContentOffset.y + diff.y
  )
}

В методе offsetDifference рассчитывается разница:

func offsetDifference(for updates: [UICollectionViewUpdateItem]) -> CGPoint {
  
  let previousVisibleFrame = previousVisibleAttributes[visibleState.targetIndexPath] ?? .zero

  for item in updates {
      visibleStateController.update(with: item)
  }

  let newVisibleFrame = layoutDataSource?
      .layoutAttributesForItem(at: visibleStateController.targetIndexPath)?.frame ?? .zero
  let calculatedOffsetDiff = diff(
      from: previousVisibleFrame,
      new: newVisibleFrame
  )
  return calculatedOffsetDiff
}

Здесь есть два ключевых момента. Первый - previousVisibleAttributes. Это атрибуты indexPathsForVisibleItems. Их нужно хранить и обновлять перед каждым обновлением коллекции. Для этого подходит метод invalidateLayout(with:):

override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
  if !context.invalidateEverything {
      refreshVisibleAttributes()
  }
  super.invalidateLayout(with: context)
}

func refreshVisibleAttributes() {        
  guard collectionView.indexPathsForVisibleItems.count > 0 else {
      return
  }
  previousVisibleAttributes = collectionView.indexPathsForVisibleItems
      .reduce(into: [IndexPath: CGRect](), { seed, indexPath in
          seed[indexPath] = layoutAttributesForItem(at: indexPath)?.frame ?? .zero
      })
}

Второй момент - рассчитать indexPath целевой видимой ячейки после обновления, чтобы после того, как коллекция построит свой новый layout, получить новый frame этой ячейки:

extension VisibleIndexesStateControllerImpl {
    open func update(with item: UICollectionViewUpdateItem) {
        switch item.updateAction {
        case .insert:
            targetIndexPath = inserted(with: item)
        case .delete:
            targetIndexPath = deleted(with: item)
        case .move, .none, .reload:
            break
        @unknown default:
            break
        }
    }
    
    private func inserted(with item: UICollectionViewUpdateItem) -> IndexPath {
        guard let indexPath = item.indexPathAfterUpdate else {
            return targetIndexPath
        }
        
        if item.isSection {
            return targetIndexPath.insertedSection(at: indexPath)
        }
        
        return targetIndexPath.insertedRow(at: indexPath)
    }
    
    private func deleted(with item: UICollectionViewUpdateItem) -> IndexPath {
        guard let indexPath = item.indexPathBeforeUpdate else {
            return targetIndexPath
        }
        
        if item.isSection {
            return targetIndexPath.deletedSection(at: indexPath)
        }
        
        return targetIndexPath.deletedRow(at: indexPath)
    }
}

Здесь простая логика - если вставка/удаление происходит "выше" целевой видимой ячейки, то обновляем значение ее IndexPath:

extension IndexPath {
    public func insertedRow(at indexPath: IndexPath) -> IndexPath {
        if indexPath.section == section,
           indexPath.row <= row {
            return incrementedRow()
        }
        return self
    }
    
    public func insertedSection(at indexPath: IndexPath) -> IndexPath {
        if indexPath.section <= section {
            return incrementedSection()
        }
        return self
    }
    
    public func deletedRow(at indexPath: IndexPath) -> IndexPath {
        if indexPath.section == section,
           indexPath.row <= row {
            return decrementedRow()
        }
        return self
    }
    
    public func deletedSection(at indexPath: IndexPath) -> IndexPath {
        if indexPath.section <= section {
            return decrementedSection()
        }
        return self
    }
}

В результате, зная старый и новый indexPath целевой видимой ячейки, можно получить соответствующие frames и рассчитать новый contentOffset.

Остается только применить новый contentOffset:

override open func finalizeCollectionViewUpdates() {
  super.finalizeCollectionViewUpdates()
  
  guard let targetContentOffset = offset else {
      return
  }
  offset = nil
  collectionView.contentOffset = targetContentOffset
}

Решение, использующее данный способ, лежит тут.

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0 +3
Просмотры 712
Комментарии Комментарии 2