Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UICollectionView cells resizing when deleting items with estimatedItemSize

I have a simple project with a storyboard containing only a single a UICollectionViewController, built with Xcode 7.1.1 for iOS 9.1

class ViewController: UICollectionViewController {

    var values = ["tortile", "jetty", "tisane", "glaucia", "formic", "agile", "eider", "rooter", "nowhence", "hydrus", "outdo", "godsend", "tinkler", "lipscomb", "hamlet", "unbreeched", "fischer", "beastings", "bravely", "bosky", "ridgefield", "sunfast", "karol", "loudmouth", "liam", "zunyite", "kneepad", "ashburn", "lowness", "wencher", "bedwards", "guaira", "afeared", "hermon", "dormered", "uhde", "rusher", "allyou", "potluck", "campshed", "reeda", "bayonne", "preclose", "luncheon", "untombed", "northern", "gjukung", "bratticed", "zeugma", "raker"]

    @IBOutlet weak var flowLayout: UICollectionViewFlowLayout!
    override func viewDidLoad() {
        super.viewDidLoad()        
        flowLayout.estimatedItemSize = CGSize(width: 10, height: 10)
    }

    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return values.count
    }

    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCellWithReuseIdentifier("MyCell", forIndexPath: indexPath) as! MyCell
        cell.name = values[indexPath.row]
        return cell
    }

    override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
        values.removeAtIndex(indexPath.row)
        collectionView.deleteItemsAtIndexPaths([indexPath])
    }
}

class MyCell: UICollectionViewCell {

    @IBOutlet weak var label: UILabel!

    var name: String? {
        didSet {
            label.text = name
        }
    }
}

When deleting the cells from the collection view, all remaining cells animate to their estimatedItemSize, and then swap back to the correct size.

enter image description here

Interestingly, this produces auto layout constraint warnings for each cell when the animation occurs:

2015-12-02 14:30:45.236 CollectionTest[1631:427853] Unable to simultaneously satisfy constraints.
    Probably at least one of the constraints in the following list is one you don't want. 
    Try this: 
        (1) look at each constraint and try to figure out which you don't expect; 
        (2) find the code that added the unwanted constraint or constraints and fix it. 
    (Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints) 
(
    "<NSAutoresizingMaskLayoutConstraint:0x14556f780 h=--& v=--& H:[UIView:0x1456ac6c0(10)]>",
    "<NSLayoutConstraint:0x1456acfd0 UIView:0x1456ac6c0.trailingMargin == UILabel:0x1456ac830'raker'.trailing>",
    "<NSLayoutConstraint:0x1456ad020 UILabel:0x1456ac830'raker'.leading == UIView:0x1456ac6c0.leadingMargin>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x1456acfd0 UIView:0x1456ac6c0.trailingMargin == UILabel:0x1456ac830'raker'.trailing>

My initial thought was that breaking these constraints was what was causing the resizing problem.

Updating the cell's awakeFromNib method:

override func awakeFromNib() {
    super.awakeFromNib()
    contentView.translatesAutoresizingMaskIntoConstraints = false
} 

fixes the warnings, but the problem still occurs.

I tried re-adding my own constraints between the cell and its contentView, but this didn't resolve the issue:

override func awakeFromNib() {
    super.awakeFromNib()
    contentView.translatesAutoresizingMaskIntoConstraints = false

    for constraint in [
        contentView.leadingAnchor.constraintEqualToAnchor(leadingAnchor),
        contentView.trailingAnchor.constraintEqualToAnchor(trailingAnchor),
        contentView.topAnchor.constraintEqualToAnchor(topAnchor),
        contentView.bottomAnchor.constraintEqualToAnchor(bottomAnchor)]
    {
        constraint.priority = 999
        constraint.active = true
    }
}

Thoughts?

like image 697
Ashley Mills Avatar asked Dec 02 '15 14:12

Ashley Mills


2 Answers

flow layout calculates actual sizes of cells after doing layout by estimated sizes to define which ones are visible. After that it adjusts the layout based on real sizes.

However, when it animates, when it calculates initial position for animation, it doesn't reach the stage of dequeueing cells and running auto layout there, so it uses only estimated sizes.

The easiest way is to try to give the closest estimated sizes, or if you could provide the size in the delegate in sizeForItemAt call.

In my case, I was trying to animate layoutAttributes without inserting or deleting cells and for that specific case I subclassed UICollectionViewFlowLayout and then overridden this method:

override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
    if !context.invalidateEverything && context.invalidatedItemIndexPaths == nil && context.contentOffsetAdjustment == .zero  && context.contentSizeAdjustment == .zero {
        return
    }
    super.invalidateLayout(with: context)
}

