metamagikum
metamagikum

Reputation: 1378

UICollectionViewCompositionalLayout masonry like with different row and column spans for swift

I'm working on a native iOS app and need to implement a layout for a presentation widget in Swift. For this I have the following design concept: enter image description here The cells should not be arranged in rows, but rather table-like with different row and column-spans.

This is the code I have so far. It's simple and far from the desired result!

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let insets = collectionView.contentInset
    let collectionViewWidth = collectionView.frame.width - (insets.left + insets.right + 1)
    let collectionViewHeight = collectionView.frame.height - (insets.top + insets.bottom)

    switch indexPath.row {
    case 0:
        return CGSize(width: collectionViewWidth, height: collectionViewWidth)
    case 1:
        return CGSize(width: collectionViewWidth / 2, height: collectionViewWidth / 2)
    default:
        return CGSize(width: collectionViewWidth, height: collectionViewWidth)
    }
}

enter image description here

Is this possible? (I come from the Android corner) .. how can I implement the desired arrangement with swift?

The arrangement may need to be rotated by 90° and vertical flipped (depending on the display orientation). The design implementation can be excluded (Borders, Margins). Later, the individual tiles should be changed one after the other using a timer.

Upvotes: 1

Views: 23

Answers (2)

metamagikum
metamagikum

Reputation: 1378

Did it :)

MasonryViewController (createLayoput)

private func createLayout() -> UICollectionViewCompositionalLayout {
    return UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
        
        let spacing: CGFloat = 8
        
        // Großes Quadrat
        let largeItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalHeight(0.5)
            )
        )
        largeItem.contentInsets = NSDirectionalEdgeInsets(top: spacing, leading: spacing, bottom: spacing, trailing: spacing)
        
        // Kleines Quadrat
        let smallItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalHeight(0.5)
            )
        )
        smallItem.contentInsets = NSDirectionalEdgeInsets(top: spacing, leading: spacing, bottom: spacing, trailing: spacing)
        
        // Gruppe mit zwei kleinen Quadraten
        let smallGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.5),
                heightDimension: .fractionalHeight(1.0)
            ),
            subitems: [smallItem, smallItem]
        )
        
        // Horizontale Gruppe mit großem Quadrat und zwei kleinen
        let mainGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(0.5)
            ),
            subitems: [largeItem, smallGroup]
        )
        
        let section = NSCollectionLayoutSection(group: mainGroup)
        return section
    }
}

...

MasonryCell

import UIKit

class MasonryCell: UICollectionViewCell {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }

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

    private func setupCell() {
        contentView.layer.borderWidth = 3
        contentView.layer.borderColor = UIColor.black.cgColor
        contentView.layer.cornerRadius = 10
        contentView.clipsToBounds = true
    }

    func configure(color: UIColor) {
        contentView.backgroundColor = color
    }
}

Upvotes: 0

Kiryl Famin
Kiryl Famin

Reputation: 337

You should use UI​Collection​View​Compositional​Layout for complex layouts like this: here's a guide.

I was able to come up with this result:

Masonry layout

MasonryLayoutViewController.swift:

import UIKit

class MasonryLayoutViewController: UIViewController {
    
    private let items = Array(1...9)
    private var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        title = "Masonry Layout"
        
        collectionView = UICollectionView(
            frame: .zero,
            collectionViewLayout: createMasonryLayout()
        )
        collectionView.backgroundColor = .systemBackground
        
