Search code examples
iosswiftuicollectionviewpdfkit

UICollectionView reloadData only works one time in two


I have a UIViewController that presents a UIDocumentPicker to pick PDF files, and contains a UICollectionView that displays them (each cell contains a PDFView to do so).

Here is the code:

import MobileCoreServices; import PDFKit; import UIKit

class ViewController: UIViewController {
    var urls: [URL] = []
    @IBOutlet weak var collectionView: UICollectionView!

    @IBAction func pickFile() {
        DispatchQueue.main.async {
            let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypePDF as String], in: .import)
            documentPicker.delegate = self
            documentPicker.modalPresentationStyle = .formSheet
            self.present(documentPicker, animated: true, completion: nil)
        }
    }
    
    override func viewDidLoad() {
        collectionView.register(UINib(nibName: PDFCollectionViewCell.identifier, bundle: .main),
                                forCellWithReuseIdentifier: PDFCollectionViewCell.identifier)
    }
    
    init() { super.init(nibName: "ViewController", bundle: .main) }
    required init?(coder: NSCoder) { fatalError() }
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PDFCollectionViewCell.identifier, for: indexPath) as! PDFCollectionViewCell
        cell.pdfView.document = PDFDocument(url: urls[indexPath.row])
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return urls.count
    }

    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        CGSize(width: 150, height: 150)
    }
}

extension ViewController: UIDocumentPickerDelegate {
    // MARK: PDF Picker Delegate
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        controller.dismiss(animated: true, completion: nil)
        
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        controller.dismiss(animated: true, completion: {
            DispatchQueue.main.async {
                self.urls.append(contentsOf: urls)
                self.collectionView.reloadData()
            }
        })
    }
}

class PDFCollectionViewCell: UICollectionViewCell {
    static let identifier = "PDFCollectionViewCell"
    
    @IBOutlet weak var pdfView: PDFView! { didSet { setPdfViewUI() } }
    
    func setPdfViewUI() {
        pdfView.displayMode = .singlePage
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical
        pdfView.isUserInteractionEnabled = false
    }
}

Now, for some reason, the collectionView.reloadData() actually only works one time in two. It works the first time, then the second time nothing happens, then the third time the collection view is updated again with the three expected elements...

I realized that even if I'm calling reloadData(), the dataSource and delegate methods (numberOfItems/cellForItem) are not getting called when this happens.

Any idea of what is happening?

Thank you for your help!

EDIT: I can ensure that I don't have any other code in viewDidLoad/appear methods, that the pickFile function is actually called fine and that the url is correctly fetched, with the urls array being updated as it should before calling reloadData().

Also, I have tried this with both UITableView and UICollectionView, and I'm having this issue in each situation. It just feels like something is wrong either with the fact that I'm using a PDFView, or with the document picker.


