Home > OS >  Swift - Update auto layout properties of UI components programmatically
Swift - Update auto layout properties of UI components programmatically

Time:02-05

To design and create my UI, I always use auto layouts and do it programmatically instead of using storyboard. In every view class of mine, I have a method called

private func setupView(frame:CGRect) {
      /* START CONTAINER VIEW */
    containerView = UIView()
    containerView.translatesAutoresizingMaskIntoConstraints = false
    addSubview(containerView)
    
    containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: frame.width * (13 / IPHONE8_SCREEN_WIDTH)).isActive = true
    containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -frame.width * (13 / IPHONE8_SCREEN_WIDTH)).isActive = true
    containerView.topAnchor.constraint(equalTo: topAnchor, constant: frame.height * (26 / IPHONE8_SCREEN_HEIGHT)).isActive = true
    containerView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
    
    containerView.backgroundColor = UIColor.white
    /* END CONTAINER VIEW */
    ...
}

to initialize the components. Now let's say, in the method above, I initialize 10 UI components which are properly displayed when I run my code. However, depending on some variables, I have another function that is being called

private func addNextRoundInformation() {
   ..
    nextRoundLabel = UILabel()
    nextRoundLabel.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(nextRoundLabel)
    
    nextRoundLabel.leadingAnchor.constraint(equalTo: currentRoundLabel.leadingAnchor).isActive = true
    nextRoundLabel.widthAnchor.constraint(equalTo:currentRoundLabel.widthAnchor).isActive = true
    nextRoundLabel.topAnchor.constraint(equalTo: roundEndsInLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT)).isActive = true
}

which should place a new label between some others which were already initialized. Of course, when putting the new label between some particular ones, I also update the auto layout constraints of the of the bottom label like

 private func updateNumberOfWinnersLabelConstraint() {
    numberOfWinnersPerRoundLabel.topAnchor.constraint(equalTo: nextRoundLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT)).isActive = true
    numberOfWinnersPerRoundLabelValue.topAnchor.constraint(equalTo: nextRoundLabelValue.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT)).isActive = true
}

The topAnchor of each label depends on the bottom anchor of the previous one.

With this approach, I can't see nextRoundLabel at all. It only appears, if I initialize it in the private func setupView(frame:CGRect) {}

Why?

CodePudding user response:

You could do this with "Top-to-Bottom" constraints, but it would be rather complex.

You would need to essentially create a "Linked List" to track each view, the views above and below it, and its constraints.

So, to "insert" a new view after the 3rd view, you would need to:

  • deactivate the inter-view constraints
  • insert the new view into the linked list
  • re-create and activate the new constraints

Putting your views in a UIStackView turns that process into a single line of code:

stackView.insertArrangedSubview(newView, at: 3)

Here's a quick example:

class ViewController: UIViewController {
    
    let testView: SampleView = SampleView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemYellow
        
        let infoLabel: UILabel = {
            let v = UILabel()
            v.textAlignment = .center
            v.numberOfLines = 0
            v.text = "Tap to Insert \"New Label\"\nafter \"Label 3\""
            return v
        }()
        let btn: UIButton = {
            let v = UIButton()
            v.setTitle("Insert", for: [])
            v.setTitleColor(.white, for: .normal)
            v.setTitleColor(.lightGray, for: .highlighted)
            v.backgroundColor = .systemBlue
            return v
        }()
        
        [infoLabel, btn, testView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            infoLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            infoLabel.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.75),
            infoLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
            btn.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 20.0),
            btn.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.75),
            btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),

            testView.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 40.0),
            testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
            testView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            
        ])
        
        btn.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
    }
    
    @objc func btnTapped(_ sender: Any?) {
        testView.insertNew()
    }
    
}

class SampleView: UIView {
    