This prevents recalculating layout attributes using estimated sizes when nothing has been changed.

like image 190
Dmitry Avatar answered Oct 17 '22 00:10

Dmitry


TL;DR: I could only get a collection view to properly behave with the delegate sizeForItem method. Working sample here: https://github.com/chrisco314/CollectionView-AutoLayout

In the controller:

func collectionView(_ collectionView: UICollectionView,
                    layout collectionViewLayout: UICollectionViewLayout,
                    sizeForItemAt indexPath: IndexPath) -> CGSize {
    var cell = Cell.prototype
    let contents = data[indexPath.section][indexPath.item]
    cell.text = contents
    cell.expand = selected.contains(indexPath)

    let width = collectionView.bounds
        .inset(collectionView.contentInset)
        .inset(layout.sectionInset)
        .width
    let finalSize = cell.systemLayoutSizeFitting(
        .init(width: width, height: 0),
        withHorizontalFittingPriority: .required,
        verticalFittingPriority: .fittingSizeLevel)
        .withWidth(width)
    print("sizeForItemAt: \(finalSize)")
    return finalSize
}

In the cell:

override func systemLayoutSizeFitting(
    _ targetSize: CGSize,
    withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
    verticalFittingPriority: UILayoutPriority) -> CGSize {

    let contentSize = contentView.systemLayoutSizeFitting(
        targetSize,
        withHorizontalFittingPriority: horizontalFittingPriority,
        verticalFittingPriority: verticalFittingPriority)

    return contentSize
}

Constraints for an expanding panel:

lazy var panel: UIView = {
    let view = Panel()
    view.pin(body, to: .left, .top, .right)
    view.clipsToBounds = true
    panelHeight = view.heightAnchor.constraint(equalTo: body.heightAnchor)
    return view
}()

var panelHeight: NSLayoutConstraint!
lazy var height:CGFloat = 60

lazy var body: UIView = {
    let view = Body()
    view.backgroundColor = .blue
    view.pin(contents, inset: 9)
    let bodyHeight = view.heightAnchor.constraint(equalToConstant: height)
    bodyHeight.isActive = true
    return view
}()

lazy var contents: UILabel = {
    let label = UILabel()
    label.backgroundColor = .white
    label.numberOfLines = 0
    label.text = "Body with height constraint of \(height)"
    return label
}()

I had a host of problems like this and many others, spent a stupid amount of time trying to find a path through that worked for all cases - rendering with autolayout, rational animations for insertion and deletion, handling rotations, etc. In my experience, the only way that worked was to use the sizeForItem delegate method. You can use estimatedSize and auto layout, but for me, the animations would always collapse to the top, and everything then spring down again - perhaps what you are seeing.

I have a sample that is basically my playground for testing. I tried different approaches across the different tabs of the tab view controller here, using estimated sizes, constraints on the cells themselves, custom systemSizeFitting that returns the desired size, and the delegate based sizeThatFits

The sample is a bit hacked up, but the third tab demonstrates a delegate based method that works for expanding cells, and insertion and deletion animations. Note that tab2? demonstrates inconsistent animations that the collection view uses, based on the ratio of expanding cells. If the ratio is greater than 2:1, it fades and snaps, if it is less then 2:1, it animates up and down smoothly.

All the non delegate approaches that tried failed when it came to animations, per above. Maybe there is an approach that works without the delegate method (and I would love to see if it it did), but I could not find it.

enter image description here

like image 25
Chris Conover Avatar answered Oct 17 '22 01:10

Chris Conover