Пользовательские UICollectionViewLayout с ячейками автоматического калибровки разрываются с более высокими оцененными высотами элементов

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

На изображении ниже левый скриншот показывает начальный рендеринг с большой оцененной высотой, а на правом изображении показано, где расчетная высота меньше конечной высоты.

Эта проблема возникает в IOS 10 и 11.

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

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

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

enter image description here

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

/// Simple demo layout, only 1 section is supported
/// This is not optimised, it is purely a simplified version
/// of a more complex custom layout that demonstrates
/// the glitch.
public class Layout: UICollectionViewLayout {

    public var estimatedItemHeight: CGFloat = 50
    public var spacing: CGFloat = 10

    var contentWidth: CGFloat = 0
    var numberOfItems = 0
    var heightCache = [Int: CGFloat]()

    override public func prepare() {
        super.prepare()

        self.contentWidth = self.collectionView?.bounds.width ?? 0
        self.numberOfItems = self.collectionView?.numberOfItems(inSection: 0) ?? 0
    }

    override public var collectionViewContentSize: CGSize {
        // Get frame for last item an duse maxY
        let lastItemIndex = self.numberOfItems - 1
        let contentHeight = self.frame(for: IndexPath(item: lastItemIndex, section: 0)).maxY

        return CGSize(width: self.contentWidth, height: contentHeight)
    }

    override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        // Not optimal but works, get all frames for all items and calculate intersection
        let attributes: [UICollectionViewLayoutAttributes] = (0 ..< self.numberOfItems)
            .map { IndexPath(item: $0, section: 0) }
            .compactMap { indexPath in
                let frame = self.frame(for: indexPath)
                guard frame.intersects(rect) else {
                    return nil
                }
                let attributesForItem = self.layoutAttributesForItem(at: indexPath)
                return attributesForItem
            }
        return attributes
    }

    override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = self.frame(for: indexPath)
        return attributes
    }

    public func frame(for indexPath: IndexPath) -> CGRect {
        let heightsTillNow: CGFloat = (0 ..< indexPath.item).reduce(0) {
            return $0 + self.spacing + (self.heightCache[$1] ?? self.estimatedItemHeight)
        }
        let height = self.heightCache[indexPath.item] ?? self.estimatedItemHeight
        let frame = CGRect(
            x: 0,
            y: heightsTillNow,
            width: self.contentWidth,
            height: height
        )
        return frame
    }

    override public func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
        let index = originalAttributes.indexPath.item
        let shouldInvalidateLayout = self.heightCache[index] != preferredAttributes.size.height

        return shouldInvalidateLayout
    }

    override public func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)

        let index = originalAttributes.indexPath.item
        let oldContentSize = self.collectionViewContentSize

        self.heightCache[index] = preferredAttributes.size.height

        let newContentSize = self.collectionViewContentSize
        let contentSizeDelta = newContentSize.height - oldContentSize.height

        context.contentSizeAdjustment = CGSize(width: 0, height: contentSizeDelta)

        // Everything underneath has to be invalidated
        let indexPaths: [IndexPath] = (index ..< self.numberOfItems).map {
            return IndexPath(item: $0, section: 0)
        }
        context.invalidateItems(at: indexPaths)

        return context
    }

}

Здесь ячейка предпочитает вычисление атрибутов макета (обратите внимание, что мы разрешаем макету решать и фиксируем ширину, и мы просим автозапуск рассчитать высоту ячейки, заданной шириной).

public class Cell: UICollectionViewCell {

    // ...

    public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let finalWidth = layoutAttributes.bounds.width

        // With the fixed width given by layout, calculate the height using autolayout
        let finalHeight = systemLayoutSizeFitting(
            CGSize(width: finalWidth, height: 0),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        ).height

        let finalSize = CGSize(width: finalWidth, height: finalHeight)
        layoutAttributes.size = finalSize
        return layoutAttributes
    }
}

Есть ли что-то очевидное, что вызывает это в логике компоновки?

Ответ 1

Я продублировал проблему с помощью набора

estimatedItemHeight = 500

в демо-коде. У меня вопрос о вашей логике для вычисления фрейма для каждой ячейки: вся высота в self.heightCache равна нулю, поэтому утверждение

return $0 + self.spacing + (self.heightCache[$1] ?? self.estimatedItemHeight)

в рамке функции такая же, как и

return $0 + self.spacing + self.estimatedItemHeight

Я думаю, может быть, вы должны проверить этот код

self.heightCache[index] = preferredAttributes.size.height

в функции

invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext

как preferredAttributes.size.height всегда равен нулю

и

finalHeight

также равно нулю в классе

клетка

Ответ 2

Я также пытаюсь создать пользовательский подкласс UICollectionViewLayout для макета UITableView -style, и я сталкиваюсь с немного другой проблемой. Но я обнаружил, что в shouldInvalidateLayoutForPreferredLayoutAttributes, если вы вернетесь в зависимости от того, соответствует ли предпочтительная высота исходной высоте (а не соответствует ли высота, соответствующая высоте вашего кеша), она будет правильно применять атрибуты макета, и все ваши ячейки будут имеют правильную высоту.

Но тогда вы получаете недостающие ячейки, потому что вы не всегда получаете layoutAttributesForElementsInRect на layoutAttributesForElementsInRect после того, как произошла автоматическая калибровка, и изменили высоты вашей ячейки (и, следовательно, позиции y).

См. Пример проекта здесь, на GitHub.

Изменение: на мой вопрос ответили, и пример GitHub теперь работает.