简体   繁体   中英

How to correctly use intrincSize in custom UIView. Where/when to calculate and to update the size?

I have been trying to understand and utilize intrinsicSize on a custom UIView for some days now. So far with little success.

The post is quite long, sorry for that:-) Problem is, that the topic is quite complex. While I know that there might be other solutions, I simply want to understand how intrinsicSize can be used correctly.

So when someone knows a good source for a in depth explanation on how to use / implement intrinsicSize you can skip all my questions and just leave me link.

My goal:

Create a custom UIView which uses intrinsicSize to let AutoLayout automatically adopt to different content. Just like a UILabel which automatically resizes depending on its text content, font, font size, etc.

As an example assume a simple view RectsView which does nothing but drawing a given number of rects of a given size with given spacing. If not all rects fit into a single row, the content is wrapped and drawing is continued in another row. Thus the height of the view depends on the different properties (number of rects, rects size, spacing, etc.)

This is very much like a UILabel but instead of words or letters simple rects are drawn. However, while UILabel works perfectly I was not able to achive the same for my RectsView .

Why intrinsicSize

As @DonMag pointed out in his excellent answers to my previous question, I do not have to use intrinsicSize to achieve my goal. I could also use subviews and add constraints to create such a rect pattern. Or I could use a UICollectionView , etc.

While this might certainly work, I think it would add a lot of overhead. If the goal would be to recreate a UILabel class, one would not use AutoLayout or a CollectionView to arrange the letters to words, would one? Instead one would certainly try to draw the letters manually... Especially when using the RectsView in a TableView or a CollectionView a plain view with direct drawing is certainly better than a complex solution compiled of tons of subviews arranged using AutoLayout.

Of course this is an extreme example. However, at the bottom line there are cases where using intrinsicSize is certainly the better option. Since UILabel and other build in views uses intrinsicSize perfectly, there has to be a way to get this working and I just want to know how:-)

My understanding of intrinsic Size

@DonMag suspected, that "I do not really understanding what intrinsicContentSize does and does not do" . He is most likely correct:-) However, the problem is that I found no source which really explains it... Thus I have spend several hours trying to understand how to correctly use intrinsicSize without little progress.

This is what I have learned from the docs :

  • intrinsicSize is a feature used in AutoLayout . Views which offer an intrinsic height and/or width do not need to specify constraints for these values.
  • There is no guarantee that the view will exactly get its intrinsicSize . It is more like a way to tell autoLayout which size would be best for the view while autoLayout will calculate the actual size.
  • The calculation is done using the intrinsicSize and the Compression Resistance + Content Hugging properties.
  • The calculation of the intrinsicSize should only depend on the content, not of the views frame.

What I do not understand:

  • How can the calculation be independend from the views frame? Of course the UIImageView can use the size of its image but the height of a UILabel can obviously only be calculated depending on its content AND its width. So how could my RectsView calculate its height without considering the frames width?
  • When should the calculation of the intrinsicSize happen? In my example of the RectsView the size depends on rect size, spacing and number. In a UILabel the size also depends on multiple properties like text, font, font size, etc. If the calculation is done when setting each property it will be performed multiple times which is quite inefficient. So what is the right place to do it?

Example implementation:

Here is a simple implementation of my RectsView :

@IBDesignable class RectsView: UIView {
    
