Swift - CompositionalLayout - collection view cell height not calculated based on Image height

645 Views Asked by At

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.

2

There are 2 best solutions below

2
Alexander Shangin On

You can try put this in your view controller:

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    DispatchQueue.main.async {
        self.collectionView.collectionViewLayout.invalidateLayout()
    }
}
0
Rob On

First, I would personally advise against directly update a cell asynchronously. With diffable data sources, when the image has been retrieved asynchronously, you should just update the model and re-build the snapshot, and reapply an updated snapshot to your data source. Remember that viewWillAppear may be called multiple times, but viewDidLoad will only be called once.

Second, having defined the top/bottom/leading/trailing constraints, I would then define a width-to-height ratio that matches the image.

Also, in an unrelated observation, you are rebuilding your view hierarchy, data sources, etc., in viewWillAppear. This method should be limited to updating the snapshot, and all the configuration should be limited to viewDidLoad. Sure in a simple present/dismiss scenario, viewWillAppear will be called only once, but in other scenarios (where this view might, itself, later present and then dismiss some other view), this view’s viewWillAppear will be called again. In short, stuff that should be done once should be in viewDidLoad and only stuff that should be done multiple times should go in viewWillAppear.

For example:

import UIKit
import Photos

class ViewController: UIViewController, UICollectionViewDelegate {
    var fetchResult: PHFetchResult<PHAsset>!
    var dataSource: UICollectionViewDiffableDataSource<Section, FetchedAsset>!
    lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
    var emptyAlbumMessageView: UIView!
    let imageManager = PHCachingImageManager()

    var assets: [FetchedAsset] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        createEmptyAlbumMessageView()
        configureHierarchy()
        configureDataSource()
    }

    func fetchAndDisplayPhotos() {
        guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
            PHPhotoLibrary.requestAuthorization(for: .readWrite) { [weak self] status in
                guard let self else { return }
                DispatchQueue.main.async {
                    self.configurePhotoData()
                    self.displayPhotos(self.fetchResult!, title: "All Photos")
                }
            }
            return
        }

        configurePhotoData()
        displayPhotos(fetchResult!, title: "All Photos")
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        fetchAndDisplayPhotos()
    }
}

// MARK: - Private methods

private extension ViewController {
    func buildAssets() {
        assets = []
        assets.reserveCapacity(fetchResult.count)
        fetchResult.enumerateObjects { [self] asset, _, _ in
            assets.append(FetchedAsset(asset: asset))
        }
    }

    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 = .boldSystemFont(ofSize: 21)
        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 = .systemFont(ofSize: 17)
        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)
        ])

        view.bringSubviewToFront(emptyAlbumMessageView)
        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)
        buildAssets()
    }

    func configureHierarchy() {
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.delegate = self

        view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false

        let topConstraint: NSLayoutConstraint

        if #available(iOS 11.0, *) {
            topConstraint = collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0)
        } else {
            topConstraint = collectionView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor, constant: 0)
        }

        NSLayoutConstraint.activate([
            topConstraint,
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        self.collectionView = collectionView

        collectionView.scrollsToTop = false
    }

    func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<PhotoThumbnailCollectionViewCell, FetchedAsset> { [weak self] cell, indexPath, asset in
            guard let self else { return }

            let scale = UIScreen.main.scale

            cell.contentMode = .scaleAspectFit

            let imageViewFrameWidth = collectionView.frame.width
            let imageViewFrameHeight = CGFloat(asset.asset.pixelHeight) / CGFloat(asset.asset.pixelWidth) * imageViewFrameWidth

            let thumbnailSize = CGSize(width: imageViewFrameWidth * scale, height: imageViewFrameHeight * scale)

            cell.representedAssetIdentifier = asset.id

            if let image = asset.image {
                cell.image = image
            } else {
                cell.image = nil
                imageManager.requestImage(for: asset.asset, targetSize: thumbnailSize, contentMode: .aspectFit, options: nil) { [weak self] image, _ in
                    guard let self else { return }
                    assets[indexPath.item].image = image!
                    DispatchQueue.main.async {
                        self.updateSnapshot()
                    }
                }
            }
        }

        dataSource = UICollectionViewDiffableDataSource<Section, FetchedAsset>(collectionView: collectionView) { collectionView, indexPath, asset in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: asset)
        }
    }

    func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection in

            let estimateHeight: CGFloat = 200

            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                  heightDimension: .estimated(estimateHeight))

            let itemLayout = NSCollectionLayoutItem(layoutSize: itemSize)

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                                   heightDimension: .estimated(estimateHeight))

            let groupLayout = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [itemLayout])
            groupLayout.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)

            let sectionLayout = NSCollectionLayoutSection(group: groupLayout)
            sectionLayout.interGroupSpacing = 5

            return sectionLayout
        }
    }

    func updateSnapshot(animate: Bool = true) {
        emptyAlbumMessageView.isHidden = fetchResult.count != 0

        var snapshot = NSDiffableDataSourceSnapshot<Section, FetchedAsset>()
        snapshot.appendSections([.main])
        snapshot.appendItems(assets)

        dataSource.apply(snapshot, animatingDifferences: animate)
    }
}

