Wrap items in a horizontal UIStackView on multiple lines
Asked Answered
D

1

22

I am working on an iOS app which display a UITableView of items. Each row will show a list of tags with different widths(given by the tag's text length). I can place all the tags in a horizontal UIStackView but I want them to wrap on multiple lines instead of o a single scrollable one. Basically I'm interested in a functionality similar to FlexBox's flex-wrap property.

I've attached an image for reference.

enter image description here

Any ideas how to achieve this?

Dodecasyllable answered 7/3, 2020 at 20:56 Comment(4)
Why not use a UICollectionView? This really is why it's there.Meteorograph
I’ve thought about that but I’m seeing it as a last resort type of solution. I want cells to be always visible so the reuse mechanism of the CollectionView will be implemented but not used. And there’s also the problem of always adjusting the CollectionView’s height constraint so that there is no vertical scrolling.Dodecasyllable
If there is no cleaner solution I’ll give this one a try.Dodecasyllable
if using a UICollectionView for this use-case, simple things like centering the cells become a nightmare using 'inset' flowlayout delegate? @dfdExpense
K
24

There are a number of different ways to approach this.

One approach - not using stack views:

  • add your labels to a "container" view
  • start with x = 0 and y = 0
  • loop through the labels, calculating a new x value (label width + desired spacing between labels)
  • if the new x would be past the edge of the container, reset x = 0 and add desired height to y to "move to the next row"
  • after labels have been laid out, set the height of the container view

Here is a simple example:

class TagLabelsViewController: UIViewController {
    
    let containerView: UIView = {
        let v = UIView()
        return v
    }()
    
    let tagNames: [String] = [
        "First Tag",
        "Second",
        "Third Tag",
        "Fourth",
        "The Fifth Tag",
        "Sixth",
        "Seventh",
        "Tag Eight",
        "Here are some Letter Tags",
        "A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
        "Nine",
        "Ten",
        "Eleven",
        "Tag Twelve",
        "Tag 13",
        "Fourteen",
        "Fifteen",
        "Sixteen",
        "Seventeen",
        "Eightteen",
        "Nineteen",
        "Last Tag",
    ]
    
    var tagLabels = [UILabel]()
    
    let tagHeight:CGFloat = 30
    let tagPadding: CGFloat = 16
    let tagSpacingX: CGFloat = 8
    let tagSpacingY: CGFloat = 8
    
    // container view height will be modified when laying out subviews
    var containerHeightConstraint: NSLayoutConstraint = NSLayoutConstraint()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // add the container view
        view.addSubview(containerView)
        
        // give it a background color so we can see it
        containerView.backgroundColor = .yellow
        
        // use autolayout
        containerView.translatesAutoresizingMaskIntoConstraints = false
        
        // initialize height constraint - actual height will be set later
        containerHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: 10.0)
        
        // constrain container safe-area top / leading / trailing to view with 20-pts padding
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            containerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            containerHeightConstraint,
        ])
        
        // add the buttons to the scroll view
        addTagLabels()
        
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        // call this here, after views have been laid-out
        // this will also be called when the size changes, such as device rotation,
        // so the buttons will "re-layout"
        displayTagLabels()
        
    }
    
    func addTagLabels() -> Void {
        
        for j in 0..<self.tagNames.count {
            
            // create a new label
            let newLabel = UILabel()
            
            // set its properties (title, colors, corners, etc)
            newLabel.text = tagNames[j]
            newLabel.textAlignment = .center
            newLabel.backgroundColor = UIColor.cyan
            newLabel.layer.masksToBounds = true
            newLabel.layer.cornerRadius = 8
            newLabel.layer.borderColor = UIColor.red.cgColor
            newLabel.layer.borderWidth = 1

            // set its frame width and height
            newLabel.frame.size.width = newLabel.intrinsicContentSize.width + tagPadding
            newLabel.frame.size.height = tagHeight
            
            // add it to the scroll view
            containerView.addSubview(newLabel)
            
            // append it to tagLabels array
            tagLabels.append(newLabel)
            
        }
        
    }
    
    func displayTagLabels() {
        
        let containerWidth = containerView.frame.size.width
        
        var currentOriginX: CGFloat = 0
        var currentOriginY: CGFloat = 0
        
        // for each label in the array
        tagLabels.forEach { label in
            
            // if current X + label width will be greater than container view width
            //  "move to next row"
            if currentOriginX + label.frame.width > containerWidth {
                currentOriginX = 0
                currentOriginY += tagHeight + tagSpacingY
            }
            
            // set the btn frame origin
            label.frame.origin.x = currentOriginX
            label.frame.origin.y = currentOriginY
            
            // increment current X by btn width + spacing
            currentOriginX += label.frame.width + tagSpacingX
            
        }
        
        // update container view height
        containerHeightConstraint.constant = currentOriginY + tagHeight
        
    }
    
}