Solution

  • This is a very very weird bug that happens when you use PDFView in the UICollectionViewCell. I confirmed this in following environment -

    1. Xcode 12.5
    2. iPhone SE 2020 (iOS 14.6)

    UICollectionView.reloadData() calls are not reliably working when PDFView is added as a subview inside UICollectionViewCell.contentView.

    What else can we try?

    Surprisingly UICollectionView.insertItems(at:) works where UICollectionView.reloadData() doesn't for this case. There's working code sample provided at the end of this answer for anyone else trying to reproduce/confirm the issue.

    Why this might be happening?

    Honestly no idea. UICollectionView.reloadData() is supposed to guarantee that UI is in sync with your dataSource. Let's look at the stack traces of reloadData() (when it works in this case) & insertItems(at:).

    ReloadData_StackTrace

    enter image description here

    InsertItemsAtIndexPaths_StackTrace

    enter image description here

    Conclusion

    1. reloadData() relies on layoutSubviews() to perform the UI refresh. This is inherited from UIView like - UIView.layoutSubviews() > UIScrollView > UICollectionView. It's a very well known UI event and can easily be intercepted by any subclass of UIView. PDFView: UIView can also do that. Why does it happen inconsistently? Only someone who can disassemble & inspect the PDFKit.framework may know about this. This is clearly a bug in PDFView.layoutSubviews() implementation that interferes with it's superview's layoutSubviews() implementation.

    2. insertItems(at:) adds new instance(s) of cell(s) at specified indexPath(s) and clearly does not rely on layoutSubviews() and hence works reliably in this case.

    Sample Code

    import MobileCoreServices
    import PDFKit
    import UIKit
    
    class ViewController: UIViewController {
        
        // MARK: - Instance Variables
        private lazy var flowLayout: UICollectionViewFlowLayout = {
            let sectionInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
            
            let layout = UICollectionViewFlowLayout()
            layout.scrollDirection = .vertical
            layout.sectionInset = sectionInset
            layout.itemSize = CGSize(width: 150, height: 150)
            layout.minimumInteritemSpacing = 20
            layout.minimumLineSpacing = 20
            
            return layout
        }()
        
        private lazy var pdfsCollectionView: UICollectionView = {
            let cv = UICollectionView(frame: self.view.bounds, collectionViewLayout: flowLayout)
            cv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            cv.backgroundColor = .red
            
            cv.dataSource = self
            cv.delegate = self
            return cv
        }()
        
        private lazy var pickFileButton: UIButton = {
            let button = UIButton(frame: CGRect(x: 300, y: 610, width: 60, height: 40)) // hard-coded for iPhone SE
            button.setTitle("Pick", for: .normal)
            button.setTitleColor(.white, for: .normal)
            button.backgroundColor = .purple
            
            button.addTarget(self, action: #selector(pickFile), for: .touchUpInside)
            return button
        }()
        
        private var urls: [URL] = []
        
        
        // MARK: - View Life Cycle
        override func viewDidLoad() {
            super.viewDidLoad()
            
            self.view.addSubview(pdfsCollectionView)
            pdfsCollectionView.register(
                PDFCollectionViewCell.self,
                forCellWithReuseIdentifier: PDFCollectionViewCell.cellIdentifier
            )
            
            self.view.addSubview(pickFileButton)
        }
        
        
        // MARK: - Helpers
        @objc private func pickFile() {
            let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypePDF as String], in: .import)
            documentPicker.delegate = self
            documentPicker.modalPresentationStyle = .formSheet
            self.present(documentPicker, animated: true, completion: nil)
        }
        
    }
    
    extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PDFCollectionViewCell.cellIdentifier, for: indexPath) as! PDFCollectionViewCell
            cell.pdfView.document = PDFDocument(url: urls[indexPath.row])
            cell.contentView.backgroundColor = .yellow
            return cell
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return urls.count
        }
    }
    
    extension ViewController: UIDocumentPickerDelegate {
        func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            controller.dismiss(animated: true, completion: nil)
        }
        
        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            
            // CAUTION:
            // These urls are in the temporary directory - `.../tmp/<CFBundleIdentifier>-Inbox/<File_Name>.pdf`
            // You should move/copy these files to your app's document directory
            
            controller.dismiss(animated: true, completion: {
                DispatchQueue.main.async {
                    let count = self.urls.count
                    var indexPaths: [IndexPath] = []
                    for i in 0..<urls.count {
                        indexPaths.append(IndexPath(item: count+i, section: 0))
                    }
                    
                    self.urls.append(contentsOf: urls)
                    
                    // Does not work reliably
                    /*
                    self.pdfsCollectionView.reloadData()
                    */
                    
                    // Works reliably
                    self.pdfsCollectionView.insertItems(at: indexPaths)
                }
            })
        }
    }
    
    
    class PDFCollectionViewCell: UICollectionViewCell {
        static let cellIdentifier = "PDFCollectionViewCell"
        
        lazy var pdfView: PDFView = {
            let view = PDFView(frame: self.contentView.bounds)
            view.displayMode = .singlePage
            view.autoScales = true
            view.displayDirection = .vertical
            view.isUserInteractionEnabled = false
            
            self.contentView.addSubview(view)
            view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            view.backgroundColor = .yellow
            
            return view
        }()
    }