// MARK: - Internal interface

extension ViewController {
    func displayPhotos(_ fetchResult: PHFetchResult<PHAsset>, title: String?) {
        self.fetchResult = fetchResult
        self.title = title

        updateSnapshot()
        // scrollToBottom()
    }

    // func scrollToBottom() {
    //     guard fetchResult.count > 0 else { return }
    //
    //     DispatchQueue.main.async { [self] in
    //         self.collectionView.scrollToItem(at: IndexPath(row: fetchResult.count - 1, section: 0), at: .bottom, animated: false)
    //     }
    // }
}

// MARK: - PhotoThumbnailCollectionViewCell

class PhotoThumbnailCollectionViewCell: UICollectionViewCell {
    var representedAssetIdentifier: String!

    var image: UIImage? {
        didSet { setNeedsUpdateConfiguration() }
    }

    override func updateConfiguration(using state: UICellConfigurationState) {
        var config = PhotoThumbnailCellConfiguration().updated(for: state)
        config.image = image
        config.contentMode = contentMode
        contentConfiguration = config
    }
}

// MARK: - PhotoThumbnailCellConfiguration

struct PhotoThumbnailCellConfiguration: UIContentConfiguration {
    var text: String?
    var image: UIImage?
    var contentMode: UIView.ContentMode = .scaleAspectFit

    func makeContentView() -> UIView & UIContentView {
        return PhotoThumbnailContentView(self)
    }

    func updated(for state: UIConfigurationState) -> PhotoThumbnailCellConfiguration {
        return self
    }
}

// MARK: - PhotoThumbnailContentView

class PhotoThumbnailContentView: UIView, UIContentView {
    var configuration: UIContentConfiguration {
        didSet { configure(configuration: configuration) }
    }

    let imageView = UIImageView()
    let spinner = UIActivityIndicatorView(style: .large)

    var ratioConstraint: NSLayoutConstraint?

    override var intrinsicContentSize: CGSize {
        CGSize(width: 0, height: 44)
    }

    init(_ configuration: UIContentConfiguration) {
        self.configuration = configuration

        super.init(frame: .zero)

        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.isHidden = true

        spinner.translatesAutoresizingMaskIntoConstraints = false

        addSubview(imageView)
        addSubview(spinner)

        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0),
            imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0),
            imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0),
            imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0),

            spinner.topAnchor.constraint(equalTo: topAnchor, constant: 0),
            spinner.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0),
            spinner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0),
            spinner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0),
        ])

        configure(configuration: configuration)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(configuration: UIContentConfiguration) {
        guard let configuration = configuration as? PhotoThumbnailCellConfiguration else { return }

        if let ratioConstraint {
            imageView.removeConstraint(ratioConstraint)
        }

        if let image = configuration.image {
            spinner.isHidden = true
            imageView.isHidden = false
            imageView.image = image
            imageView.contentMode = configuration.contentMode
            let multiplier = CGFloat(image.size.height) / CGFloat(image.size.width)
            ratioConstraint = imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: multiplier, constant: 0)
            ratioConstraint?.priority = .defaultHigh
            imageView.addConstraint(ratioConstraint!)
            spinner.stopAnimating()
        } else {
            spinner.isHidden = false
            imageView.isHidden = true
            spinner.startAnimating()
        }
    }
}

// MARK: - Section {

extension ViewController {
    enum Section: CaseIterable {
        case main
    }
}

// MARK: - FetchedAsset

extension ViewController {
    struct FetchedAsset: Identifiable, Hashable {
        let asset: PHAsset
        var id: String { asset.localIdentifier }
        var image: UIImage?
    }
}