How can I create UIStackView with variable spacing between views?
Asked Answered
W

6

93

I have a simple horizontal UIStackView with several UIViews stacked inside. My goal is to create variable spacing between views. I am well aware that I can create constant space between the subviews using "spacing" property. However my goal is to create variable space. Please note, if at all possible, I would like to avoid using invisible views that act as spacers.

The best I came up with was to wrap my UIViews in a separate UIStackView, and use layoutMarginsRelativeArrangement = YES to respect layout margins of my inner stack. I was hoping I could do something similar with any UIView without resorting to this ugly work-around. Here is my sample code:

// Create stack view
UIStackView *stackView = [[UIStackView alloc] init];
stackView.translatesAutoresizingMaskIntoConstraints = NO;
stackView.axis = UILayoutConstraintAxisHorizontal;
stackView.alignment = UIStackViewAlignmentCenter;
stackView.layoutMarginsRelativeArrangement = YES;

// Create subview
UIView *view1 = [[UIView alloc] init];
view1.translatesAutoresizingMaskIntoConstraints = NO;
// ... Add Auto Layout constraints for height / width
// ...
// I was hoping the layoutMargins would be respected, but they are not
view1.layoutMargins = UIEdgeInsetsMake(0, 25, 0, 0);

// ... Create more subviews
// UIView view2 = [[UIView alloc] init];
// ...

// Stack the subviews
[stackView addArrangedSubview:view1];
[stackView addArrangedSubview:view2];

The result is a stack with views right next to each other with spacing:

enter image description here

Washtub answered 7/10, 2015 at 17:51 Comment(0)
C
177

Update For iOS 11, StackViews with Custom Spacing

Apple has added the ability to set custom spacing in iOS 11. You simply have to specify the spacing after each arranged subview. Unfortunately you can't specify spacing before.

stackView.setCustomSpacing(10.0, after: firstLabel)
stackView.setCustomSpacing(10.0, after: secondLabel)

Still way better than using your own views.

For iOS 10 and Below

You could simply add a transparent views into your stack view and add width constraints to them.

(Label - UIView - Label - UIView -Label)

and if you keep distribution to fill, then you can setup variable width constraints on your UIViews.

But I would consider if this is the right situation to use stackviews if that's the case. Autolayout makes it very easy to setup variable widths between views.

Consummation answered 7/10, 2015 at 18:15 Comment(9)
Thanks but I was hoping I didn't have to resort to blank views to add space or going back to Auto Layout. Though if UIStackView does not support my use case I may need to anyway.Washtub
Why do you want the spacing to be variable? You can have variable spacing if you use 'distribution' 'equal centering'. This will make the centers equal widths apart, but the spacing will vary. Explain more about your use case.Consummation
Basically, I have several views that I need to distribute horizontally (or vertically) with predetermined space between the views, which may or may not be of the same value. I want to be able to specify those margins with either layoutMargins or some other mechanism.Washtub
Let me know if you find another way, but I believe setting constraints on UIViews within a stackview is the most straight forward solution.Consummation
Sure, will do! Thanks for your help. To clarify your comment "I believe setting constraints on UIViews within a stackview is the most straight forward solution" - are you referring to your original suggestion to insert blank views between visible ones?Washtub
Yeah, that's what I'm referring to. Cheers! And if you wouldn't mind updating your question or accepting the answer that would be awesome!Consummation
Let us continue this discussion in chat.Consummation
Still can't get over how ugly Swifts interpretation of UIKit is... the Obj-C variant [stackView setCustomSpacing:10 afterView:secondLabel];is much cleaner.Philipson
I was testing this approach and it seems like it creates a spacing constraint between the views. When you try to hide a view from stack view, the rest of the view would get resized accordingly. But, in this case as we added constraints, the debugger would complain and say it's trying to break the constraints. It succeeds but definitely not as clean as it would be if we had used spacer views in between.Total
L
5

Swift 4

Following lilpit answer, here is an extension of the UIStackView to add a top and a bottom spacing to your arrangedSubview

