How To: Self-Sizing Custom-View from XIB with Stac

2019-03-17 18:03发布

问题:

I've recently started diving into iOS development with Swift again and am now looking into a custom UIControl. For layout and flexibility this custom view is built using a StackView.

What I want to achieve ...

... is basically to simply have the same functionality with the StackView that I usually have when I drag it directly into the storyboard - where it resizes to fit no problem. This essentially the same as self-sizing tableview cells.

Showcase

I can reduce my CustomView to a simple example: It's a StackView with three labels in it, Alignment set to Center, Distribution Equal Centering. The StackView would be pinned to left, right and top layout guides (or trailing, leading and top). This I can easily re-create with a CustomView, loaded from XIB translate into a CustomView with the same parameters - I attached both as screenshot.

Screenshot of Simple StackView

Screenshot of CustomView

Loading the XIB File and setting it up.

import UIKit

@IBDesignable
class CustomView: UIView {

    // loaded from NIB
    private weak var view: UIView!

    convenience init() {
        self.init(frame: CGRect())
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.loadNib()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.loadNib()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // we need to adjust the frame of the subview to no longer match the size used
        // in the XIB file BUT the actual frame we got assinged from the superview
        self.view.frame = bounds
    }

    private func loadNib ()  {
        self.view = Bundle (for: type(of: self)).loadNibNamed(
            "CustomView", owner: self, options: nil)! [0] as! UIView
        self.addSubview(self.view)
    }
}

What you can see here is that I simply load the XIB file, override layout Subviews to adapt the frame to the actual view and that's it.

Questions

So my questions are related to how this is usually approached. The labels in my StackView have an intrinsic content size, i.e., usually the StackView simply adapts to the height of the labels without complaining - IF you design it in the storyboard. It doesn't though, if you put all of it in a XIB and leave the layout as it is - the IB will complain about an unknown height if the view is pinned three ways as in the screenshot (top, leading, trailing).

I read the Auto-Layout Guide and watched the WWDC videos "Mysteries of AutoLayout" but the topic is still quite new to me and I don't think I got the right approach yet. So this is where I need help

(1) Should I set constraints and disable translatesAutoresizingMaskIntoConstraints ?

What I first could and should do is to set translatesAutoresizingMaskIntoConstraints false such that I don't get auto-generated constraints and define my own constraints. So I could change loadNib into this:

private func loadNib ()  {
    self.view = Bundle (for: type(of: self)).loadNibNamed(
        "CustomView", owner: self, options: nil)! [0] as! UIView
    self.view.translatesAutoresizingMaskIntoConstraints = false
    self.addSubview(self.view)

    self.addConstraint(NSLayoutConstraint (
        item: self
        , attribute: .bottom
        , relatedBy: .equal
        , toItem: self.view
        , attribute: .bottom
        , multiplier: 1.0
        , constant: 0))

    self.addConstraint(NSLayoutConstraint (
        item: self
        , attribute: .top
        , relatedBy: .equal
        , toItem: self.view
        , attribute: .top
        , multiplier: 1.0
        , constant: 0))

    self.addConstraint(NSLayoutConstraint (
        item: self.view
        , attribute: .leading
        , relatedBy: .equal
        , toItem: self
        , attribute: .leading
        , multiplier: 1.0
        , constant: 0))

    self.addConstraint(NSLayoutConstraint (
        item: self.view
        , attribute: .trailing
        , relatedBy: .equal
        , toItem: self
        , attribute: .trailing
        , multiplier: 1.0
        , constant: 0))
}

Basically I did stretch the view to fit the full width of my UIView in the Storyboard and restrained it's top and bottom to top and bottom of the StackView.

I had tons of problems with XCode 8 crashing on me when using this one so I'm not quite sure what to make of it.

(2) Or should I override intrinsicContentSize ?

I could also simply override intrinsicContentSize by checking the height of all my arrangedSubviews and take the one that is the highest, set my view height to it:

override var intrinsicContentSize: CGSize {
    var size = CGSize.zero

    for (_ , aSubView) in (self.view as! UIStackView).arrangedSubviews.enumerated() {
        let subViewHeight = aSubView.intrinsicContentSize.height
        if  subViewHeight > size.height {
            size.height = aSubView.intrinsicContentSize.height
        }
    }

    // add some padding
    size.height += 0
    size.width = UIViewNoIntrinsicMetric
    return size
}

While this surely gives me more flexibility I'd also have to invalidate the intrinsic content size, check the Dynamic Type and lots of other stuff I'd rather avoid.

(3) Am I even loading the XIB in the "proposed" way ?

Or is there a better way, e.g. by using UINib.instantiate? I really couldn't find a best-practice way to do that.

(4) Why do I have to do this in the first place?

