0

I'm tasked with modifying one of our horizontal collectionViews so that it supports 2 possible cell widths based on the value of one of the properties of the item at that indexPath. This is rather simple to implement using UICollectionViewFlowLayout's handy delegate method sizeForItemAt(indexPath: ).

However, since our current collectionView uses UICompositionalLayout, I've been researching if it's possible to achieve the same behaviour using the compositional approach.

What I have tried so far:

  • Having 2 different cell classes and setting the width in the cells themselves, then setting the item and group sizes to an estimated with at exactly the midpoint of both widths (large cell width is 145, small cell width is 95, estimated width is 120). Result: this only resulted in every cell having a 120 width

  • Creating 2 different compositionalLayout items (small item, large item). Putting each in its own group and setting the fractional width to 1 Result: this also did not allow cell width to adjust on the fly.

Here is a screenShot of the desired result that I achieved using FlowLaout

Desired result

I know UICompositionalLayout is amazing for handling complex layouts like Apples PhotoAlbum. But is one of its limitations not having an equivalent to sizeForItemAt(indexPath:)?

Here is how our diffableDataSource is set up (not currently supporting multiple cell widths):

private func configureDataSource() {
    dataSource = UICollectionViewDiffableDataSource<Int, UploadParameter>(
      collectionView: collectionView) { [unowned self] collectionView, indexPath, media in
        
        guard !media.isEmptyAudio else {
          let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: String(describing: AudioCell.self),
            for: indexPath
          ) as! AudioCell
          cell.duration = media.duration
          return cell
        }
        
        let cell = collectionView.dequeueReusableCell(
          withReuseIdentifier: String(describing: MediaCell.self),
          for: indexPath
        ) as! MediaCell
        
        cell.videoIconImageView.isHidden = !media.isVideo
        
        return configuredCellInTimeline(
          collectionView,
          for: cell,
          at: indexPath,
          with: media
        )
      }
    
    var snapshot = NSDiffableDataSourceSnapshot<Int, UploadParameter>()
    snapshot.appendSections([0])
    dataSource.apply(snapshot, animatingDifferences: false)
  }

Furthermore, here is the configureCellInTimeLine method that we return when configuring the diffableDataSource (not currently supporting multiple cell widths):

private func configuredCellInTimeline(
    _ collectionView: UICollectionView,
    for cell: MediaCell,
    at indexPath: IndexPath,
    with media: UploadParameter) -> MediaCell {
      let idx = (mediaItems.count - 1) - indexPath.item
      let idxPath = IndexPath(row: idx, section: 0)
      
      cell.duration = media.duration
      cell.deletion = deletion
      cell.selection = selection
      cell.color = media.color
      cell.isVideo = media.isVideo
      cell.isFetchedAsset = false
      
      // Remove any video cells from the selected index paths
      if cell.isVideo,
         selectedMediaIndexPaths.contains(idxPath) {
        selectedMediaIndexPaths.remove(idxPath)
      }
      
      switch media.entity {
      case let .image(image):
        cell.image = image
      case let .video(_, image):
        cell.image = image
      case let .asset(asset):
        cell.isFetchedAsset = true
        MediaService.shared.fetchImage(
          forAsset: asset,
          resizedTo: ImageSize.thumbnail) { image in
            guard cell.isFetchedAsset else { return }
            cell.image = image
          }
      case .empty:
        if media.isEmptyAudio {
          cell.duration = media.duration
          return cell
        } else {
          cell.image = nil
        }
      }
      
      if self.selectedMediaIndexPaths.isEmpty {
        cell.selectionType = .normal
      } else if selectedMediaIndexPaths.contains(idxPath) {
        cell.selectionType = .selected
      } else {
        cell.selectionType = .unselected
      }
      
      cell.onImageSelectionChange = { [weak self] didSelect in
        DispatchQueue.main.async {
          self?.timelineCell(at: indexPath, onSelectionChange: didSelect)
        }
      }
      
      cell.onDurationTap = { [weak self] in
        DispatchQueue.main.async {
          self?.tappedTimelineCellDuration(at: indexPath)
        }
      }
      
      cell.onTap = { [weak self] in
        DispatchQueue.main.async {
          self?.tappedTimelineCell(at: indexPath)
        }
      }
      
      cell.onLongPress = { [weak self] in
        DispatchQueue.main.async {
          self?.onLongPress.send()
        }
      }
      
      cell.onDelete = { [weak self] in
        DispatchQueue.main.async {
          self?.deletedTimelineCell(at: indexPath)
        }
      }
      
      cell.layoutIfNeeded()
      
      return cell
    }

