Search code examples
swiftlayoutuikit

How to setup constraints UICollectionViewCell label to UICollectionView frame?


What am I trying to achieve:

I have a collectionView which represents events in a timeline. These cells (events) have description label that is constrained to the left edge.

When scrolling to events to the left on the timeline, some events are too wide and are not showing the description label immediately (i must scroll to the very beginning of the cell on timeline to see it)

The idea was to setup constraints between cells label to collectionView frame so that you could see event description immediately, but these are different view hierarchies.

What would be the best way to approach this?

What i have tried:

Setting up the constraint crashes the app: Terminating app due to uncaught exception 'NSGenericException', reason: 'Unable to activate constraint with anchors <NSLayoutXAxisAnchor:0x600002cbe700 "UILabel:0x11ef0ea20.leading"> and <NSLayoutXAxisAnchor:0x600002cbf240 "UILayoutGuide:0x6000000289a0'UIScrollView-frameLayoutGuide'.leading"> because they have no common ancestor. Does the constraint or its anchors reference items in different view hierarchies? That's illegal.'

Additional Info:

UICollectionViewCompositionalLayout:

UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
    let item = data.items[sectionIndex]
    let contentWidth = data.contentWidth

   return context.coordinator.layout(item, contentWidth)
}

NSCollectionLayoutSection:

var layout: (Item, CGFloat) -> NSCollectionLayoutSection = { item, contentWidth in
  let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(contentWidth), heightDimension: .absolute(100))

  let itemSizes = item.items.map { NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(CGFloat($0.duration)), heightDimension: .absolute(100)))}

  let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: itemSizes)
  group.interItemSpacing = .fixed(0)
  group.contentInsets = .init(top: 0, leading: item.leftGap, bottom: 0, trailing: item.rightGap)

  let section = NSCollectionLayoutSection(group: group)
  section.interGroupSpacing = 0

  let header = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: .init(widthDimension: .absolute(255), heightDimension: .absolute(100)),
    elementKind: UICollectionView.elementKindSectionHeader,
    alignment: .leading
  )}
}

  header.pinToVisibleBounds = true
  section.boundarySupplementaryItems = [header]

  return section 
}

CollectionViewCell:

class MyCollectionViewCell: UICollectionViewCell {

static let identifier: String = "MyCollectionViewCell"

var item: DataState.Item = .dummy {
    didSet {
        descriptionLabel.text = item.title
        descriptionTimeLabel.text = item.timeDescription
    }
}

var containerView: UIView = {
    let containerView = UIView()
    containerView.translatesAutoresizingMaskIntoConstraints = false
    containerView.layer.cornerRadius = 15
    containerView.clipsToBounds = true
    return containerView
}()

var bg: UIView = {
    let bg = UIView()
    bg.translatesAutoresizingMaskIntoConstraints = false
    bg.backgroundColor = .black
    return bg
}()

var descriptionLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = .systemFont(ofSize: 31, weight: .regular)
    return label
}()

var descriptionTimeLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = .systemFont(ofSize: 23, weight: .regular)
    return label
}()

override init(frame: CGRect) {
    super.init(frame: frame)
    
    contentView.addSubview(containerView)
    containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5).isActive = true
    containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5).isActive = true
    containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -5).isActive = true
    containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 5).isActive = true

    containerView.addSubview(bg)
    bg.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
    bg.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
    bg.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
    bg.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
            
    containerView.addSubview(descriptionLabel)
    descriptionLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10).isActive = true
    descriptionLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
    
    containerView.addSubview(descriptionTimeLabel)
    descriptionTimeLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10).isActive = true
    descriptionTimeLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10).isActive = true
}
}

Just to remind whole point is to make labels of cell visible immediately as the wide cell being revealed and be continously updated so when the cell is fully visible label's leadingAnchor and containerView leadingAnchor equals 10


Solution

  • So turns out that if you add constraints to cell as properties and then in: scrollViewDidScroll(UIScrollView) find visibleCells and filter those which are visible just halfway or so. you can then change constants on constraints and update the view.

    works great. looks weird.

    example:

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard let collectionView = scrollView as? UICollectionView else { return } // move elsewhere just to access reference to collectionView and dont cast it everytime offset changes (which could be many times a second)
    
        let scrollOffset = scrollView.contentOffset.x
        if let visible = collectionView?.visibleCells as? [MyCell] {
            let visibleDisappearing = visible.filter({ $0.frame.minX <= scrollOffset })
            let visibleFull = visible.filter({ $0.frame.minX > scrollOffset })
    
            for disappearingCell in visibleDisappearing {
                let distance = scrollView.contentOffset.x - disappearingCell.frame.minX + 10 // 10 would be the original distance from edge to label
                disappearingCell.descriptionLeadingConstraint.constant = distance
                disappearingCell.descriptionTimeLeadingConstraint.constant = distance
                disappearingCell.layoutIfNeeded()
            }
    
            for fullCell in visibleFull {
                fullCell.descriptionLeadingConstraint.constant = 10
                fullCell.descriptionTimeLeadingConstraint.constant = 10
                fullCell.layoutIfNeeded()
            }
    }
    

    }