Really, why? I only moved the StackView into a CustomView. So why does the StackView stop adapting itself now, why do I have to set the top and bottom constraints?

What was helping the most so far is that key-phrase in https://stackoverflow.com/a/24441368 :

"In general, auto layout is performed in a top-down fashion. In other words, a parent view layout is performed first, and then any child view layouts are performed."

.. but I'm really not so sure about it.

(5) And why do I have to add the constraints in code?

Assuming I disable translatesAutoresizingMaskIntoConstraints then I can't set constraints like for tableview cells in IB? It'll always translate into something like label.top = top and thus the label would be stretched instead of the StackView inheriting the size/height. Also I can't really pin the StackView to it's container view in the IB for the XIB file.

Hope someone can help me out there. Cheers!

回答1:

Here are some answers to your questions, although in a different order:

(5) And why do I have to add the constraints in code?

Assuming I disable translatesAutoresizingMaskIntoConstraints then I can't set constraints like for tableview cells in IB? It'll always translate into something like label.top = top and thus the label would be stretched instead of the StackView inheriting the size/height. Also I can't really pin the StackView to it's container view in the IB for the XIB file.

You have to add the constraints in code, in this example, because you are loading an arbitrary view from a nib, and then adding it to a your coded CustomView. The view in the nib has no pre-existing knowledge of what context or hierarchy it will be placed into, so it can't possible defined ahead of time how to constrain itself to an unknown future superview. In the case of the StackView on the storyboard, or inside a prototype cell, that context is known as well as its parent view.

One nice alternative you can consider for this case, if you don't want to manually write constraints, is to make your CustomView subclass UIStackView instead of UIView. Since UIStackView generates appropriate constraints automatically, it will save you the manual coding. See next answer for some sample code.

(3) Am I even loading the XIB in the "proposed" way ?

Or is there a better way, e.g. by using UINib.instantiate? I really couldn't find a best-practice way to do that.

I'm not aware of a "standard" or "right" way to handle loading a nib into a custom view. I've seen a variety of approaches that all work. However, I will note that you are doing more work than needed in your sample code. Here is a more minimal way of accomplishing the same outcome, but also using a UIStackView subclass instead of a UIView subclass (as mentioned above):

class CustomView: UIStackView {
    required init(coder: NSCoder) {
        super.init(coder: coder)
        distribution = .fill
        alignment = .fill
        if let nibView = Bundle.main.loadNibNamed("CustomView", owner: self, options: nil)?.first as? UIView {
            addArrangedSubview(nibView)
        }
    }
}

Note that because CustomView is now a subclass of UIStackView, it will create the constraints needed for any subviews automatically. In this case, because distribution and alignment are both set to .fill, it will pin the edges o the subview to its own edges, as you want.

(4) Why do I have to do this in the first place?

Really, why? I only moved the StackView into a CustomView. So why does the StackView stop adapting itself now, why do I have to set the top and bottom constraints?

Actually, the key issue here is your StackView's alignment property of .center. That is saying that the labels in StackView will be centered vertically, but it does not constrain them to the top or the bottom of the StackView. It's just like adding a centerY constraint to a subview inside a view. You can set the outer view to any height you want, and the subview will be centered, but it does not "push out" the top and bottom of the outer view, because center constraints only constrain centers, not top and bottom edges.

In your storyboard version of the StackView, the view hierarchy above that StackView is known, and there are also options to generate constraints automatically from resizing masks. Either way, the final StackView heigh is known to UIKit and the labels will be centered vertically within that height, but will not actually effect it. You need to either set the alignment of your StackView to be .fill, or add additional constraints from the tops and bottoms of the labels to the top and bottom of the StackView in order for auto layout to determine how tall the StackView should be.

(2) Or should I override intrinsicContentSize ?

This wouldn't help much, because the intrinsic content size by itself won't push out the edges of a parent view unless the edges of the subview are also constrained to the edges of the parent view. In any event, you will get all the correct intrinsic content size behavior you need in this case automatically, so long as you address the .center alignment issue mentioned above.

(1) Should I set constraints and disable translatesAutoresizingMaskIntoConstraints ?

This would normally be a valid approach. If you subclass UIStackView as I describe above, though, you don't need to do either.

In summary

If you:

  1. Create your CustomView as a subclass of UIStackView as in the sample code above

  2. Create a view on your storyboard set to your custom class CustomView. If you want the CustomView to self-size correctly, you need to give it constraints and not use auto-generated constraints from the resizing mask.

  3. Change the StackView in your nib to either have the alignment set to .fill or to have additional constraints between the top and bottom of at least one of the labels (most likely the large center one) and the top and bottom of the stack view

Then auto layout should work properly and your custom view with the StackView should layout as expected.