Search code examples
iosswiftuiviewautolayout

Using custom UIView class with Auto Layout returns incorrect bounds


I'm presenting a simple view controller. To follow MVC, I moved my programmatic view code into a separate UIView subclass. I followed the advice here of how to set up the custom view.

This works ok in iPhone. But, on iPad, the view controller is automatically presented with the new post-iOS 13 default modal style .pageSheet, which causes one of my buttons to be too wide. I looked into the view debugger and it's because the width constraint is set to self.bounds.width * 0.7, and self.bounds returns the full width of the iPad (1024 points) at the time the constraint is set, not the actual final view (which is only 704 points wide).

iPhone simulator screenshot

iPad 12.9" simulator screenshot

Three questions:

  1. In general, am I setting up the custom view + view controller correctly? Or is there a best practice I'm not following?

  2. How do I force an update to the constraints so that the view recognizes it's being presented in a .pageSheet modal mode on iPad, and automatically update the width of self.bounds? I tried self.view.setNeedsLayout() and self.view.layoutIfNeeded() but it didn't do anything.

  3. Why is it that the Snooze button is laid out correctly on iPad, but not the Turn Off Alarm button... the Snooze button relies on constraints pinned to self.leadingAnchor and self.trailingAnchor. Why do those constraints correctly recognize the modal view they're being presented in, but self.bounds.width doesn't?

Code:

Xcode project posted here

View Controller using a custom view:

class CustomViewController: UIViewController {
    
    var customView: CustomView {
        return self.view as! CustomView
    }
    
    override func loadView() {
        let customView = CustomView(frame: UIScreen.main.bounds)
        self.view = customView  // Set view to our custom view
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        print("View's upon viewDidLoad is: \(self.view.bounds)") // <-- This gives incorrect bounds

        // Add actions for buttons
        customView.snoozeButton.addTarget(self, action: #selector(snoozeButtonPressed), for: .touchUpInside)
        customView.dismissButton.addTarget(self, action: #selector(dismissButtonPressed), for: .touchUpInside)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        print("View's bounds upon viewDidAppear is: \(self.view.bounds)") // <-- This gives correct bounds
        
        // This doesn't work
//      //self.view.setNeedsLayout()
//      // self.view.layoutIfNeeded()

    }

    
    @objc func snoozeButtonPressed() {
        // Do something here
    }
    
    @objc func dismissButtonPressed() {
        self.dismiss(animated: true, completion: nil)
    }

}

Custom view code:

class CustomView: UIView {
    
    public var snoozeButton: UIButton = {
        let button = UIButton(type: .system)
        button.isUserInteractionEnabled = true
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("Snooze", for: .normal)
        button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
        button.setTitleColor(UIColor.white, for: .normal)
        button.layer.borderWidth = 1
        button.layer.borderColor = UIColor.white.cgColor
        button.layer.cornerRadius = 14
        return button
    }()
    
    public var dismissButton: UIButton = {
        let button = UIButton(type: .system)
        button.isUserInteractionEnabled = true
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("Turn off alarm", for: .normal)
        button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
        button.setTitleColor(UIColor.white, for: .normal)
        button.backgroundColor = UIColor.clear
        button.layer.cornerRadius = 14
        button.layer.borderWidth = 2
        button.layer.borderColor = UIColor.white.cgColor
        return button
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setupUI()
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func setupUI() {
        self.backgroundColor = .systemPurple
        
        // Add subviews
        self.addSubview(snoozeButton)
        self.addSubview(dismissButton)
    }
    
    func setupConstraints() {
        
        print("Self.bounds when setting up custom view constraints is: \(self.bounds)") // <-- This gives incorrect bounds 
        
        NSLayoutConstraint.activate([
        
            dismissButton.heightAnchor.constraint(equalToConstant: 60),
            dismissButton.widthAnchor.constraint(equalToConstant: self.bounds.width * 0.7),  // <-- This is the constraint that's wrong 
            dismissButton.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            dismissButton.centerYAnchor.constraint(equalTo: self.centerYAnchor),
        
            snoozeButton.heightAnchor.constraint(equalToConstant: 60),
            snoozeButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 20),
            snoozeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
            snoozeButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -40)
    
        ])
            
    }
}

.. and finally, the underlying view controller doing the presenting:

class BlankViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.backgroundColor = .white
    }
    
    override func viewDidAppear(_ animated: Bool) {
        let customVC = CustomViewController()
        self.present(customVC, animated: true, completion: nil)
    }
}

Thanks.


Solution

  • There's no need to reference bounds -- just use a relative width constraint.

    Change this line:

    dismissButton.widthAnchor.constraint(equalToConstant: self.bounds.width * 0.7),  // <-- This gives incorrect bounds
    

    to this:

    dismissButton.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.7),
    

    Now, the button will be 70% of the view's width. As an added benefit, it will adjust itself if/when the view width changes, instead of being stuck at a calculated Constant value.