0

In https://stackoverflow.com/a/51231881/72437, it show how to achieve a full width, dynamic height in vertical UICollectionView's cell, by using UICollectionViewCompositionalLayout

We would like to achieve the same on a horizontal UICollectionView, with requirements

  1. Fixed height 44
  2. Minimum width 44
  3. The width should be dynamically enlarge, when the content grows

Here's how our solution looks like

class MenuTabsView: UIView, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
   
    lazy var collView: UICollectionView = {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: NSCollectionLayoutDimension.estimated(44),
            heightDimension: NSCollectionLayoutDimension.fractionalHeight(1.0)
        )
        let item = NSCollectionLayoutItem(
            layoutSize: itemSize
        )
        item.contentInsets = NSDirectionalEdgeInsets(
            top: 0,
            leading: 0,
            bottom: 0,
            trailing: 1
        )
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: itemSize,
            subitem: item,
            count: 1
        )
        let section = NSCollectionLayoutSection(group: group)
        
        let configuration = UICollectionViewCompositionalLayoutConfiguration()
        configuration.scrollDirection = .horizontal
        
        let layout = UICollectionViewCompositionalLayout(section: section, configuration: configuration)
        
        let cv = UICollectionView.init(frame: CGRect.zero, collectionViewLayout: layout)
        cv.showsHorizontalScrollIndicator = false
        cv.backgroundColor = .white
        cv.delegate = self
        cv.dataSource = self
        
        return cv
    }()

We expect by using widthDimension: NSCollectionLayoutDimension.estimated(44), that's the key to make the cell width grow dynamically. However, that doesn't work as expected. It looks like

enter image description here

May I know, how can we solve this problem by using UICollectionViewCompositionalLayout ? The complete workable project is located at https://github.com/yccheok/PageViewControllerWithTabs/tree/UICollectionViewCompositionalLayout


p/s

We want to avoid from using UICollectionViewDelegateFlowLayout method collectionView(_:layout:sizeForItemAt:). As, our cell can grow complex, and it will contain other views besides UILabel. Having to calculate the content size manually, will make the solution inflexible and error prone.

Cheok Yan Cheng
  • 49,649
  • 117
  • 410
  • 768

3 Answers3

2

You seem to be asking the same question over and over?

You may find it helpful if you ask your complete question first.

You say "our cell content can grow complex" but you don't provide any information about what "complex" might be.

Here's an example that might get you headed in the right direction.

First, the output... Each "tab" has an Image View, a Label and a Button, or a combination of elements as follows:

// 1st tab is Image + Label + Button
// 2nd tab is Label + Button
// 3rd tab is Image + Label
// 4th tab is Image + Button
// 5th tab is Label Only
// 6th tab is Button Only
// 7th tab is Image Only

With different color tabs:

enter image description here

enter image description here

With the same color tabs, except for the "active" tab:

enter image description here

enter image description here

With background colors on the tab elements to see the frames:

enter image description here

enter image description here

I used this as the struct for the tab info:

struct TabInfo {
    var name: String? = ""
    var color: Int = 0
    var imageName: String? = ""
    var buttonTitle: String? = ""
}

and I used this from your GitHub repo:

class Utils {
    static func intToUIColor(argbValue: Int) -> UIColor {
        
        // &  binary AND operator to zero out other color values
        // >>  bitwise right shift operator
        // Divide by 0xFF because UIColor takes CGFloats between 0.0 and 1.0
        
        let red =   CGFloat((argbValue & 0xFF0000) >> 16) / 0xFF
        let green = CGFloat((argbValue & 0x00FF00) >> 8) / 0xFF
        let blue =  CGFloat(argbValue & 0x0000FF) / 0xFF
        let alpha = CGFloat((argbValue & 0xFF000000) >> 24) / 0xFF
        
        return UIColor(red: red, green: green, blue: blue, alpha: alpha)
    }
}

Sample code for the view controller:

class TabsTestViewController: UIViewController {
    
    // 1st tab is Image + Label + Button
    // 2nd tab is Label + Button
    // 3rd tab is Image + Label
    // 4th tab is Image + Button
    // 5th tab is Label Only
    // 6th tab is Button Only
    // 7th tab is Image Only
    var tabs = [
        TabInfo(name: "All", color: 0xff5481e6, imageName: "swiftBlue64x64", buttonTitle: "One"),
        TabInfo(name: "Calendar", color: 0xff7cb342, imageName: nil, buttonTitle: "Two"),
        TabInfo(name: "Home", color: 0xffe53935, imageName: "swiftBlue64x64", buttonTitle: nil),
        TabInfo(name: nil, color: 0xfffb8c00, imageName: "swiftBlue64x64", buttonTitle: "Work"),
        TabInfo(name: "Label Only", color: 0xffe00000, imageName: nil, buttonTitle: nil),
        TabInfo(name: nil, color: 0xff008000, imageName: nil, buttonTitle: "Button Only"),
        TabInfo(name: nil, color: 0xff000080, imageName: "swiftBlue64x64", buttonTitle: nil),
    ]

