iOS - Add vertical line programatically inside a stack view
Asked Answered
E

12

20

I'm trying to add vertical lines, between labels inside a stack view all programatically.

The desired finish will be something like this image:

stackview

I can add the labels, all with the desired spacing; I can add horizontal lines but I can't figure out how to add those separator vertical lines in-between.

I'd like to do it something like this:

let stackView = UIStackView(arrangedSubviews: [label1, verticalLine, label2, verticalLine, label3])

Any hint?

Thanks

Electrify answered 21/5, 2017 at 15:0 Comment(1)
Create a 2 point wide 'UIView' (enforced with constraints) with a solid black colour and add two of them to the 'UIStackView' in the correct places?Ombudsman
B
47

You can't use the same view in two places, so you'll need to create two separate vertical line views. You need to configure each vertical line view like this:

  • Set its background color.
  • Constrain its width to 1 (so you get a line, not a rectangle).
  • Constrain its height (so it doesn't get stretched to the full height of the stack view).

So add the labels one at a time to the stack view, and do something like this before adding each label to the stack view:

if stackView.arrangedSubviews.count > 0 {
    let separator = UIView()
    separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
    separator.backgroundColor = .black
    stackView.addArrangedSubview(separator)
    separator.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.6).isActive = true
}

Note that you do not want the vertical lines to be the same width as the labels, so you must not set the distribution property of the stack view to fillEqually. Instead, if you want all the labels to have equal width, you must create width constraints between the labels yourself. For example, after adding each new label, do this:

if let firstLabel = stackView.arrangedSubviews.first as? UILabel {
    label.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
}

Result:

result

Full playground code (updated to Swift 4.1 by Federico Zanetello):

import UIKit
import PlaygroundSupport

extension UIFont {
  var withSmallCaps: UIFont {
    let feature: [UIFontDescriptor.FeatureKey: Any] = [
      UIFontDescriptor.FeatureKey.featureIdentifier: kLowerCaseType,
      UIFontDescriptor.FeatureKey.typeIdentifier: kLowerCaseSmallCapsSelector]
    let attributes: [UIFontDescriptor.AttributeName: Any] = [UIFontDescriptor.AttributeName.featureSettings: [feature]]
    let descriptor = self.fontDescriptor.addingAttributes(attributes)
    return UIFont(descriptor: descriptor, size: pointSize)
  }
}

let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 44))
rootView.backgroundColor = .white
PlaygroundPage.current.liveView = rootView

let stackView = UIStackView()
stackView.axis = .horizontal
stackView.alignment = .center
stackView.frame = rootView.bounds
rootView.addSubview(stackView)

typealias Item = (name: String, value: Int)
let items: [Item] = [
  Item(name: "posts", value: 135),
  Item(name: "followers", value: 6347),
  Item(name: "following", value: 328),
]

let valueStyle: [NSAttributedStringKey: Any] = [NSAttributedStringKey.font: UIFont.boldSystemFont(ofSize: 12).withSmallCaps]
let nameStyle: [NSAttributedStringKey: Any] = [NSAttributedStringKey.font: UIFont.systemFont(ofSize: 12).withSmallCaps,
                                NSAttributedStringKey.foregroundColor: UIColor.darkGray]
let valueFormatter = NumberFormatter()
valueFormatter.numberStyle = .decimal

for item in items {
  if stackView.arrangedSubviews.count > 0 {
    let separator = UIView()
    separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
    separator.backgroundColor = .black
    stackView.addArrangedSubview(separator)
    separator.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.4).isActive = true
  }

  let richText = NSMutableAttributedString()
  let valueString = valueFormatter.string(for: item.value)!
  richText.append(NSAttributedString(string: valueString, attributes: valueStyle))
  richText.append(NSAttributedString(string: "\n" + item.name, attributes: nameStyle))
  let label = UILabel()
  label.attributedText = richText
  label.textAlignment = .center
  label.numberOfLines = 0
  stackView.addArrangedSubview(label)

  if let firstLabel = stackView.arrangedSubviews.first as? UILabel {
    label.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
  }
}