finally, here is how I achieved the desired result of supporting multiple cell widths using a flowLayout and sizeForItemAt(indexPath:):

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

    let largeCellWidth = 145
    let standardCellWidth = 95
    let cellHeight = 170

    let item = mediaItems.reversed()[indexPath.item]
    return CGSize(width: item.isLongDuration ? largeCellWidth : standardCellWidth, height: cellHeight)
  }

hope this wasn't too long Looking forward to hearing what you guys and gals

Shyne
  • 1
  • 2
  • 1
    Please provide enough code so others can better understand or reproduce the problem. – Community Jun 24 '22 at 22:52
  • Depending on your needs you might as well adopt either one compositional layout or a different one based on that value. – valeCocoa Jun 25 '22 at 14:12
  • @valeCocoa would you have a code example of swapping layouts like you mentioned? – Shyne Jun 27 '22 at 17:04
  • First you need to at least write in your question some code regarding it, I then can try to plug in and help out. For example I don't know if your app uses a diffable datasource, in that case when you apply a new snapshot to it, then that could be the time to check if the layout of the collection view should change, hence change it in that method too. Another trick could be to wrap the whole collection view controller inside a SwiftUI view, but that would also depend on your app. – valeCocoa Jun 27 '22 at 18:28
  • @valeCocoa I went ahead and added some relevant code We're currently only using SwiftUI for very simple views like alerts. – Shyne Jun 27 '22 at 21:41
  • Hi, the layout seems pretty simple and maybe it will just work with self-sizing cells since the size variation affects only the width of the cell. Have you already implemented the compositional layout? – valeCocoa Jun 28 '22 at 22:34
  • @valeCocoa That was my initial thought as well! especially since there are already examples of compositional layouts with dynamic cell height. However, I've yet to manage to implement multiple cell widths successfully using compositional layout, nor have I found any examples online! – Shyne Jul 04 '22 at 14:00
  • @Shyne I don’t think you’ll have to implement different cells and layouts in your case for every expected width. After all in the compositional layout usually you won’t use exact sizes but rather proportions in the container of an element (group, cell, etc.). – valeCocoa Jul 05 '22 at 15:27
  • @valeCocoa I still haven't found a single example of cell width being calculated on the fly in a compositional layout. At this point it seems like flowLayout is unavoidable, I just find it odd that there wouldn't be a compositional way to define the size of a cell at a certain indexPath, It seems so basic. – Shyne Jul 05 '22 at 17:36
  • @Shyne please have a look at my answer it might be a starting point for your compositional layout – valeCocoa Jul 06 '22 at 23:26

1 Answers1

0

Here is a little playground example. Play with it and maybe you could get the desired result. Keep in mind that the cell with a variable width uses just a constraint to have its width changed, while in your case you’ll have cells that contain a view which should self-size itself based on its content (as for example a label does). Another caveat: it appears edge insets are ignored when adopting an .estimated size value for an item in the layout.

import UIKit
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

let bgColors: Set<UIColor> = [.red, .green, .magenta]
let model: [UIColor] = (0..<10).map { _ in 
    bgColors.randomElement()! 
}

class MyCell: UICollectionViewCell {
    var widthConstraint: NSLayoutConstraint!

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.widthConstraint = contentView.widthAnchor.constraint(equalToConstant: 90)
        self.widthConstraint.isActive = true
        self.widthConstraint.priority = .init(1000)
        contentView.layer.cornerRadius = 8
    }

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

    override func prepareForReuse() {
        super.prepareForReuse()
    
        widthConstraint.constant = 90
        contentView.backgroundColor = .clear
        setNeedsLayout()
    }

}

class VC: UICollectionViewController, UICollectionViewDelegateFlowLayout {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "listCell")
        collectionView.register(MyCell.self, forCellWithReuseIdentifier: "horizontalCell")
    }

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        2
    }
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return section == 0 ? 10 : model.count
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if indexPath.section == 0 {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "listCell", for: indexPath)
            cell.contentView.backgroundColor = .blue
            return cell
        }
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "horizontalCell", for: indexPath) as! MyCell

    
        let bgColor = model[indexPath.row]
        cell.contentView.backgroundColor = bgColor
        let width: CGFloat
        switch bgColor {
        case .red: width = 60
        case .green: width = 90
        case .magenta: width = 120
        default: width = 90
        }
    
        cell.widthConstraint.constant = width

        return cell
    }
}

let layout = UICollectionViewCompositionalLayout { sectionIDX, environment in 
    if sectionIDX == 0 {
        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(90)), subitems: [item])
        group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)

        return section
    } else if sectionIDX == 1 {
        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .estimated(90), heightDimension: .fractionalHeight(1)))
        item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)
        let group = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .estimated(90), heightDimension: .absolute(90)), subitems: [item])
        group.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)
    
        return section
    }

    return nil
}

PlaygroundPage.current.liveView = VC(collectionViewLayout: layout)
valeCocoa
  • 344
  • 1
  • 8