    var containerView: UIView!
    var stackView: UIStackView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        backgroundColor = .red
        setupView(frame: .zero)
    }
    
    private func setupView(frame:CGRect) {
        /* START CONTAINER VIEW */
        containerView = UIView()
        containerView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(containerView)
        
        stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 8
        stackView.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(stackView)
        
        NSLayoutConstraint.activate([

            containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            containerView.topAnchor.constraint(equalTo: topAnchor, constant: 12),
            containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
            
            stackView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0),
            stackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8.0),
            stackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8.0),

        ])
        
        containerView.backgroundColor = UIColor.white
        /* END CONTAINER VIEW */
        
        // add 10 labels to the stack view
        for i in 1...10 {
            let v = UILabel()
            v.text = "Label \(i)"
            v.backgroundColor = .green
            stackView.addArrangedSubview(v)
        }

    }
    
    func insertNew() {
        let v = UILabel()
        v.text = "New Label"
        v.backgroundColor = .cyan
        v.translatesAutoresizingMaskIntoConstraints = false
        
        // we're adding a label after 3rd label
        stackView.insertArrangedSubview(v, at: 3)
    }
}

It starts looking like this:

enter image description here

after tapping the "Insert" button, it looks like this:

enter image description here


Edit

To explain why your current approach isn't working...

Starting with this layout:

enter image description here

each label's Top is constrained to the previous label's Bottom (with constant: spacing).

Those constraints are indicated by the blue arrows.

You then want to "insert" Next Round Label between Round Ends In and Winners Per Round:

enter image description here

Your code:

  • adds the label
  • adds a constraint from the Top of Next Round Label to the Bottom of Round Ends In
  • adds a constraint from the Top of Winners Per Round to the Bottom of Next Round Label

but... Winners Per Round already has a .topAnchor connected to Round Ends In, so it now has two top anchors.

The conflicting constraints are shown in red:

enter image description here

As I said, I think your description of what you're trying to do would lend itself to using stack views, and would make "inserting" views so much easier.

But, if you need to stick with your current "Top-to-Bottom" constraints approach, you have several options.

One - remove all the labels, then re-add and re-constrain them, including the one you're inserting.

Two - track the constraints (using an array or custom object properties) so you can deactivate the conflicting constraint(s).

Three - use some code along the lines of

let theConstraint = containerView.constraints.first(where: {($0.secondItem as? UILabel) == roundEndsInLabel})

to "find" the constraint that needs to be deactivated.

CodePudding user response:

I found a working solution:

First, I declared two helper variables

/* START HELPER VARIABLES */
var numberOfWinnersLabelTopConstraint:NSLayoutConstraint?
var numberOfWinnersLabelValueTopConstraint:NSLayoutConstraint?
/* END HELPER VARIABLES */

then I made some minor adjustments in my setupView function:

