Search code examples
swiftxcodeautolayoutclosuresswift-playground

Why would AutoLayout not respect constraints if an initial frame is specified for the target view?


We have a weird case where our view is not appearing as expected and we've traced it to AutoLayout and some sequence of events that we can't see why it would change things.

To simplify the example, here's some test code you can run directly in an Xcode playground. (We're using Xcode 11.3.1 if it matters.)

First, we define an InlineConfigure operator, like so...

// 'InlineConfigure' operator
infix operator ~> : AssignmentPrecedence

@discardableResult
public func ~> <T>(item:T, _ configure:(T)->Void) -> T {
    configure(item)
    return item
}

Next, to illustrate it's not a case of something being deallocated when not expected, we create a UIView subclass specifically to log that...

class SomeView : UIView {

    override init(frame: CGRect) {
        super.init(frame:frame)
        print("SomeView created")
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    deinit {
        print("SomeView deinitialized")
    }
}

With both of the above in place, take a look at this code. It works as expected.

class TestViewController : UIViewController {

    let margin:CGFloat = 24

    override func loadView() {

        view = UIView()
        view.backgroundColor = .yellow

        SomeView()~>{

            $0.backgroundColor = .blue
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)

            NSLayoutConstraint.activate([
                $0.leftAnchor.constraint(equalTo: view.leftAnchor, constant: margin),
                $0.topAnchor.constraint(equalTo: view.topAnchor, constant: margin),
                $0.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -margin),
                $0.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -margin)
            ])
        }
    }
}

PlaygroundPage.current.liveView = TestViewController()

If we however change the line that creates SomeView to take in a frame that has a non-zero area (e.g. both a width and a height) then that same code no longer works and you don't see the blue view at all.

override func loadView() {

    view = UIView()
    view.backgroundColor = .yellow

    SomeView(frame:CGRect(x: 0, y: 0, width: 20, height: 20))~>{

        $0.backgroundColor = .blue
        $0.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview($0)

        NSLayoutConstraint.activate([
            $0.leftAnchor.constraint(equalTo: view.leftAnchor, constant: margin),
            $0.topAnchor.constraint(equalTo: view.topAnchor, constant: margin),
            $0.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -margin),
            $0.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -margin)
        ])
    }
}

Setting the frame to have a zero area and it works again...

SomeView(frame:CGRect(x: 0, y: 0, width: 0, height: 20))~>{

    [...]

}

But back to the non-zero frame, if you move the constraint-setting code to outside of the configuration closure, then it always works regardless of the frame having an area or not!

override func loadView() {

    view = UIView()
    view.backgroundColor = .yellow

    let someView = SomeView(frame:CGRect(x: 0, y: 0, width: 20, height: 20))~>{

        $0.backgroundColor = .blue
        $0.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview($0)
    }

    NSLayoutConstraint.activate([
        someView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: margin),
        someView.topAnchor.constraint(equalTo: view.topAnchor, constant: margin),
        someView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -margin),
        someView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -margin)
    ])
}

What's confusing us is in the earlier code, $0 inside the closure is a strong reference to the newly-created view (and the parent also holds a reference to it when it was added as a subview so it's not unloading), and that exact same reference is assigned to someView on the outside where it then executes the exact same code that used to be in the closure. There is nothing in between the closure and the code on the outside except that assignment, yet it's giving different results! But why?

Can anyone explain why we're seeing this behavior? What are we missing?


Solution

  • I'd say it's because playgrounds are the work of the devil. I ran your code in a real iOS app project and it worked as expected even with

    SomeView(frame:CGRect(x: 0, y: 0, width: 20, height: 20))
    

    Basically I wouldn't expect a playground to go through all the lifetime stages of a view controller correctly.