Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 671
Another Dude Avatar asked Sep 18 '25 12:09

Another Dude


2 Answers

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
    }()
}
like image 135
Tarun Tyagi Avatar answered Sep 21 '25 01:09

Tarun Tyagi


This is being caused by the PDFViews in your cells, for reasons I'm not sure about. PDFView may be too heavyweight for use in a collection view cell, or it may have some issue when reloading or re-adding a document. I reproduced your issue without managing to fix it, though you may want to bear these points in mind:

  • You're using a deprecated initalizer for the document picker
  • The "inbox" url you're given is not guaranteed to be persistent, indeed you can see files appearing and disappearing in there as you select more documents, so you probably want to look into moving selected files into a more permanent home

Addressing these things made no difference to the strange reloading issue. What I did instead was import QuickLookThumbnailing, add an image view to the cell instead of a PDF view, and add this code:

class PDFCollectionViewCell: UICollectionViewCell {
    static let identifier = "PDFCollectionViewCell"
    
    @IBOutlet var imageView: UIImageView!
    private var request: QLThumbnailGenerator.Request?
        
    func load(_ url: URL) {
        let req = QLThumbnailGenerator.Request.init(fileAt: url, size: bounds.size, scale: UIScreen.main.scale, representationTypes: .all)
        
        QLThumbnailGenerator.shared.generateRepresentations(for: req) {
            [weak self]
            rep, type, error in
            DispatchQueue.main.async {
                self?.imageView.image = rep?.uiImage
            }
        }
        request = req
    }
    
    override func prepareForReuse() {
        if let request = request {
            QLThumbnailGenerator.shared.cancel(request)
            self.request = nil
        }
        imageView.image = nil
    }
}

This asynchronously renders thumbnails for whatever URL you pass it, increasing the quality as it goes.

like image 39
jrturton Avatar answered Sep 21 '25 02:09

jrturton