private func setupView(frame:CGRect) {
   ...
     /* START NUMBER OF WINNERS PER ROUND LABEL */
    numberOfWinnersPerRoundLabel = UILabel()
    numberOfWinnersPerRoundLabel.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(numberOfWinnersPerRoundLabel)
    
    numberOfWinnersPerRoundLabel.leadingAnchor.constraint(equalTo: currentRoundLabel.leadingAnchor).isActive = true
    numberOfWinnersPerRoundLabel.widthAnchor.constraint(equalTo:currentRoundLabel.widthAnchor).isActive = true
   // numberOfWinnersPerRoundLabel.topAnchor.constraint(equalTo: roundEndsInLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT)).isActive = true  // Replacing with helper variable
    
    numberOfWinnersLabelTopConstraint = numberOfWinnersPerRoundLabel.topAnchor.constraint(equalTo: roundEndsInLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT))
    numberOfWinnersLabelTopConstraint?.isActive = true
    numberOfWinnersPerRoundLabel.text = NSLocalizedString(NUMBER_OF_WINNERS_PER_ROUND_TEXT, comment: "")
    numberOfWinnersPerRoundLabel.textColor = .darkGray
    numberOfWinnersPerRoundLabel.adjustsFontSizeToFitWidth = true
    numberOfWinnersPerRoundLabel.font = .systemFont(ofSize: 12)
    /* END NUMBER OF WINNERS PER ROUND LABEL */

    /* START NUMBER OF WINNERS PER ROUND LABEL VALUE */
    numberOfWinnersPerRoundLabelValue = UILabel()
    numberOfWinnersPerRoundLabelValue.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(numberOfWinnersPerRoundLabelValue)
    
    numberOfWinnersPerRoundLabelValue.leadingAnchor.constraint(equalTo: currentRoundLabelValue.leadingAnchor).isActive = true
    numberOfWinnersPerRoundLabelValue.widthAnchor.constraint(equalTo:currentRoundLabelValue.widthAnchor).isActive = true
  //  numberOfWinnersPerRoundLabelValue.topAnchor.constraint(equalTo: numberOfWinnersPerRoundLabel.topAnchor).isActive = true // replacing with helper variable
    
    numberOfWinnersLabelValueTopConstraint = numberOfWinnersPerRoundLabelValue.topAnchor.constraint(equalTo: numberOfWinnersPerRoundLabel.topAnchor)
    numberOfWinnersLabelValueTopConstraint?.isActive = true
    
    numberOfWinnersPerRoundLabelValue.textColor = .black
    /* END NUMBER OF WINNERS PER ROUND LABEL VALUE */

By introducing the helper variables, I could easily deactivate the topConstraint when adding the nextRoundLabel

 private func addNextRoundInformation() {
    nextRoundLabel = UILabel()
    nextRoundLabel.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(nextRoundLabel)

    nextRoundLabel.leadingAnchor.constraint(equalTo: currentRoundLabel.leadingAnchor).isActive = true
    nextRoundLabel.widthAnchor.constraint(equalTo:currentRoundLabel.widthAnchor).isActive = true
  
    nextRoundLabel.topAnchor.constraint(equalTo: roundEndsInLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT)).isActive = true
    nextRoundLabel.text = "Next round starts in"
    nextRoundLabel.textColor = .darkGray
    nextRoundLabel.font = .systemFont(ofSize: 12)

    nextRoundLabelValue = UILabel()
    nextRoundLabelValue.translatesAutoresizingMaskIntoConstraints = false
    containerView.addSubview(nextRoundLabelValue)
    
    nextRoundLabelValue.leadingAnchor.constraint(equalTo: currentRoundLabelValue.leadingAnchor).isActive = true
    nextRoundLabelValue.widthAnchor.constraint(equalTo:currentRoundLabelValue.widthAnchor).isActive = true
    nextRoundLabelValue.topAnchor.constraint(equalTo:nextRoundLabel.topAnchor).isActive = true
    nextRoundLabelValue.textColor = .black
    nextRoundLabelValue.text = "Next round label value"
    nextRoundLabelValue.font = .systemFont(ofSize: 14)
}

private func updateNumberOfWinnersLabelConstraint() {
    numberOfWinnersLabelTopConstraint?.isActive = false // Deactivate previous constraint
    numberOfWinnersLabelValueTopConstraint?.isActive = false
    
    numberOfWinnersLabelTopConstraint =  numberOfWinnersPerRoundLabel.topAnchor.constraint(equalTo: nextRoundLabel.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT))
    numberOfWinnersLabelTopConstraint?.isActive = true
    
    numberOfWinnersLabelValueTopConstraint = numberOfWinnersPerRoundLabelValue.topAnchor.constraint(equalTo: nextRoundLabelValue.bottomAnchor, constant: frame.height * (19 / IPHONE8_SCREEN_HEIGHT))
    numberOfWinnersLabelValueTopConstraint?.isActive = true
}

Basically, I only had to update the topConstraints of the numberOfWinnersPerRoundLabel and numberOfWinnersPerRoundLabelValue since everything else would be the same. No changes needed for currentRoundLabel.

I tested it and it worked!

  •  Tags:  
  • Related