    let menuTabsView: MenuTabsView = {
        let v = MenuTabsView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()
    
    let otherView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor(white: 0.8, alpha: 1.0)
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white

        view.addSubview(menuTabsView)
        view.addSubview(otherView)
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // constrain menuTabsView Top / Leading / Trailing
            menuTabsView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            menuTabsView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            menuTabsView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            
            // constrain otherView Leading / Trailing / Bottom
            otherView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            otherView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            otherView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
            // constrain otherView Top to menuTabsView Bottom
            otherView.topAnchor.constraint(equalTo: menuTabsView.bottomAnchor, constant: 0.0),

        ])
        
        // un-comment to set all tab colors to green - 0xff7cb342
        //  except first tab
        //for i in 1..<tabs.count {
        //  tabs[i].color = 0xff7cb342
        //}
        
        menuTabsView.dataArray = tabs
        
        // set background color of "bottom bar" to first tab's background color
        guard let tab = tabs.first else {
            return
        }
        menuTabsView.bottomBar.backgroundColor = Utils.intToUIColor(argbValue: tab.color)
        
    }
    
}

Sample code for the MenuTabsView:

class MenuTabsView: UIView {

    var tabsHeight: CGFloat = 44
    
    var dataArray: [TabInfo] = [] {
        didSet{
            self.collView.reloadData()
        }
    }

    lazy var collView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.minimumLineSpacing = 1
        // needed to prevent last cell being clipped
        layout.minimumInteritemSpacing = 1
        layout.estimatedItemSize = CGSize(width: 100, height: self.tabsHeight)
        let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
        return cv
    }()

    let bottomBar: UIView = {
        let v = UIView()
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        collView.translatesAutoresizingMaskIntoConstraints = false
        bottomBar.translatesAutoresizingMaskIntoConstraints = false

        addSubview(collView)
        addSubview(bottomBar)
        
        NSLayoutConstraint.activate([
            // collection view constrained Top / Leading / Trailing
            collView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
            collView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
            collView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),

            // collection view Height constrained to 44
            collView.heightAnchor.constraint(equalToConstant: tabsHeight),

            // "bottom bar" constrained Leading / Trailing / Bottom
            bottomBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
            bottomBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
            bottomBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0),

            // "bottom bar" Height constrained to 4-pts
            bottomBar.heightAnchor.constraint(equalToConstant: 4.0),
            
            // collection view Bottom constrained to "bottom bar" Top
            collView.bottomAnchor.constraint(equalTo: bottomBar.topAnchor),
        ])
        
        collView.register(MyStackCell.self, forCellWithReuseIdentifier: "cell")
        collView.dataSource = self
        collView.delegate = self
        
        collView.backgroundColor = .clear
        backgroundColor = .white
        
    }

}
extension MenuTabsView: UICollectionViewDelegate, UICollectionViewDataSource {
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return dataArray.count
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyStackCell
        let t = dataArray[indexPath.item]
        cell.configure(with: t.name, imageName: t.imageName, buttonTitle: t.buttonTitle, bkgColor: t.color)
        //cell.configure(with: t.name, or: nil, or: "My Button")
        return cell
    }

}

and finally, the collection view cell:

class MyStackCell: UICollectionViewCell {
    
    // can be set by caller to change default cell height
    public var stackHeight: CGFloat = 36.0
    
    private var stackHeightConstraint: NSLayoutConstraint!
    
    private let label: UILabel = {
        let v = UILabel()
        v.textAlignment = .center
        return v
    }()
    
    private let imageView: UIImageView = {
        let v = UIImageView()
        return v
    }()
    
    private let button: UIButton = {
        let v = UIButton()
        v.setTitleColor(.white, for: .normal)
        v.setTitleColor(.lightGray, for: .highlighted)
        return v
    }()
    
    private let stack: UIStackView = {
        let v = UIStackView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.alignment = .center
        v.spacing = 8
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        contentView.addSubview(stack)

        // stack views in cells get cranky
        // so we set priorities on desired element constraints to 999 (1 less than required)
        // to avoid console warnings
        
        var c = imageView.heightAnchor.constraint(equalToConstant: 32.0)
        c.priority = UILayoutPriority(rawValue: 999)
        c.isActive = true
        
        // image view has 1:1 ratio
        c = imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor)
        c.priority = UILayoutPriority(rawValue: 999)
        c.isActive = true

        // minimum width for label if desired
        c = label.widthAnchor.constraint(greaterThanOrEqualToConstant: 44.0)
        c.priority = UILayoutPriority(rawValue: 999)
        c.isActive = true
        