UIGraphicsBeginImageContextWithOptions(rootView.bounds.size, true, 1)
rootView.drawHierarchy(in: rootView.bounds, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
let png = UIImagePNGRepresentation(image)!
let path = NSTemporaryDirectory() + "/image.png"
Swift.print(path)
try! png.write(to: URL(fileURLWithPath: path))
Brannan answered 21/5, 2017 at 17:11 Comment(2)
This was a great hack in order how to achieve my desired design;Electrify
I had a similar problem and this technique worked out great!. One optimization might be to add the label constraint skipping the first one.Burkitt
T
32

You can try the following.

  1. First of all take a UIView and apply the same constraints of UIStackView to this UIView.
  2. Make the Background color of this UIView to Black (The color of the lines)
  3. Now take a UIStackView and add it as a child of above UIView.
  4. Add constraints of the UIStackView i.e. bind it to all the edges of parent UIView.
  5. Now make the bakckground color of UIStackView to Clear Color.
  6. Set the spacing of UIStackView to 1 or 2 (the width of lines)
  7. Now add the 3 labels into stackview.
  8. Make sure the labels have background color to White Color and Text Color to Black Color.

Thus you'll achieve the required scene. See these pictures for reference.

enter image description here enter image description here

Terrain answered 21/5, 2017 at 15:29 Comment(5)
Hmm.. I'd never thought doing that, ever. It may solve the issue. I'll give it a try later and I'll feedback if it worked properly. Thank youElectrify
It's exactly what I'm looking for!Judges
A great solution: just create a background view with background-colorGrandiose
Nice idea... ;))Placido
Great idea! Good workHiggle
N
19

Here's a simple extension for adding separators between each row (NOTE! Rows, not columns as asked! Simple to modify for that case as well) . Basically same as accepted answer, but in a reusable format.

Use by calling e.g.

yourStackViewObjectInstance.addHorizontalSeparators(color : .black)

Extension:

extension UIStackView {
    func addHorizontalSeparators(color : UIColor) {
        var i = self.arrangedSubviews.count
        while i >= 0 {
            let separator = createSeparator(color: color)
            insertArrangedSubview(separator, at: i)
            separator.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
            i -= 1
        }
    }

    private func createSeparator(color : UIColor) -> UIView {
        let separator = UIView()
        separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
        separator.backgroundColor = color
        return separator
    }
}
Nonprofit answered 12/10, 2018 at 9:29 Comment(3)
This is a nice one. Thanks for sharingElectrify
Worked great. Thx!Pantelleria
For anyone reading this, if you don't want seperator at the top, change while i >= 0 to while i > 0. If you don't want seperator at the bottom, change var i = self.arrangedSubviews.count to var i = self.arrangedSubviews.count - 1.Tris
D
1

Here's a more flexible UIStackView subclass that supports to arbitrary addition of arranged subviews and is suitable for those that need a clear background on their UIStackView and subviews to put on top of a UIVisualEffectView, like the picture below.

import UIKit

@IBDesignable class SeparatorStackView: UIStackView {

    @IBInspectable var separatorColor: UIColor? = .black {
        didSet {
            invalidateSeparators()
        }
    }
    @IBInspectable var separatorWidth: CGFloat = 0.5 {
        didSet {
            invalidateSeparators()
        }
    }
    @IBInspectable private var separatorTopPadding: CGFloat = 0 {
        didSet {
            separatorInsets.top = separatorTopPadding
        }
    }
    @IBInspectable private var separatorBottomPadding: CGFloat = 0 {
        didSet {
            separatorInsets.bottom = separatorBottomPadding
        }
    }
    @IBInspectable private var separatorLeftPadding: CGFloat = 0 {
        didSet {
            separatorInsets.left = separatorLeftPadding
        }
    }
    @IBInspectable private var separatorRightPadding: CGFloat = 0 {
        didSet {
            separatorInsets.right = separatorRightPadding
        }
    }

    var separatorInsets: UIEdgeInsets = .zero {
        didSet {
            invalidateSeparators()
        }
    }