The results:

enter image description here

enter image description here

It's pretty straight-forward, and with the comments in the code you should be able to adapt it to your needs.

If you want a "pre-built" solution, perhaps with more features, searching for

swift left aligned tags view

comes up with lots of matches. This one (I have nothing to do with it) looks interesting: https://github.com/ElaWorkshop/TagListView


Edit

Using this concept in a table view cell is not much different than using it as a view in a view controller.

First step, let's create a custom UIView subclass to handle all of the layout logic:

class TagLabelsView: UIView {
    
    var tagNames: [String] = [] {
        didSet {
            addTagLabels()
        }
    }
    
    let tagHeight:CGFloat = 30
    let tagPadding: CGFloat = 16
    let tagSpacingX: CGFloat = 8
    let tagSpacingY: CGFloat = 8

    var intrinsicHeight: CGFloat = 0
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit() -> Void {
    }

    func addTagLabels() -> Void {
        
        // if we already have tag labels (or buttons, etc)
        //  remove any excess (e.g. we had 10 tags, new set is only 7)
        while self.subviews.count > tagNames.count {
            self.subviews[0].removeFromSuperview()
        }
        
        // if we don't have enough labels, create and add as needed
        while self.subviews.count < tagNames.count {

            // create a new label
            let newLabel = UILabel()
            
            // set its properties (title, colors, corners, etc)
            newLabel.textAlignment = .center
            newLabel.backgroundColor = UIColor.cyan
            newLabel.layer.masksToBounds = true
            newLabel.layer.cornerRadius = 8
            newLabel.layer.borderColor = UIColor.red.cgColor
            newLabel.layer.borderWidth = 1

            addSubview(newLabel)
            
        }

        // now loop through labels and set text and size
        for (str, v) in zip(tagNames, self.subviews) {
            guard let label = v as? UILabel else {
                fatalError("non-UILabel subview found!")
            }
            label.text = str
            label.frame.size.width = label.intrinsicContentSize.width + tagPadding
            label.frame.size.height = tagHeight
        }

    }
    
    func displayTagLabels() {
        
        var currentOriginX: CGFloat = 0
        var currentOriginY: CGFloat = 0

        // for each label in the array
        self.subviews.forEach { v in
            
            guard let label = v as? UILabel else {
                fatalError("non-UILabel subview found!")
            }

            // if current X + label width will be greater than container view width
            //  "move to next row"
            if currentOriginX + label.frame.width > bounds.width {
                currentOriginX = 0
                currentOriginY += tagHeight + tagSpacingY
            }
            
            // set the btn frame origin
            label.frame.origin.x = currentOriginX
            label.frame.origin.y = currentOriginY
            
            // increment current X by btn width + spacing
            currentOriginX += label.frame.width + tagSpacingX
            
        }
        
        // update intrinsic height
        intrinsicHeight = currentOriginY + tagHeight
        invalidateIntrinsicContentSize()
        
    }

    // allow this view to set its own intrinsic height
    override var intrinsicContentSize: CGSize {
        var sz = super.intrinsicContentSize
        sz.height = intrinsicHeight
        return sz
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        displayTagLabels()
    }
    
}

We can use that inside a cell -- or, as a "regular old subview" -- like this:

let tagsView = TagLabelsView()
let tags: [String] = ["One", "Two", "Three", "etc..."]
tagsView.tagNames = tags

Here's a complete example using our custom TagLabelsView:

class PlainTagLabelsViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let tagsView = TagLabelsView()
        
        // add the tags view
        view.addSubview(tagsView)
        
        // use autolayout
        tagsView.translatesAutoresizingMaskIntoConstraints = false
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            // constrain to safe-area top / leading / trailing to view with 20-pts padding
            tagsView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            tagsView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            tagsView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
        ])

        // some sample "tags" from Stack Overflow
        let tags: [String] = [
            "asp.net-core",
            "asp.net-mvc",
            "asp.net",
            "azure",
            "bash",
            "c",
            "c#",
            "c++",
            "class",
            "codeigniter",
            "cordova",
            "css",
            "csv",
            "dart",
            "database",
            "dataframe",
        ]

        tagsView.tagNames = tags
        
        // give the tags view a background color so we can see it
        tagsView.backgroundColor = .yellow
    }
    
}

To use that in a table view cell, we create a cell class that uses our TagLabelsView as a subview:

class TagsCell: UITableViewCell {