extension UIStackView {
    func addCustomSpacing(top: CGFloat, bottom: CGFloat) {
    
        //If the stack view has just one arrangedView, we add a dummy one
        if self.arrangedSubviews.count == 1 {
            self.insertArrangedSubview(UIView(frame: .zero), at: 0)
        }
    
        //Getting the second last arrangedSubview and the current one
        let lastTwoArrangedSubviews = Array(self.arrangedSubviews.suffix(2))
        let arrSpacing: [CGFloat] = [top, bottom]
    
        //Looping through the two last arrangedSubview to add spacing in each of them
        for (index, anArrangedSubview) in lastTwoArrangedSubviews.enumerated() {
        
            //After iOS 11, the stackview has a native method
            if #available(iOS 11.0, *) {
                self.setCustomSpacing(arrSpacing[index], after: anArrangedSubview)
                //Before iOS 11 : Adding dummy separator UIViews
            } else {
                guard let arrangedSubviewIndex = arrangedSubviews.firstIndex(of: anArrangedSubview) else {
                    return
                }
            
                let separatorView = UIView(frame: .zero)
                separatorView.translatesAutoresizingMaskIntoConstraints = false
            
                //calculate spacing to keep a coherent spacing with the ios11 version
                let isBetweenExisitingViews = arrangedSubviewIndex != arrangedSubviews.count - 1
                let existingSpacing = isBetweenExisitingViews ? 2 * spacing : spacing
                let separatorSize = arrSpacing[index] - existingSpacing
            
                guard separatorSize > 0 else {
                    return
                }
            
                switch axis {
                case .horizontal:
                    separatorView.widthAnchor.constraint(equalToConstant: separatorSize).isActive = true
                case .vertical:
                    separatorView.heightAnchor.constraint(equalToConstant: separatorSize).isActive = true
                }
            
                insertArrangedSubview(separatorView, at: arrangedSubviewIndex + 1)
            }
        }
    }
}

Then you would use it like this:

//Creating label to add to the UIStackview
let label = UILabel(frame: .zero)

//Adding label to the UIStackview
stackView.addArrangedSubview(label)

//Create margin on top and bottom of the UILabel
stackView.addCustomSpacing(top: 40, bottom: 100)
Lassa answered 22/1, 2019 at 11:22 Comment(0)
T
3

From Rob's response I created a UIStackView extension that might help:

extension UIStackView {
  func addCustomSpacing(_ spacing: CGFloat, after arrangedSubview: UIView) {
    if #available(iOS 11.0, *) {
      self.setCustomSpacing(spacing, after: arrangedSubview)
    } else {
      let separatorView = UIView(frame: .zero)
      separatorView.translatesAutoresizingMaskIntoConstraints = false
      switch axis {
      case .horizontal:
        separatorView.widthAnchor.constraint(equalToConstant: spacing).isActive = true
      case .vertical:
        separatorView.heightAnchor.constraint(equalToConstant: spacing).isActive = true
      }
      if let index = self.arrangedSubviews.firstIndex(of: arrangedSubview) {
        insertArrangedSubview(separatorView, at: index + 1)
      }
    }
  }
}

You can use and modify it any way you want, for exemplo if you want the "separatorView" reference, you can just return the UIView:

  func addCustomSpacing(_ spacing: CGFloat, after arrangedSubview: UIView) -> UIView?