    private var separators: [UIView] = []

    override func layoutSubviews() {
        super.layoutSubviews()

        invalidateSeparators()
    }

    override func awakeFromNib() {
        super.awakeFromNib()

        invalidateSeparators()
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()

        invalidateSeparators()
    }


    private func invalidateSeparators() {
        guard arrangedSubviews.count > 1 else {
            separators.forEach({$0.removeFromSuperview()})
            separators.removeAll()
            return
        }

        if separators.count > arrangedSubviews.count {
            separators.removeLast(separators.count - arrangedSubviews.count)
        } else if separators.count < arrangedSubviews.count {
            separators += Array<UIView>(repeating: UIView(), count: arrangedSubviews.count - separators.count)
        }

        separators.forEach({$0.backgroundColor = self.separatorColor; self.addSubview($0)})

        for (index, subview) in arrangedSubviews.enumerated() where arrangedSubviews.count >= index + 2 {
            let nextSubview = arrangedSubviews[index + 1]
            let separator = separators[index]

            let origin: CGPoint
            let size: CGSize

            if axis == .horizontal {
                let originX = (nextSubview.frame.maxX - subview.frame.minX)/2 + separatorInsets.left - separatorInsets.right
                origin = CGPoint(x: originX, y: separatorInsets.top)
                let height = frame.height - separatorInsets.bottom - separatorInsets.top
                size = CGSize(width: separatorWidth, height: height)
        } else {
                let originY = (nextSubview.frame.maxY - subview.frame.minY)/2 + separatorInsets.top - separatorInsets.bottom
                origin = CGPoint(x: separatorInsets.left, y: originY)
                let width = frame.width - separatorInsets.left - separatorInsets.right
                size = CGSize(width: width, height: separatorWidth)
            }

            separator.frame = CGRect(origin: origin, size: size)
        }
    }
}

The result?

SeparatorStackView on top of a UIVisualEffectView

Dumps answered 24/7, 2018 at 15:8 Comment(4)
On a vertical StackView with multiple items this only ads one separator between the first and second itemSynaeresis
The line in invalidateSeparators() that creates the separators array adds the same separator to the array multiple times.Intercurrent
It also calculates the wrong frame for any separators beyond the first. Working on a fix.Intercurrent
I have stackView in multiple items, Why just first in show?Ova
R
1

You can use interface builder to do this. Set the distribution of the parent UIStackview to Equal Centering and add two UIView with width 0 in the beginnging and ending of the parent UIStackView. Then add a UIView with width 1 in the middle and set the background color of the UIView.

enter image description here enter image description here

Regan answered 16/11, 2020 at 2:54 Comment(0)
M
1

Another version of @OwlOCR's solution, if we don't want separators at the beginning and end.

extension UIStackView {
    func addHorizontalSeparators(color : UIColor) {
        let separatorsToAdd = self.arrangedSubviews.count - 1
        var insertAt = 1
        for _ in 1...separatorsToAdd {
            let separator = createSeparator(color: color)
            insertArrangedSubview(separator, at: insertAt)
            insertAt += 2
            separator.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
        }
    }

    private func createSeparator(color : UIColor) -> UIView {
        let separator = UIView()
        separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
        separator.backgroundColor = color
        return separator
    }
}
Moment answered 4/3, 2021 at 17:40 Comment(0)
T
1

My solution is very simple. Instead of creating method for vertical and horizontal axis, you can use switch statement on axis property inside UIStackView to check which axis is currently being used.

To add separators simply just add this extension and pass positions of where separators should be placed and color.

extension UIStackView {

func addSeparators(at positions: [Int], color: UIColor) {
    for position in positions {
        let separator = UIView()
        separator.backgroundColor = color
        
        insertArrangedSubview(separator, at: position)
        switch self.axis {
        case .horizontal:
            separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
            separator.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
        case .vertical:
            separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
            separator.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
        @unknown default:
            fatalError("Unknown UIStackView axis value.")
        }
    }
}

}

Example use case:

stackView.addSeparators(at: [2], color: .black)
Todo answered 10/3, 2021 at 15:57 Comment(0)
A
0