    // Properties which determin the intrinsic height
    @IBInspectable public var rectSize: CGFloat = 20 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }
    
    @IBInspectable public var rectSpacing: CGFloat = 10 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }
    
    @IBInspectable public var rowSpacing: CGFloat = 5 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }
    
    @IBInspectable public var rectsCount: Int = 20 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }
    
    
    // Calculte the content and its height
    private var rects = [CGRect]()
    func calcContent() {
        var x: CGFloat = 0
        var y: CGFloat = 0
        
        rects = []
        if rectsCount > 0 {
            for _ in 0..<rectsCount {
                let rect = CGRect(x: x, y: y, width: rectSize, height: rectSize)
                rects.append(rect)
                
                x += rectSize + rectSpacing
                if x + rectSize > frame.width {
                    x = 0
                    y += rectSize + rowSpacing
                }
            }
        }
        
        height = y + rectSize
        invalidateIntrinsicContentSize()
    }
    
    
    // Intrinc height
    @IBInspectable var height: CGFloat = 50
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: height)
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        invalidateIntrinsicContentSize()
    }
    
    
    // Drawing
    override func draw(_ rect: CGRect) {
        super.draw(rect)
                
        let context = UIGraphicsGetCurrentContext()
        
        for rect in rects {
            context?.setFillColor(UIColor.red.cgColor)
            context?.fill(rect)
        }
        
        
        let attrs = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: UIFont.systemFontSize)]
        let text = "\(height)"
        
        text.draw(at: CGPoint(x: 0, y: 0), withAttributes: attrs)
    }
}

class ViewController: UITableViewController {
    let CellId = "CellId"

    // Dummy content with different values per row
    var data: [(CGFloat, CGFloat, CGFloat, Int)] = [
        (10.0, 15.0, 13.0, 35),
        (20.0, 10.0, 16.0, 28),
        (30.0, 5.0, 19.0, 21)
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UINib(nibName: "IntrinsicCell", bundle: nil), forCellReuseIdentifier: CellId)
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 3
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: CellId, for: indexPath) as? IntrinsicCell ?? IntrinsicCell()
        
        // Dummy content with different values per row
        cell.rectSize = data[indexPath.row].0     // CGFloat((indexPath.row+1) * 10)
        cell.rectSpacing = data[indexPath.row].1  // CGFloat(20 - (indexPath.row+1) * 5)
        cell.rowSpacing = data[indexPath.row].2   // CGFloat(10 + (indexPath.row+1) * 3)
        cell.rectsCount = data[indexPath.row].3   // (5 - indexPath.row) * 7
        
        return cell
    }

    // Add/remove content when tapping on a row
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        var rowData = data[indexPath.row]
        rowData.3 = (indexPath.row % 2 == 0 ? rowData.3 + 5 : rowData.3 - 5)
        data[indexPath.row] = rowData
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
}


// Simple cell holing an intrinsic rectsView
class IntrinsicCell: UITableViewCell {
    @IBOutlet private var rectsView: RectsView!
        
    var rectSize: CGFloat {
        get { return rectsView.rectSize }
        set { rectsView.rectSize = newValue }
    }
    
    var rectSpacing: CGFloat {
        get { return rectsView.rectSpacing }
        set { rectsView.rectSpacing = newValue }
    }
    
    var rowSpacing: CGFloat {
        get { return rectsView.rowSpacing }
        set { rectsView.rowSpacing = newValue }
    }
    
    var rectsCount: Int {
        get { return rectsView.rectsCount }
        set { rectsView.rectsCount = newValue }
    }
} 

Problems:

Basicly the intrinsicSize works fine: When the TableView is rendered for the first time each row has a different height depending on its intrinsic content. However, the size is not correctly, since the intrinsicSize is calculated before the TableView actually layouts its subviews. Thus the RectsView s calculate their content size using the default width instead of there acutal, final width.

So: When/Where to calculate the initial layout?

Additionally updates to the properties (= tapping on the cells) are not handled correctly

So: When/Where to calculate the updated layout?

在此处输入图像描述

Again: Sorry for the long post and thank you very much if you have managed to read until here. I really appreciate that!

I you know any good source / tutorial / howto which explains all this, I happy about any link you can provide!

UPDATE: Some more observations

I have added some debug output to my RectsView and to a UILabel subclass to see how intrinsicContentSize is used.

In RectsView intrinsicContentSize is called only once before the bounds are set to their final size. Since at this point I not not know the final size yet, I can only calculate the intrinsic size based on the old, outdated width which leads to a wrong result.

In UIView however, intrinsicContentSize is called multiple times ( why? ) and in the last call, the result seems to be fitting the upcoming, final size. How can this size be known at this point?