        // minimum width for button if desired
        c = button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44.0)
        c.priority = UILayoutPriority(rawValue: 999)
        c.isActive = true
        
        // height for stack view
        stackHeightConstraint = stack.heightAnchor.constraint(equalToConstant: stackHeight)
        stackHeightConstraint.priority = UILayoutPriority(rawValue: 999)
        stackHeightConstraint.isActive = true

        NSLayoutConstraint.activate([
            stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4.0),
            stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
            stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
            stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4.0),
        ])
        
        stack.addArrangedSubview(imageView)
        stack.addArrangedSubview(label)
        stack.addArrangedSubview(button)
        
        // during development, so we can see the frames
        // delete or comment-out when satisfied with layout
        //imageView.backgroundColor = .yellow
        //label.backgroundColor = .green
        //button.backgroundColor = .blue

    }

    func customTabHeight(_ h: CGFloat) -> Void {
        stackHeightConstraint.constant = h
    }
    
    func configure(with name: String?, imageName: String?, buttonTitle: String?, bkgColor: Int?) -> Void {
        
        // set and show elements
        //  or hide if nil
        
        if let s = imageName, s.count > 0 {
            if let img = UIImage(named: s) {
                imageView.image = img
                imageView.isHidden = false
            }
        } else {
            imageView.isHidden = true
        }
        if let s = name, s.count > 0 {
            label.text = s
            label.isHidden = false
        } else {
            label.isHidden = true
        }
        if let s = buttonTitle, s.count > 0 {
            button.setTitle(s, for: [])
            button.isHidden = false
        } else {
            button.isHidden = true
        }
        if let c = bkgColor {
            backgroundColor = Utils.intToUIColor(argbValue: c)
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // set the mask in layoutSubviews
        let maskPath = UIBezierPath(roundedRect: bounds,
                                    byRoundingCorners: [.topLeft, .topRight],
                                    cornerRadii: CGSize(width: 12.0, height: 12.0))
        let shape = CAShapeLayer()
        shape.path = maskPath.cgPath
        layer.mask = shape
    }
}

Note that this is Example Code Only!!!

It is not intended to be production-ready --- it's just to help you on your way.

DonMag
  • 44,662
  • 5
  • 32
  • 56
  • Thank you for your pointer! I am very interested in your solution. I definitely want to spend a day to try out throughly by tomorrow, as you some how manage to use `UICollectionViewDelegateFlowLayout` without having to implement `collectionView(_:layout:sizeForItemAt:)`. Right now, I have no idea how it work by glancing at the code. I somehow accept @williamfinn answer, as he points out my mistake on `UICollectionViewCompositionalLayout`. Thanks anyway. – Cheok Yan Cheng Aug 31 '20 at 17:35
1

I have created what you need. Follow the following steps:

  • Create a cell with a label inside.
  • Add trailing and leading constraints to the label (my example has 10 and 10) (important)
  • Set the label to align vertically.
  • Set the width of the label to be >= 44. You can do this by inspecting the constraint and changing from equal to >=.

Once this is done you need to create your layout. I had the following function (I have height 50 just change to 44 in your case):

func layoutConfig() -> UICollectionViewCompositionalLayout {
    return UICollectionViewCompositionalLayout { (sectionNumber, env) -> NSCollectionLayoutSection? in
        let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .absolute(50))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        return section
    }
}

Then in your viewDidLoad() function before you call the delegate or dataSource methods you need to call the following (assuming you keep the same function name):

collectionView.collectionViewLayout = layoutConfig()

You should end up with the following. It scrolls horizontally: enter image description here

Here is the source code to the example:

https://github.com/williamfinn/HorizontalCollectionExample

williamfinn
  • 104
  • 4
  • Oh my god! Thank you so much! I think I notice my problem is calling `NSCollectionLayoutGroup.horizontal( layoutSize:, subitem:, count: )` instead of your `NSCollectionLayoutGroup.horizontal(layoutSize:, subitems: )`. I can sleep more soundly tonight and continue tomorrow. Thanks a lot! – Cheok Yan Cheng Aug 31 '20 at 17:24
0

Implement UICollectionViewDelegateFlowLayout method collectionView(_:layout:sizeForItemAt:) method to return the cell size according to your text.

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let text = "This is your text"
    let size = text.size(withAttributes:[.font: UIFont.systemFont(ofSize:18.0)])
    return CGSize(width: size.width + 10.0, height: 44.0)
    
}
PGDev
  • 20,976
  • 5
  • 29
  • 68
  • Hi, we rather not to use that way. Reason is that, our cell content can grow complex. The above is the simple example. Once the cell content grows complex, there is no easy way to measure the content size. – Cheok Yan Cheng Aug 31 '20 at 11:25
  • @CheokYanCheng Even if the content grow complex, there must be some max limit to the cell width right? – PGDev Aug 31 '20 at 11:28
  • Sorry. That's not the message I want to bring out. In short, we just want to tell the system "wrap this cell width based on its current content". We want to avoid from perform manual calculation to measure content size. We have gone through that and we know it is pain, and error prone to do so. We need a reliable solution and UICollectionViewCompositionalLayout seems like the right direction. – Cheok Yan Cheng Aug 31 '20 at 11:40