Sarthak Mishra
Sarthak Mishra

Reputation: 1114

Build a tags list using UICompositional layout iOS | Remove empty space within groups

I am building a list that contains hashtags using the UICompositionalLayout, I have managed to achieve dynamic-sized cells in my sections using the estimated metrics. Which looks like this

enter image description here

However, I want to remove the empty spaces between the tags and have a continuous stream spanning over two rows. Here is how I am building my layout section

let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(120), heightDimension: .absolute(44))
        let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
        layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
        let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.43), heightDimension: .fractionalWidth(0.35))
        let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
        let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
        layoutSection.orthogonalScrollingBehavior = .continuous
        return layoutSection

Thanks a lot for your help!

Upvotes: 2

Views: 981

Answers (2)

Mahameed
Mahameed

Reputation: 59

I think it is related to the cell.xib constraints (trailing ). check that and reply back to me.

good luck.

Upvotes: -1

DonMag
DonMag

Reputation: 77672

UI components are great -- until they're not.

Something such as a UICollectionView is designed to layout and manage a large number of items (cells).

When the layout doesn't match the desired layout, though, it can become a fight against the built-in design of the component. That's the case here.

As you know, by default, a horizontal scrolling collection view aligns the cells like this:

enter image description here

Trying to get the cells left-aligned with equal spacing from row-to-row is not easy, whether using Flow or Compositional layout.

You might want to take a different approach -- here's one idea...

We'll start with a simple "self-selectable Tag View":

class MyTagView: UIView {
    
    public var selectChanged: ((MyTagView) ->())?
    
    public var text: String = "" {
        didSet {
            theLabel.text = text
        }
    }
    
    public var normalForegroundColor: UIColor = UIColor(red: 78.0 / 255.0, green: 164.0 / 255.0, blue: 145.0 / 255.0, alpha: 1.0)
    public var normalBackgroundColor: UIColor = .white
    public var highlightForegroundColor: UIColor = .lightGray
    public var highlightBackgroundColor: UIColor = UIColor(red: 78.0 / 255.0, green: 164.0 / 255.0, blue: 145.0 / 255.0, alpha: 1.0)
    
    private let theLabel: UILabel = {
        let v = UILabel()
        v.textAlignment = .center
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        backgroundColor = normalBackgroundColor
        theLabel.textColor = normalForegroundColor
        theLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(theLabel)
        let g = self
        NSLayoutConstraint.activate([
            theLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
            theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
            theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
            theLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 40.0),
        ])
        layer.cornerRadius = 8
        layer.borderWidth = 1
        layer.borderColor = normalForegroundColor.cgColor
        
        let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
        addGestureRecognizer(t)
    }
    @objc func gotTap(_ sender: Any?) {
        selected.toggle()
        selectChanged?(self)
    }
    var selected: Bool = false {
        didSet {
            if selected {
                backgroundColor = normalForegroundColor
                theLabel.textColor = normalBackgroundColor
                layer.borderColor = normalBackgroundColor.cgColor
            } else {
                backgroundColor = normalBackgroundColor
                theLabel.textColor = normalForegroundColor
                layer.borderColor = normalForegroundColor.cgColor
            }
        }
    }
}

which gives us this:

enter image description here

It also has a closure so we can inform its parent / controller when the selected state has changed.

Next we'll create a "Tags View" that will use a vertical stack view with horizontal "row" stack views to layout the tags:

class MyTagsView: UIView {
    
    public var delegate: MyTagsViewDelegate?
    
    public var tagViews: [MyTagView] = []
    
    var theTags: [String] = [] {
        didSet {
            // clear existing (in case we're setting the tags multiple times)
            vStack.arrangedSubviews.forEach { v in
                v.removeFromSuperview()
            }
            tagViews = []
            var totalWidth: CGFloat = 0
            // create individual tag views and get the total width
            theTags.forEach { str in
                let t = MyTagView()
                t.text = "#" + str
                let sz = t.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
                totalWidth += sz.width
                tagViews.append(t)
            }
 
            let rowWidth: CGFloat = totalWidth / CGFloat(numRows)
            var iTag: Int = 0
            while iTag < tagViews.count {
                // create a new "row" horizontal stack view
                let v = UIStackView()
                v.spacing = 8
                vStack.addArrangedSubview(v)
                var cw: CGFloat = 0
                // add tag views
                while cw < rowWidth, iTag < tagViews.count {
                    let t = tagViews[iTag]
                    let sz = t.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
                    v.addArrangedSubview(t)
                    cw += sz.width
                    iTag += 1
                }
            }

            // set closure so we can track selections
            tagViews.forEach { tv in
                tv.selectChanged = { [weak self] theTagView in
                    guard let self = self,
                          let idx = self.tagViews.firstIndex(of: theTagView)
                    else { return }
                    if theTagView.selected {
                        self.delegate?.myTagsView(self, didSelectItemAt: idx)
                    } else {
                        self.delegate?.myTagsView(self, didDeSelectItemAt: idx)
                    }
                }
            }
        }
    }
    
    // number of rows of tagViews
    public var numRows: Int = 2
    
    // vertical stack view to hold the rows
    private let vStack: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.spacing = 8
        v.alignment = .leading
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        let scrollView = UIScrollView()
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(vStack)
        addSubview(scrollView)
        let g = self
        let cg = scrollView.contentLayoutGuide
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
            vStack.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
            vStack.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
            vStack.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
            vStack.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
            
            scrollView.heightAnchor.constraint(equalTo: vStack.heightAnchor, constant: 16.0),
        ])
    }
    
}

It will use a protocol to allow our controller to respond to selection / deselection:

// protocol so we can tell the controller about selections
protocol MyTagsViewDelegate {
    func myTagsView(_ myTagsView: MyTagsView, didSelectItemAt index: Int)
    func myTagsView(_ myTagsView: MyTagsView, didDeSelectItemAt index: Int)
}

Our example controller will create 3 instances of MyTagsView, with 2, 3 and 4 "rows":

class MyTagsVC: UIViewController, MyTagsViewDelegate {
    
    let theTags: [String] = [
        "streetphotograhy", "portraits", "wild", "india", "landscape", "portrait",
        "These", "Are", "Tags", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
        "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
        "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L",
    ]

    let stack = UIStackView()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBlue
        
        stack.axis = .vertical
        stack.spacing = 16
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 16.0),
            stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            // let the view determine its own height
        ])
        
        // let's create 3 tag views
        //  with 2, 3, 4 rows
        for i in 2...4 {
            let tv = MyTagsView()
            tv.backgroundColor = .white
            tv.numRows = i
            tv.theTags = self.theTags
            tv.delegate = self
            stack.addArrangedSubview(tv)
        }
        
    }
    func myTagsView(_ myTagsView: MyTagsView, didSelectItemAt index: Int) {
        guard let tvIDX = stack.arrangedSubviews.firstIndex(of: myTagsView) else { return }
        print("Selected: \(index) / \"\(theTags[index])\" in tags view \(tvIDX)")
    }
    func myTagsView(_ myTagsView: MyTagsView, didDeSelectItemAt index: Int) {
        guard let tvIDX = stack.arrangedSubviews.firstIndex(of: myTagsView) else { return }
        print("Deselected: \(index) / \"\(theTags[index])\" in tags view \(tvIDX)")
    }
}

and it looks like this:

enter image description here

enter image description here

This is just an idea of one other approach ... and is just starter code to play with to see if it would be a workable alternative.

Upvotes: 0

Related Questions