RectsView willSet frame: (-120.0, -11.5, 240.0, 23.0)
RectsView didSet frame: (40.0, 11.0, 240.0, 23.0)
RectsView didSet rectSize: 10.0
RectsView didSet rectSpacing: 15.0
RectsView didSet rowSpacing: 20
RectsView didSet rectsCount: 35
RectsView get intrinsicContentSize: 79.0
RectsView willSet bounds: (0.0, 0.0, 240.0, 23.0)
RectsView didSet bounds: (0.0, 0.0, 350.0, 79.33333333333333)
RectsView layoutSubviews
RectsView layoutSubviews

MyLabel willSet frame: (-116.5, -9.5, 233.0, 19.0)
MyLabel didSet frame: (53.0, 13.0, 233.0, 19.0)
MyLabel willSet text: (53.0, 13.0, 233.0, 19.0)
MyLabel didSet text: (53.0, 13.0, 233.0, 19.0)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (675.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel willSet bounds: (0.0, 0.0, 233.0, 19.0)
MyLabel didSet bounds: (0.0, 0.0, 350.0, 41.0)
MyLabel layoutSubviews
MyLabel layoutSubviews

When called for the first time UILabel returns a intrinsic width of 65536.0 is this some constant? I am only aware of UIView.noIntrinsicMetric = -1 which specifies, that the view does not have a instrinsic size in the given dimension.

Why, is intrinsicContentSize called multiple times on UILabel ? I tried to return the same size (65536.0, 20.333333333333332) in RectsView but this does not make any difference, intrinsicContentSize is still only called once.

In the last call to intrinsicContentSize of UILabel the value (338.0, 40.666666666666664) is returend. It seems that UILabel know at this point, that it will be re-sized to a width of 350 , but how?

Another oberservation is, that on both views intrinsicContentSize is NOT called after bounds.didSet . Thus there has to be a way to know the upcomming frame changes in intrinsicContentSize before bounds.didSet . How?

UPDATE 2:

I have added debug output to following, other UILabel methods as well, but they are not called (thus seem not to influence the problem):

sizeToFit()
sizeThatFits(_ :)
contentCompressionResistancePriority(for :)
contentHuggingPriority(for :)
systemLayoutSizeFitting(_ :)
systemLayoutSizeFitting(_ :, withHorizontalFittingPriority :, verticalFittingPriority:)
preferredMaxLayoutWidth  get + set

As @DonMag suggested I used a Technical Support Incident to get a feedback directly from an Apple engineer. It took some time to get an answer but eventually it arrived:

Long story short: Do not use intrinsic size

It seems that intrinsic size is more a less an internal feature which is not intended to be used by custom views. Of course it is not a private API and can be uses. However, without (many) being uses along side with other private iOS features it is pretty limited and can hardly be used.

Apple suggested to use AutoLayout and constraints instead. Even for such a basic example as a custom UILabel clone.

Came across this question: UITableViewCell with intrinsic height based on width which seems to be a similar issue.

That solution was to override systemLayoutSizeFitting(...) in the cell class. Seems to work for your case -- with a necessary change in RectsView to re-calculate when bounds changes.

See how this behaves for you:

@IBDesignable class RectsView: UIView {
    
    // calcContent needs to be called
    //  when any of these values are changed
    
    // Properties which determin the intrinsic height
    @IBInspectable public var rectSize: CGFloat = 20 {
        didSet {
            calcContent(bounds.width)
        }
    }
    
    @IBInspectable public var rectSpacing: CGFloat = 10 {
        didSet {
            calcContent(bounds.width)
        }
    }
    
    @IBInspectable public var rowSpacing: CGFloat = 5 {
        didSet {
            calcContent(bounds.width)
        }
    }
    
    @IBInspectable public var rectsCount: Int = 20 {
        didSet {
            calcContent(bounds.width)
        }
    }
    
    // Calculte the content and its height
    private var rects = [CGRect]()
    
    // using newWidth parameter allows us to
    //  pass the new bounds width when
    //  the bounds is set
    func calcContent(_ newWidth: CGFloat) {
        var x: CGFloat = 0
        var y: CGFloat = 0
        
        rects = []
        if rectsCount > 0 {
            for i in 0..<rectsCount {
                let rect = CGRect(x: x, y: y, width: rectSize, height: rectSize)
                rects.append(rect)
                
                x += rectSize + rectSpacing
                if x + rectSize > newWidth && i < rectsCount - 1 {
                    x = 0
                    y += rectSize + rowSpacing
                }
            }
        }
        
        height = y + rectSize
        
        // need to trigger draw()
        setNeedsDisplay()
    }
    
    // Intrinsic height
    @IBInspectable var height: CGFloat = 10 {
        didSet {
            invalidateIntrinsicContentSize()
        }
    }
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: height)
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        invalidateIntrinsicContentSize()
    }
    
    // Drawing
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        let context = UIGraphicsGetCurrentContext()
        
        for rect in rects {
            context?.setFillColor(UIColor.red.cgColor)
            context?.fill(rect)
        }
        
        let attrs = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: UIFont.systemFontSize)]
        let text = "\(height)"
        
        text.draw(at: CGPoint(x: 0, y: 0), withAttributes: attrs)
    }
    
    override var bounds: CGRect {
        willSet {
            calcContent(newValue.width)
        }
    }
}