        collectionView.register(MasonryCell.self, forCellWithReuseIdentifier: MasonryCell.reuseIdentifier)
        collectionView.dataSource = self
        
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])
    }
    
    // MARK: - layout
    
    private func createMasonryLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
            guard let self = self else { return nil }
            
            // number of groups (each group has 3 items)
            let groupCount = Int(ceil(Double(self.items.count) / 3.0))
            
            var groups: [NSCollectionLayoutGroup] = []
            // alternate groups with different layouts
            for i in 0..<groupCount {
                if i % 2 == 0 {
                    groups.append(self.makeBigLeftGroup())
                } else {
                    groups.append(self.makeBigRightGroup())
                }
            }
            
            let interGroupSpacing: CGFloat = 8
            // calculate outer group height
            let outerGroupHeight = CGFloat(groupCount) * 300 + CGFloat(max(0, groupCount - 1)) * interGroupSpacing
            
            let outerGroupSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(outerGroupHeight)
            )
            // create vertical outer group with all groups
            let outerGroup = NSCollectionLayoutGroup.vertical(
                layoutSize: outerGroupSize,
                subitems: groups
            )
            
            let section = NSCollectionLayoutSection(group: outerGroup)
            section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
            section.interGroupSpacing = interGroupSpacing
            
            return section
        }
    }
    
    private func makeBigLeftGroup() -> NSCollectionLayoutGroup {
        // big item occupies full width of group
        let bigItemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0)
        )
        let bigItem = NSCollectionLayoutItem(layoutSize: bigItemSize)
        
        let bigGroupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(2.0/3.0),
            heightDimension: .fractionalHeight(1.0)
        )
        let bigGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: bigGroupSize,
            subitems: [bigItem]
        )
        
        let smallItemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(0.5)
        )
        let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
        
        // group for two small items, width is 1/3 of container
        let smallGroupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0 / 3),
            heightDimension: .fractionalHeight(1.0)
        )
        let smallGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: smallGroupSize,
            subitems: [smallItem, smallItem]
        )
        
        // container group with horizontal layout
        let containerSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(300)
        )
        let containerGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: containerSize,
            subitems: [bigGroup, smallGroup]
        )
        return containerGroup
    }
    
    // group where big item is on right and small items on left
    private func makeBigRightGroup() -> NSCollectionLayoutGroup {
        // small items, each takes half height
        let smallItemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(0.5)
        )
        let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
        
        // group for two small items, width is 1/3 of container
        let smallGroupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1 / 3),
            heightDimension: .fractionalHeight(1)
        )
        let smallGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: smallGroupSize,
            subitems: [smallItem, smallItem]
        )
        
        // big item occupies full width of group
        let bigItemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(1)
        )
        let bigItem = NSCollectionLayoutItem(layoutSize: bigItemSize)
        
        // group for big item, width is 2/3 of container
        let bigGroupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(2/3),
            heightDimension: .fractionalHeight(1)
        )
        let bigGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: bigGroupSize,
            subitems: [bigItem]
        )
        
        // container group with horizontal layout (small items first, then big item)
        let containerSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(300)
        )
        let containerGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: containerSize,
            subitems: [smallGroup, bigGroup]
        )
        return containerGroup
    }
}

// MARK: - UICollectionViewDataSource

extension MasonryLayoutViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: MasonryCell.reuseIdentifier,
            for: indexPath
        ) as! MasonryCell
        cell.configure(with: items[indexPath.item])
        return cell
    }
}

MasonryCell.swift:

import UIKit

class MasonryCell: UICollectionViewCell {
    
    static let reuseIdentifier = "MasonryCell"
    
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.boldSystemFont(ofSize: 22)
        label.textColor = .white
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
            titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ])
        
        contentView.layer.cornerRadius = 8
        contentView.layer.masksToBounds = true
        
        contentView.backgroundColor = randomColor()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configure(with number: Int) {
        titleLabel.text = "Element \(number)"
    }
    
    private func randomColor() -> UIColor {
        let r = CGFloat.random(in: 0...1)
        let g = CGFloat.random(in: 0...1)
        let b = CGFloat.random(in: 0...1)
        return UIColor(red: r, green: g, blue: b, alpha: 1)
    }
}

A short explanation:

  • Items: Each cell is represented by an NSCollectionLayoutItem. In our layout, these items are the individual cells you see.

  • Groups: Items are combined into groups. In our case, each “container group” is a horizontal group with a fixed height (300 points) that holds 3 items arranged in one of two configurations: In a “big left” group, one big item (occupying 2/3 of the width) is placed on the left, and a vertical subgroup (with two small items, each taking half the height) is on the right. In a “big right” group, the layout is reversed: a vertical subgroup of two small items (1/3 width) is on the left, and one big item (2/3 width) is on the right. Each of these container groups is built using NSCollectionLayoutGroup (and can have nested groups for the small items).

  • Sections: All container groups are then arranged in a vertical outer group (another NSCollectionLayoutGroup) that forms the section. This outer group is created with a calculated height based on the number of container groups plus spacing. Finally, the section (an NSCollectionLayoutSection) is returned by the layout provider of the UICollectionViewCompositionalLayout.

Upvotes: 1

Related Questions