2

I am using Compositional Layout plus Diffable Data Source to display images in a UICollectionView from Photo Album using PhotoKit's Cached Image Manager in a single column.

I would like the image cell to be the entire width of the collection view and the height of the cell to be scaled to the height of the image using Aspect Fit content mode.

When I create the layout I use estimated height for both item and group layout objects. But the initial height of each cell stays at the estimated height. As soon as I start scrolling, some of the cells do actually resize the height correctly, but not always.

Here is the sample code (replace the default ViewController logic in a sample iOS project with the following code):

import UIKit
import Photos
import PhotosUI

class ViewController: UIViewController, UICollectionViewDelegate {
    
    enum Section: CaseIterable {
        case main
    }
    
    var fetchResult: PHFetchResult<PHAsset>!
    var dataSource: UICollectionViewDiffableDataSource<Section, PHAsset>!
    var collectionView: UICollectionView!
    var emptyAlbumMessageView : UIView! = nil
    let imageManager = PHCachingImageManager()
    
    var selectedAssets: [PHAsset] {
        
        var pAssets = [PHAsset]()
        fetchResult.enumerateObjects { (asset, index, stop) in
            pAssets.append(asset)
        }
        
        return pAssets
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        
        createEmptyAlbumMessageView()
        
        configurePhotoData()
        configureHierarchy()
        configureDataSource()
        
        displayPhotos(fetchResult!, title: "All Photos")
    }
    
    func createEmptyAlbumMessageView() {
        
        emptyAlbumMessageView = UIView()
        emptyAlbumMessageView.backgroundColor = .black
        view.addSubview(emptyAlbumMessageView)
        emptyAlbumMessageView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            emptyAlbumMessageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            emptyAlbumMessageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            emptyAlbumMessageView.topAnchor.constraint(equalTo: view.topAnchor),
            emptyAlbumMessageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
        // Title Label
        let titleLabel : UILabel = UILabel()
        titleLabel.text = "Empty Album"
        titleLabel.textAlignment = .center
        titleLabel.font = UIFont.boldSystemFont(ofSize: 21.0)
        emptyAlbumMessageView.addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            titleLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 100),
            titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
            titleLabel.centerXAnchor.constraint(equalTo: emptyAlbumMessageView.centerXAnchor, constant: -30),
            titleLabel.centerYAnchor.constraint(equalTo: emptyAlbumMessageView.centerYAnchor)])
        
        // Message Label
        let messageLabel : UILabel = UILabel(frame: CGRect(x: 290, y: 394, width: 294, height: 80))
        messageLabel.text = "This album is empty. Add some photos to it in the Photos app and they will appear here automatically."
        messageLabel.font = UIFont.systemFont(ofSize: 17.0)
        messageLabel.numberOfLines = 3
        messageLabel.textAlignment = .center
        messageLabel.lineBreakMode = .byWordWrapping
        
        emptyAlbumMessageView.addSubview(messageLabel)
        messageLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            messageLabel.widthAnchor.constraint(equalToConstant: 294),
            messageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 80),
            messageLabel.centerXAnchor.constraint(equalTo: titleLabel.centerXAnchor),
            messageLabel.firstBaselineAnchor.constraint(equalTo: titleLabel.lastBaselineAnchor, constant: 10)
        ])
        
        self.view.bringSubviewToFront(emptyAlbumMessageView)
        self.emptyAlbumMessageView.isHidden = true
    }
    
    func configurePhotoData() {
        let allPhotosOptions = PHFetchOptions()
        allPhotosOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
        allPhotosOptions.predicate = NSPredicate(format: "mediaType = %d", PHAssetMediaType.image.rawValue)
        
        fetchResult = PHAsset.fetchAssets(with: allPhotosOptions)
    }
    
    func configureHierarchy() {
        
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.delegate = self
        
        view.addSubview(collectionView)
        
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        if #available(iOS 11.0, *) {
            let safeArea = self.view.safeAreaLayoutGuide
            collectionView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 0).isActive = true
        } else {
            let topGuide = self.topLayoutGuide
            collectionView.topAnchor.constraint(equalTo: topGuide.bottomAnchor, constant: 0).isActive = true
        }
        
        collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        
        self.collectionView = collectionView
        
        self.collectionView.scrollsToTop = false
    }
    
    func configureDataSource() {
        
        let cellRegistration = UICollectionView.CellRegistration
        <PhotoThumbnailCollectionViewCell, PHAsset> { [weak self] cell, indexPath, asset in
            
            guard let self = self else { return }
            
            let scale = UIScreen.main.scale
            
            cell.contentMode = .scaleAspectFit
            
            let imageViewFrameWidth = self.collectionView.frame.width
            let imageViewFrameHeight = (Double(asset.pixelHeight)/scale) / (Double(asset.pixelWidth)/scale) * imageViewFrameWidth
            
            let thumbnailSize = CGSize(width: imageViewFrameWidth * scale, height: imageViewFrameHeight * scale)
            
            cell.representedAssetIdentifier = asset.localIdentifier
            
            self.imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: cell.contentMode == .scaleAspectFit ? .aspectFit : .aspectFill, options: nil, resultHandler: { image, _ in
                
                if cell.representedAssetIdentifier == asset.localIdentifier {
                    cell.image = image
                }
            })
            
            cell.layoutIfNeeded()
        }
        
        dataSource = UICollectionViewDiffableDataSource<Section, PHAsset>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, asset: PHAsset) -> UICollectionViewCell? in
            
            let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: asset)
            
            return cell
        }
    }
    
    func createLayout() -> UICollectionViewLayout {
        
        let layout = UICollectionViewCompositionalLayout { 
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection in
            
            let estimateHeight : CGFloat = 200
                        
            let itemWidthDimension = NSCollectionLayoutDimension.fractionalWidth(1.0)
            let itemHeightDimension = NSCollectionLayoutDimension.estimated(estimateHeight)

            let itemSize = NSCollectionLayoutSize(widthDimension: itemWidthDimension,
                                                  heightDimension: itemHeightDimension)
            
            let itemLayout = NSCollectionLayoutItem(layoutSize: itemSize)
                        
            let groupWidthDimension = NSCollectionLayoutDimension.fractionalWidth(1.0)
            let groupHeightDimension = NSCollectionLayoutDimension.estimated(estimateHeight)

            let groupSize = NSCollectionLayoutSize(widthDimension: groupWidthDimension,
                                                   heightDimension: groupHeightDimension )
            
            let groupLayout = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [itemLayout])

            let sectionLayout = NSCollectionLayoutSection(group: groupLayout)
            
            return sectionLayout
        }
        return layout
    }
    
    public func displayPhotos(_ fetchResult: PHFetchResult<PHAsset>, title: String?) {
        
        self.fetchResult = fetchResult
        self.title = title
        
        updateSnapshot(animate: false)
        scrollToBottom()
    }
    
    func updateSnapshot(animate: Bool = false, reload: Bool = true) {
        
        self.emptyAlbumMessageView.isHidden = !(0 == fetchResult.count)
        
        var snapshot = NSDiffableDataSourceSnapshot<Section, PHAsset>()
        snapshot.appendSections([.main])
        let selectedAssets = selectedAssets
        snapshot.appendItems(selectedAssets)
        
        if true == reload {
            snapshot.reloadItems(selectedAssets)
        } else {
            snapshot.reconfigureItems(selectedAssets)
        }
        
        dataSource.apply(snapshot, animatingDifferences: animate)
    }
    
    public func scrollToBottom() {
        collectionView.layoutIfNeeded()
        DispatchQueue.main.async { [self] in
            self.collectionView!.scrollToItem(at: IndexPath(row: fetchResult.count-1, section: 0), at: .bottom, animated: false)
        }
    }
}