    let tagsView: TagLabelsView = {
        let v = TagLabelsView()
        return v
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    func commonInit() -> Void {
        
        // add the container view
        contentView.addSubview(tagsView)
        
        // give it a background color so we can see it
        tagsView.backgroundColor = .yellow
        
        // use autolayout
        tagsView.translatesAutoresizingMaskIntoConstraints = false
        
        // constrain tagsView top / leading / trailing / bottom to
        //  contentView Layout Margins Guide
        let g = contentView.layoutMarginsGuide
        
        NSLayoutConstraint.activate([
            tagsView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            tagsView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            tagsView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            tagsView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
        ])

    }

    func fillData(_ tagNames: [String]) -> Void {
        tagsView.tagNames = tagNames
    }
    
    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        //force layout of all subviews including RectsView, which
        //updates RectsView's intrinsic height, and thus height of a cell
        self.setNeedsLayout()
        self.layoutIfNeeded()

        //now intrinsic height is correct, so we can call super method
        return super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
    }

}

and a sample view controller with a table view containing several sets of "tags":

class TagLabelsViewController: UIViewController {

    var myData: [[String]] = []
    
    let tableView: UITableView = {
        let v = UITableView()
        return v
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // add the table view
        view.addSubview(tableView)
        
        // use autolayout
        tableView.translatesAutoresizingMaskIntoConstraints = false
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            // constrain table view safe-area top / leading / trailing / bottom to view with 20-pts padding
            tableView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
        ])
        
        tableView.register(TagsCell.self, forCellReuseIdentifier: "c")
        tableView.dataSource = self
        tableView.delegate = self

        // get some sample tag data
        myData = SampleTags().samples()
    }

}

extension TagLabelsViewController: UITableViewDataSource, UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return myData.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! TagsCell
        c.fillData(myData[indexPath.row])
        return c
    }
}

class SampleTags: NSData {
    func samples() -> [[String]] {

        let tmp: [[String]] = [
            [
                ".htaccess",
                ".net",
                "ajax",
                "algorithm",
            ],
            [
                "amazon-web-services",
                "android-layout",
                "android-studio",
                "android",
                "angular",
                "angularjs",
                "apache-spark",
            ],
            [
                "apache",
                "api",
                "arrays",
            ],
            [
                "asp.net-core",
                "asp.net-mvc",
                "asp.net",
                "azure",
                "bash",
                "c",
                "c#",
                "c++",
                "class",
                "codeigniter",
                "cordova",
                "css",
                "csv",
                "dart",
                "database",
                "dataframe",
            ],
            [
                "date",
                "datetime",
                "dictionary",
                "django",
                "docker",
            ],
            [
                "eclipse",
                "email",
                "entity-framework",
                "excel",
                "express",
                "facebook",
            ],
            [
                "file",
                "firebase",
                "flutter",
                "for-loop",
                "forms",
                "function",
                "git",
                "go",
                "google-chrome",
                "google-maps",
                "hibernate",
                "html",
                "http",
            ],
            [
                "image",
                "ios",
                "iphone",
                "java",
                "javascript",
                "jquery",
                "json",
                "kotlin",
                "laravel",
                "linq",
                "linux",
            ],
            [
                "list",
                "loops",
                "macos",
                "matlab",
                "matplotlib",
                "maven",
                "mongodb",
                "multithreading",
                "mysql",
                "node.js",
            ],
            [
                "numpy",
                "object",
                "objective-c",
                "oop",
                "opencv",
                "oracle",
                "pandas",
                "performance",
                "perl",
                "php",
                "postgresql",
                "powershell",
                "python-2.7",
                "python-3.x",
                "python",
            ],
            [
                "qt",
                "r",
                "react-native",
                "reactjs",
                "regex",
                "rest",
                "ruby-on-rails-3",
                "ruby-on-rails",
                "ruby",
                "scala",
                "selenium",
                "shell",
                "sockets",
                "sorting",
                "spring-boot",
                "spring-mvc",
                "spring",
                "sql-server",
                "sql",
            ],
            [
                "sqlite",
                "string",
                "swift",
            ],
            [
                "swing",
                "symfony",
                "tensorflow",
                "tsql",
                "twitter-bootstrap",
                "typescript",
                "uitableview",
                "unit-testing",
                "unity3d",
                "validation",
                "vb.net",
                "vba",
                "visual-studio",
                "vue.js",
                "web-services",
                "windows",
                "winforms",
                "wordpress",
                "wpf",
                "xaml",
                "xcode",
                "xml",
            ],
        ]
        
        return tmp
    }
}

Sample Output (iPhone 13 Pro Max):

enter image description here

enter image description here

Kalevala answered 8/3, 2020 at 14:34 Comment(2)
Great answer @DonMag, still works in late 2021. How would you go about doing this in a tableview Cell, which should auto resize to fit the height of your containerview?Villalpando
@JanL - adding that to a table view cell is pretty straightforward. See the Edit to my answer.Kalevala

© 2022 - 2024 — McMap. All rights reserved.