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.
This is a very very weird bug that happens when you use PDFView
in the UICollectionViewCell
. I confirmed this in following environment -
UICollectionView.reloadData()
calls are not reliably working when PDFView
is added as a subview inside UICollectionViewCell.contentView
.
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.
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()
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.
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.
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
}()
}