class PhotoThumbnailCollectionViewCell: UICollectionViewCell {
    
    var image: UIImage? {
        didSet {
            setNeedsUpdateConfiguration()
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    override func updateConfiguration(using state: UICellConfigurationState) {
        var config = PhotoThumbnailCellConfiguration().updated(for: state)
        config.image = image
        config.contentMode = self.contentMode
        contentConfiguration = config
    }
    
    var representedAssetIdentifier: String!
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

struct PhotoThumbnailCellConfiguration : UIContentConfiguration {
    var text : String? = nil
    var image: UIImage? = nil
    var contentMode : UIView.ContentMode = .scaleAspectFit
    
    func makeContentView() -> UIView & UIContentView {
        return PhotoThumbnailContentView(self)
    }
    
    func updated(for state: UIConfigurationState) -> PhotoThumbnailCellConfiguration {
        return self
    }
}

class PhotoThumbnailContentView : UIView, UIContentView {
    var configuration: UIContentConfiguration {
        didSet {
            self.configure(configuration: configuration)
        }
    }
    
    let imageView = UIImageView()
    
    override var intrinsicContentSize: CGSize {
        CGSize(width: 0, height: 200)
    }
    
    init(_ configuration: UIContentConfiguration) {
        
        self.configuration = configuration
        super.init(frame:.zero)
        
        self.addSubview(self.imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: self.topAnchor, constant: 0),
            imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0),
            imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0),
            imageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0),
        ])
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configure(configuration: UIContentConfiguration) {
        guard let configuration = configuration as? PhotoThumbnailCellConfiguration else { return }
        imageView.image = configuration.image
        imageView.contentMode = configuration.contentMode
    }
}

Since I am setting the estimated height in both the item and group layout objects, I would expect the cell height to get calculated automatically. For some reason, the height only gets calculated after scrolling, but not for ALL cells.

J. Coder
  • 31
  • 6

1 Answers1

0

You can try put this in your view controller:

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    DispatchQueue.main.async {
        self.collectionView.collectionViewLayout.invalidateLayout()
    }
}
  • It's important to not just post code, but to also include a description of what the code does and why you are suggesting it. This helps others understand the context and purpose of the code, and makes it more useful for others who may be reading the question or answer. – DSDmark Dec 29 '22 at 18:01