// Simple cell holing an intrinsic rectsView
class IntrinsicCell: UITableViewCell {
    @IBOutlet var rectsView: RectsView!
    
    var rectSize: CGFloat {
        get { return rectsView.rectSize }
        set { rectsView.rectSize = newValue }
    }
    
    var rectSpacing: CGFloat {
        get { return rectsView.rectSpacing }
        set { rectsView.rectSpacing = newValue }
    }
    
    var rowSpacing: CGFloat {
        get { return rectsView.rowSpacing }
        set { rectsView.rowSpacing = newValue }
    }
    
    var rectsCount: Int {
        get { return rectsView.rectsCount }
        set { rectsView.rectsCount = newValue }
    }
    
    override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        //force layout of all subviews including RectsView, which
        //updates RectsView's intrinsic height, and thus height of a cell
        self.setNeedsLayout()
        self.layoutIfNeeded()
        
        //now intrinsic height is correct, so we can call super method
        return super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
    }
    
}

class ViewController: UITableViewController {
    let CellId = "CellId"
    
    // Dummy content with different values per row
    var data: [(CGFloat, CGFloat, CGFloat, Int)] = [
        (10.0, 15.0, 13.0, 35),
        (20.0, 10.0, 16.0, 28),
        (30.0, 5.0, 19.0, 21),
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //tableView.register(UINib(nibName: "IntrinsicCell", bundle: nil), forCellReuseIdentifier: CellId)
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let cell = tableView.dequeueReusableCell(withIdentifier: CellId, for: indexPath) as! IntrinsicCell
        
        // Dummy content with different values per row
        cell.rectSize = data[indexPath.row].0     // CGFloat((indexPath.row+1) * 10)
        cell.rectSpacing = data[indexPath.row].1  // CGFloat(20 - (indexPath.row+1) * 5)
        cell.rowSpacing = data[indexPath.row].2   // CGFloat(10 + (indexPath.row+1) * 3)
        cell.rectsCount = data[indexPath.row].3   // (5 - indexPath.row) * 7
        
        return cell
        
    }
    
    // Add/remove content when tapping on a row
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        var rowData = data[indexPath.row]
        rowData.3 = (indexPath.row % 2 == 0 ? rowData.3 + 5 : rowData.3 - 5)
        data[indexPath.row] = rowData
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
} 

Edit

Yes, that would be a work-around... although, if it solves the issue, maybe not such a bad thing.

As you've noted, UILabel can re-calculate its intrinsicContentSize correctly based on the final width -- which doesn't seem to be available in time for the way this code is written.

Take a look at UILabel.h (search for iOS runtime headers), and you'll see a lot of things "under-the-hood" that we're not privy to.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM