Search code examples
uikitconstraints

How to detect when all the bounds are initialized in UIKit?


I have this setup:

  • there is a view-controller
  • on viewDidLoad, I am adding the following views and constraints:
    • a scroll view with edges mounted to the view
    • a scroll content view with edges and width equal to scroll view + constant height
    • a label inside the scroll content, mounted left+right to the scroll content + well defined vertical position (based on other constraint-ed views here, in the scroll content)

I need to perform some calculation derived from the labels actual bounds value! And I was under an impression that it's going to be just enough to wait for the viewDidLayoutSubviews on my view controller!

My label setup looks like this:

scrollContent.addSubview(bigPitchLine2)
bigPitchLine2.text = "Foo"
bigPitchLine2.font = .systemFont(ofSize: 120)
bigPitchLine2.textAlignment = .center
bigPitchLine2.snp.makeConstraints { make in
    make.centerX.equalToSuperview()
    make.top.equalTo(bigPitchLine1.snp.bottom).offset(40)
}

All the other views in hierarchy are set up in the similar fashion, and I assumed that would be relatively unproblematic. However, what I found is:

  • viewDidLayoutSubviews was called only once
  • and after this one call, the bounds of the label was still .zero

Do you think this means that my code is wrong and somehow my layout is not initialized to a sufficient degree? Or if viewDidLayoutSubviews does not actually guarantee that everything is laid out, then what is the proper way to know that all layout stuff was performed?

What am I missing? It's not supposed to work that way.


Solution

  • In general, if we want to do something based on the frame/bounds of a view (label, button, imageview, whatever), the most common approach is to subclass the view and handle it in layoutSubviews().

    Let's setup a scroll view, with a "content" view, and two labels (with different font sizes) as subviews of the content view:

    class ViewController: UIViewController {
        
        let scrollView = UIScrollView()
        let contentView = UIView()
        let bigPitchLine1 = UILabel()
        let bigPitchLine2 = UILabel()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            [scrollView, contentView, bigPitchLine1, bigPitchLine2].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
            }
            contentView.addSubview(bigPitchLine1)
            contentView.addSubview(bigPitchLine2)
            scrollView.addSubview(contentView)
            view.addSubview(scrollView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 8.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
                scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -8.0),
                
                contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 20.0),
                contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 20.0),
                contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -20.0),
                contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -20.0),
                
                bigPitchLine1.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20.0),
                bigPitchLine1.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0),
                
                bigPitchLine2.topAnchor.constraint(equalTo: bigPitchLine1.bottomAnchor, constant: 20.0),
                bigPitchLine2.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0),
    
                contentView.heightAnchor.constraint(equalToConstant: 400.0),
                contentView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -40.0),
                
            ])
            
            bigPitchLine1.text = "Foo"
            bigPitchLine1.font = .systemFont(ofSize: 100)
            bigPitchLine1.textAlignment = .center
    
            bigPitchLine2.text = "Foo"
            bigPitchLine2.font = .systemFont(ofSize: 50)
            bigPitchLine2.textAlignment = .center
            
            view.backgroundColor = .systemBackground
            scrollView.backgroundColor = .systemBlue
            contentView.backgroundColor = .cyan
            
            bigPitchLine1.backgroundColor = .white
            bigPitchLine2.backgroundColor = .white
            
        }
    
    }
    

    When we run that, we get this:

    enter image description here

    Now, for this example, we want to set the label background and foreground colors, based on the label width being greater-than 100-points.

    We can't do that in viewDidLayoutSubviews() because, as you've seen, the subviews of the various UI elements are not laid-out yet, and both label widths will be Zero.

    So, let's subclass UILabel:

    class MyLabel: UILabel {
        override func layoutSubviews() {
            super.layoutSubviews()
            // during development / debugging
            print("MyLabel bounds:", bounds)
            backgroundColor = bounds.width > 100.0 ? .systemGreen : .systemYellow
            textColor = bounds.width > 100.0 ? .white : .black
        }
    }
    

    If we then make only this change to our example view controller:

    //  let bigPitchLine1 = UILabel()
    //  let bigPitchLine2 = UILabel()
        let bigPitchLine1 = MyLabel()
        let bigPitchLine2 = MyLabel()
    

    We get this result:

    enter image description here

    Depending on exactly what you need to do (your comment says "create a patternImage-based UIColor"), this is probably the approach you'll want to take.

    Note: we've added print(...) logging so we can see the bounds values. When you run this, you'll see that layoutSubviews() can be (and usually is) called multiple times. Again, depending on what you need to do, you may want to add a property or two so you can run code only if the bounds has changed... something like this:

    class MyLabel: UILabel {
        
        var currentBounds: CGRect = .zero
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            // only execute code if the bounds has changed
            if currentBounds != bounds {
                currentBounds = bounds
                // during development / debugging
                print("MyLabel bounds:", bounds)
                backgroundColor = bounds.width > 100.0 ? .systemGreen : .systemYellow
                textColor = bounds.width > 100.0 ? .white : .black
            }
        }
        
    }