Technician answered 26/12, 2018 at 16:33 Comment(4)
This will not work if you already have a spacing defined in your stackView ( in that case, the ios 11 version will work as expected, but the ios10 version will have different spacing (2 * defaultSpacing + spacing )Almonte
If you want to have a custom spacing, you should not use the spacing property. Also, you need to use stackView.alignment = .fillTechnician
I don't understand your answer, with setCustomSpacing, it's possible to use the spacing property for indexes where you didn't use setCustomSpacing, so my answer is rightAlmonte
How not? You copied and pasted my answer and used the same setCustomSpacing. Also, you changed the names and things of place to seems a different answer.Technician
R
1

To support iOS 11.x and lower, I extended the UIStackView like Enrique mentioned, however I modified it to include:

  • Adding a space before the arrangedSubview
  • Handling cases where a space already exists and just needs to be updated
  • Removing an added space
extension UIStackView {

    func addSpacing(_ spacing: CGFloat, after arrangedSubview: UIView) {
        if #available(iOS 11.0, *) {
            setCustomSpacing(spacing, after: arrangedSubview)
        } else {

            let index = arrangedSubviews.firstIndex(of: arrangedSubview)

            if let index = index, arrangedSubviews.count > (index + 1), arrangedSubviews[index + 1].accessibilityIdentifier == "spacer" {

                arrangedSubviews[index + 1].updateConstraint(axis == .horizontal ? .width : .height, to: spacing)
            } else {
                let separatorView = UIView(frame: .zero)
                separatorView.accessibilityIdentifier = "spacer"
                separatorView.translatesAutoresizingMaskIntoConstraints = false

                switch axis {
                case .horizontal:
                    separatorView.widthAnchor.constraint(equalToConstant: spacing).isActive = true
                case .vertical:
                    separatorView.heightAnchor.constraint(equalToConstant: spacing).isActive = true
                @unknown default:
                    return
                }
                if let index = index {
                    insertArrangedSubview(separatorView, at: index + 1)
                }
            }
        }
    }

    func addSpacing(_ spacing: CGFloat, before arrangedSubview: UIView) {

        let index = arrangedSubviews.firstIndex(of: arrangedSubview)

        if let index = index, index > 0, arrangedSubviews[index - 1].accessibilityIdentifier == "spacer" {

            let previousSpacer = arrangedSubviews[index - 1]

            switch axis {
            case .horizontal:
                previousSpacer.updateConstraint(.width, to: spacing)
            case .vertical:
                previousSpacer.updateConstraint(.height, to: spacing)
            @unknown default: return // Incase NSLayoutConstraint.Axis is extended in future
            }
        } else {
            let separatorView = UIView(frame: .zero)
            separatorView.accessibilityIdentifier = "spacer"
            separatorView.translatesAutoresizingMaskIntoConstraints = false

            switch axis {
            case .horizontal:
                separatorView.widthAnchor.constraint(equalToConstant: spacing).isActive = true
            case .vertical:
                separatorView.heightAnchor.constraint(equalToConstant: spacing).isActive = true
            @unknown default:
                return
            }
            if let index = index {
                insertArrangedSubview(separatorView, at: max(index - 1, 0))
            }
        }

    }

    func removeSpacing(after arrangedSubview: UIView) {
        if #available(iOS 11.0, *) {
            setCustomSpacing(0, after: arrangedSubview)
        } else {
            if let index = arrangedSubviews.firstIndex(of: arrangedSubview), arrangedSubviews.count > (index + 1), arrangedSubviews[index + 1].accessibilityIdentifier == "spacer" {
                arrangedSubviews[index + 1].removeFromStack()
            }
        }
    }

    func removeSpacing(before arrangedSubview: UIView) {
        if let index = arrangedSubviews.firstIndex(of: arrangedSubview), index > 0, arrangedSubviews[index - 1].accessibilityIdentifier == "spacer" {
            arrangedSubviews[index - 1].removeFromStack()
        }
    }
}


extension UIView {
    func updateConstraint(_ attribute: NSLayoutConstraint.Attribute, to constant: CGFloat) {
        for constraint in constraints {
            if constraint.firstAttribute == attribute {
              constraint.constant = constant
            }
        }
    }

    func removeFromStack() {
        if let stack = superview as? UIStackView, stack.arrangedSubviews.contains(self) {
            stack.removeArrangedSubview(self)
            // Note: 1
            removeFromSuperview()
        }
    }
}

Note: 1 - According to the documentation:

To prevent the view from appearing on screen after calling the stack’s removeArrangedSubview: method, explicitly remove the view from the subviews array by calling the view’s removeFromSuperview() method, or set the view’s isHidden property to true.

Robson answered 29/10, 2019 at 13:50 Comment(0)
C
0

To achieve a similar behavior like CSS margin and padding.

  1. Padding

    myStackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: top, leading: left, bottom: bottom, trailing: right);

  2. Margin (create a wrapper View and add padding to the wrapper)

        wrapper = UIStackView();
        wrapper!.frame = viewToAdd.frame;
        wrapper!.frame.size.height = wrapper!.frame.size.height + marginTop + marginBottom;
        wrapper!.frame.size.width = wrapper!.frame.size.width + marginLeft + marginRight;
        (wrapper! as! UIStackView).axis = .horizontal;
        (wrapper! as! UIStackView).alignment = .fill
        (wrapper! as! UIStackView).spacing = 0
        (wrapper! as! UIStackView).distribution = .fill
        wrapper!.translatesAutoresizingMaskIntoConstraints = false
    
        (wrapper! as! UIStackView).isLayoutMarginsRelativeArrangement = true;
        (wrapper! as! UIStackView).insetsLayoutMarginsFromSafeArea = false;
        wrapper!.directionalLayoutMargins = NSDirectionalEdgeInsets(top: marginTop, leading: marginLeft, bottom: marginBottom, trailing: marginRight);wrapper.addArrangedSubview(viewToAdd);
    
Counterrevolution answered 18/4, 2020 at 15:20 Comment(0)
E
0

If you do not know the previous view you can create your own spacing UIView and add it to your stack view as an arranged subview.

func spacing(value: CGFloat) -> UIView {
    let spacerView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
    spacerView.translatesAutoresizingMaskIntoConstraints = false
    spacerView.heightAnchor.constraint(equalToConstant: value).isActive = true
    return spacerView
}
stackView.addArrangedSubview(spacing(value: 16))
Entremets answered 29/7, 2020 at 10:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.