UICollectionView reloadData does not reload when preceded by performBatchUpdates
| Originator: | smiller | ||
| Number: | rdar://32543861 | Date Originated: | June 2 2017 |
| Status: | Open | Resolved: | |
| Product: | iOS + SDK | Product Version: | iOS 10.2, iOS 10.3 |
| Classification: | UIKit | Reproducible: | Always |
Area:
UIKit
Summary:
When calling `UICollectionView.performBatchUpdates` then soon after calling `UICollectionView.reloadData`, the `UICollectionView` is not properly reloaded. In this bad case, `UICollectionViewDataSource.collectionView(_:cellForItemAt:)` is never called for the `reloadData`.
This issue is not experience if `UICollectionView.reloadSections` is used in place of `reloadData`.
Here are some example method traces from the demo project:
Here we have `performBatchUpdates` followed by `reloadData` and we can see that no call is made to `UICollectionViewDataSource.collectionView(_:cellForItemAt:)` as expected with `reloadData`.
```
16:39:25.944 CollectionView.performBatchUpdates
16:39:25.945 ViewController.performBatchUpdates update closure
16:39:25.946 ViewController.collectionView(_:layout:sizeForItemAt: 0-0)
16:39:25.946 ViewController.collectionView(_:layout:sizeForItemAt: 0-1)
16:39:25.947 ViewController.collectionView(_:layout:sizeForItemAt: 0-2)
16:39:25.947 ViewController.collectionView(_:layout:sizeForItemAt: 0-3)
16:39:25.950 CollectionView.reloadData
16:39:25.951 ViewController.collectionView(_:layout:sizeForItemAt: 0-0)
16:39:26.000 ViewController.collectionView(_:layout:sizeForItemAt: 0-1)
16:39:26.000 ViewController.collectionView(_:layout:sizeForItemAt: 0-2)
16:39:26.000 ViewController.collectionView(_:layout:sizeForItemAt: 0-3)
16:39:26.001 ViewController.performBatchUpdates complete closure
16:39:26.002 CollectionView.layoutSubviews
```
Here we have `performBatchUpdates` followed by `reloadSections` and we can see that calls are properly made to `UICollectionViewDataSource.collectionView(_:cellForItemAt:)` as expected.
```
17:13:28.620 CollectionView.performBatchUpdates
17:13:28.621 ViewController.performBatchUpdates update closure
17:13:28.622 ViewController.collectionView(_:layout:sizeForItemAt: 0-0)
17:13:28.622 ViewController.collectionView(_:layout:sizeForItemAt: 0-1)
17:13:28.622 ViewController.collectionView(_:layout:sizeForItemAt: 0-2)
17:13:28.623 ViewController.collectionView(_:layout:sizeForItemAt: 0-3)
17:13:28.625 CollectionView.reloadSections
17:13:28.625 ViewController.collectionView(_:layout:sizeForItemAt: 0-0)
17:13:28.625 ViewController.collectionView(_:layout:sizeForItemAt: 0-1)
17:13:28.626 ViewController.collectionView(_:layout:sizeForItemAt: 0-2)
17:13:28.626 ViewController.collectionView(_:layout:sizeForItemAt: 0-3)
17:13:28.627 ViewController.collectionView(_:cellForItemAt: 0-0)
17:13:28.629 ViewController.collectionView(_:cellForItemAt: 0-1)
17:13:28.629 ViewController.collectionView(_:cellForItemAt: 0-2)
17:13:28.630 ViewController.collectionView(_:cellForItemAt: 0-3)
17:13:28.631 CollectionView.layoutSubviews
17:13:28.633 ViewController.performBatchUpdates complete closure
17:13:28.633 CollectionView.layoutSubviews
17:13:29.002 CollectionView.layoutSubviews
```
Steps to Reproduce:
1. Call `UICollectionView.performBatchUpdates(nil, completion: nil)`
2. Optionally, update the UICollectionViewDataSource (this will help visualize the issue)
3. Call `UICollectionView.reloadData()` immediately afterward (all of this in the same method is the easiest)
Expected Results:
All cells are reloaded by calling `UICollectionViewDataSource.collectionView(_:cellForItemAt:)`.
Observed Results:
`UICollectionViewDataSource.collectionView(_:cellForItemAt:)` is never called.
Version:
iOS 10.2.1 (14D27), iOS Simulator 10.2 (14C89), Xcode Version 8.2.1 (8C1002), iOS Simulator 10.3 (14E269), Xcode Version 8.3.2 (8E2002)
Notes:
For using the included demo project:
There are some notes at the top of ViewController.swift, the main file.
1. Run the project on any device or simulator.
2. Tap the button labeled "Bad"
- You should see that the cells change size but do not reload (they should reload to yellow). If you scroll around you will see that the cells do correctly configure when scrolled on to the screen.
3. Tap the button labeled "Reset"
- This will reset the example to its original state
4. Tap the button labeled "Good"
- You should see the proper reloading behavior since this uses `reloadSections`
5. Repeat 2-4 after changing `DISABLE_ANIMATIONS` at the top of the ViewController.swift file to `true`. This just demonstrates the same issue occurs when not using animations for the animated UICollectionView methods.
Configuration:
iPhone 7 Plus 128G AT&T, iPhone Simulator
Sample Project include (you only need the ViewController):
=====================================================================
//
// ViewController.swift
// CollectionViewBug
//
// Created by Sam Miller on 3/22/17.
// Copyright © 2017 Sam Miller. All rights reserved.
//
// A view controller demonstrating a bug in UICollectionView reloadData.
// To try, simply create an empty application in Xcode and create an instance of
// ViewController as the rootViewController.
//
// You will then see that the "Bad" button does not correctly reload the UICollectionView
// whereas the "Good" button will correctly reload the cells. "Bad" incorrectly reloads
// the cells by updating the layout but not reconfiguring the cells.
//
// You can also try these methods both with animations left on, or with animations disabled.
// Just change the global variable at the top of this file, DISABLE_ANIMATIONS. This will enabled
// or disable wrapping the UICollectionView reload methods with UIView.performWithoutUpdates.
//
import UIKit
let DISABLE_ANIMATIONS = false
class CollectionView: UICollectionView {
override func layoutSubviews() {
NSLog("CollectionView.layoutSubviews")
super.layoutSubviews()
}
override func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) {
NSLog("CollectionView.performBatchUpdates")
super.performBatchUpdates(updates, completion: completion)
}
override func reloadData() {
NSLog("CollectionView.reloadData")
super.reloadData()
}
override func reloadSections(_ sections: IndexSet) {
NSLog("CollectionView.reloadSections")
super.reloadSections(sections)
}
}
class DemoCell: UICollectionViewCell {
let label = UILabel()
public override init(frame: CGRect) {
super.init(frame: frame)
label.textAlignment = .center
label.numberOfLines = 0
contentView.addSubview(label)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
label.frame = contentView.bounds
}
}
struct CellData {
let id: String
let color: UIColor
let height: CGFloat
}
class ViewController: UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {
static let defaultCells: [CellData] = [
CellData(id: "0", color: .red, height: 250),
CellData(id: "1", color: .green, height: 120),
CellData(id: "2", color: .blue, height: 200),
CellData(id: "3", color: .purple, height: 300),
CellData(id: "4", color: .gray, height: 250),
CellData(id: "5", color: .orange, height: 100),
CellData(id: "5", color: .magenta, height: 50)
]
let collectionViewLayout = UICollectionViewFlowLayout()
let collectionView: UICollectionView
let header = UIView()
let button1 = UIButton(type: UIButtonType.custom)
let button2 = UIButton(type: UIButtonType.custom)
let button3 = UIButton(type: UIButtonType.custom)
var cells: [CellData] = ViewController.defaultCells
init() {
collectionView = CollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
NSLog("ViewController.viewDidLoad")
super.viewDidLoad()
view.backgroundColor = .white
collectionView.backgroundColor = .white
view.addSubview(collectionView)
collectionView.register(DemoCell.self, forCellWithReuseIdentifier: "cell")
collectionView.dataSource = self
collectionView.delegate = self
setupHeader()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView.frame = view.bounds
layoutHeader()
}
// MARK: CollectionView data
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return cells.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
NSLog("ViewController.collectionView(_:cellForItemAt: \(indexPath.map({ "\(Int($0))" }).joined(separator: "-")))")
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
guard let cell = c as? DemoCell else { return c }
let model = cells[indexPath.item]
cell.label.text = "Cell \(model.id)"
cell.backgroundColor = model.color
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
NSLog("ViewController.collectionView(_:layout:sizeForItemAt: \(indexPath.map({ "\(Int($0))" }).joined(separator: "-")))")
return CGSize(width: view.bounds.width, height: cells[indexPath.item].height)
}
// MARK: Actions
func badReload() {
invalidateLayout()
cells = yellowCells()
UIView.performWithoutAnimation(DISABLE_ANIMATIONS) {
collectionView.reloadData()
}
}
func goodReload() {
invalidateLayout()
cells = yellowCells()
UIView.performWithoutAnimation(DISABLE_ANIMATIONS) {
collectionView.reloadSections(IndexSet(0..<collectionView.numberOfSections))
}
}
func invalidateLayout() {
UIView.performWithoutAnimation(DISABLE_ANIMATIONS) {
collectionView.performBatchUpdates({
NSLog("ViewController.performBatchUpdates update closure")
}, completion: { (finished) in
NSLog("ViewController.performBatchUpdates complete closure")
})
}
}
func reset() {
cells = ViewController.defaultCells
collectionView.reloadData()
collectionView.layoutIfNeeded()
}
// MARK: Buttons
func handleButton1() {
badReload()
}
func handleButton2() {
goodReload()
}
func handleButton3() {
reset()
}
func yellowCells() -> [CellData] {
return [
CellData(id: "A", color: .yellow, height: 100),
CellData(id: "B", color: .yellow, height: 200),
CellData(id: "C", color: .yellow, height: 300),
CellData(id: "D", color: .yellow, height: 200),
CellData(id: "E", color: .yellow, height: 100),
CellData(id: "F", color: .yellow, height: 50),
CellData(id: "G", color: .yellow, height: 400)
]
}
// MARK: Buttons Header
func setupHeader() {
collectionView.contentInset.bottom = 50
header.backgroundColor = .lightGray
view.addSubview(header)
header.addSubview(button1)
button1.setTitle("Bad", for: .normal)
button1.addTarget(self, action: #selector(handleButton1), for: .touchUpInside)
button1.backgroundColor = .darkGray
header.addSubview(button2)
button2.setTitle("Good", for: .normal)
button2.addTarget(self, action: #selector(handleButton2), for: .touchUpInside)
button2.backgroundColor = .darkGray
header.addSubview(button3)
button3.setTitle("Reset", for: .normal)
button3.addTarget(self, action: #selector(handleButton3), for: .touchUpInside)
button3.backgroundColor = .darkGray
}
func layoutHeader() {
header.frame = view.bounds
header.frame.origin.y = view.bounds.height - 50
header.frame.size.height = 50
let buttonWidth = (header.bounds.width - 5) / 3 - 5
button1.frame = CGRect(x: (5 + buttonWidth) * 0 + 5, y: 5, width: buttonWidth, height: 40)
button2.frame = CGRect(x: (5 + buttonWidth) * 1 + 5, y: 5, width: buttonWidth, height: 40)
button3.frame = CGRect(x: (5 + buttonWidth) * 2 + 5, y: 5, width: buttonWidth, height: 40)
}
}
extension UIView {
open class func performWithoutAnimation(_ isWithoutAnimation: Bool, _ actionsWithoutAnimation: () -> Swift.Void) {
if isWithoutAnimation {
self.performWithoutAnimation(actionsWithoutAnimation)
} else {
actionsWithoutAnimation()
}
}
}
Comments
Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!
Filer Response
We are not calling
reloadData()inside of a batch updates closure.Please see the example and detailed description. We are calling
performBatchUpdates(with any operation in the closure, for demo it is empty) followed by areloadData(). ThereloadData()call is not in the updates closure.Apple Developer Relations [June 15 2017, 9:01 AM]
Engineering has provided the following information regarding this issue:
reloadSections() inside of a batch updates is supported; reloadData() is not. peformBatchUpdates() closure should have incremental update calls (e.g. insert, delete, move, reloadSection) which CV will coalesce and turn into simultaneous updates+animations.