If the vertical line character, "|", works for the look you want, then you can add labels into the Stack View where you want separator lines. Then use:

myStackView.distribution = .equalSpacing

You can also change the Stack View Distribution in Interface Builder.

Accentor answered 7/8, 2018 at 16:27 Comment(0)
F
0

@GOR answer extension only for vertical line and only in centers

Stackview settings: set width contraints of each subview and parent stackview should be fill

Here's a simple extension for adding vertical separators between each row.

func addVerticalSeparators(color : UIColor) {
    var i = self.arrangedSubviews.count
    while i > 1 {
        let separator = verticalCreateSeparator(color: color)
        insertArrangedSubview(separator, at: i-1)   // (i-1) for centers only
        separator.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
        i -= 1
    }
}

private func verticalCreateSeparator(color : UIColor) -> UIView {
    let separator = UIView()
    separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
    separator.backgroundColor = color
    return separator
} 
Farm answered 10/11, 2018 at 6:30 Comment(1)
It will throw u an error. Vertical separators shouldn't have the same height as UIStackView.Todo
I
0

@mark-bourke's answer only worked for a single separator. I fixed the invalidateSeparators() method for multiple separators. I haven't tested it with horizontal stack views, but it works for vertical ones:

  private func invalidateSeparators() {
    guard arrangedSubviews.count > 1 else {
      separators.forEach({$0.removeFromSuperview()})
      separators.removeAll()
      return
    }

    if separators.count > arrangedSubviews.count {
      separators.removeLast(separators.count - arrangedSubviews.count)
    } else if separators.count < arrangedSubviews.count {
      for _ in 0..<(arrangedSubviews.count - separators.count - 1) {
        separators.append(UIView())
      }
    }

    separators.forEach({$0.backgroundColor = self.separatorColor; self.addSubview($0)})

    for (index, subview) in arrangedSubviews.enumerated() where arrangedSubviews.count >= index + 2 {
      let nextSubview = arrangedSubviews[index + 1]
      let separator = separators[index]

      let origin: CGPoint
      let size: CGSize

      if axis == .horizontal {
        let originX = subview.frame.maxX + (nextSubview.frame.minX - subview.frame.maxX) / 2.0 + separatorInsets.left - separatorInsets.right
        origin = CGPoint(x: originX, y: separatorInsets.top)
        let height = frame.height - separatorInsets.bottom - separatorInsets.top
        size = CGSize(width: separatorWidth, height: height)
      } else {
        let originY = subview.frame.maxY + (nextSubview.frame.minY - subview.frame.maxY) / 2.0 + separatorInsets.top - separatorInsets.bottom
        origin = CGPoint(x: separatorInsets.left, y: originY)
        let width = frame.width - separatorInsets.left - separatorInsets.right
        size = CGSize(width: width, height: separatorWidth)
      }

      separator.frame = CGRect(origin: origin, size: size)
      separator.isHidden = nextSubview.isHidden
    }
  }
Intercurrent answered 15/4, 2020 at 23:3 Comment(0)
G
0

a nice cocoa-pod that do the job pretty well. It uses Swizzeling, well coded.

https://github.com/barisatamer/StackViewSeparator

enter image description here

Goldfinch answered 7/5, 2020 at 8:28 Comment(0)
I
0

I've upvoted @FrankByte.com's answer because he helped me get to my solution, which is pretty similar to what the OP wanted to do:


extension UIStackView {
    func addVerticalSeparators(color : UIColor, multiplier: CGFloat = 0.5) {
        var i = self.arrangedSubviews.count - 1
        while i > 0 {
            let separator = createSeparator(color: color)
            insertArrangedSubview(separator, at: i)
            separator.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: multiplier).isActive = true
            i -= 1
        }
    }

    private func createSeparator(color: UIColor) -> UIView {
        let separator = UIView()
        separator.widthAnchor.constraint(equalToConstant: 1).isActive = true
        separator.backgroundColor = color
        return separator
    }
}
Illiterate answered 17/9, 2020 at 1:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.