iosswiftuiviewuilabelintrinsic-content-size

iOS UIView subclass gets sent the old wrong rect in draw(_ rect: CGRect) despite correct size in intrinsicContentSize


I am trying to create my own custom label as a subclass of UIView:

import UIKit

class MyLabel: UIView {
    
    private var preferredMaxLayoutWidth: CGFloat? = nil
        
    var attributedText : NSAttributedString? {
        didSet{
            setupTextKit()
            preferredMaxLayoutWidth = nil
            invalidateIntrinsicContentSize()
            setNeedsDisplay()
        }
    }
    
    private let layoutManager = NSLayoutManager()
    private let textContainer = NSTextContainer(size: .zero)
    private var textStorage : NSTextStorage?
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        print("draw!!! rect: \(rect), preferredMaxLayoutWidth: \(preferredMaxLayoutWidth)")
        
        guard let attributedText = attributedText, attributedText.length > 0 else {
            return
        }

        attributedText.draw(with: bounds, options: [.usesLineFragmentOrigin], context: nil)

    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    private func commonInit(){
        contentMode = .redraw
        backgroundColor = .red.withAlphaComponent(0.2)

        textContainer.lineFragmentPadding = 0
        textContainer.maximumNumberOfLines = 0 // 0 means no limit
        textContainer.lineBreakMode = .byWordWrapping
        textContainer.layoutManager = layoutManager
        
        layoutManager.addTextContainer(textContainer)
    }
    
    private func setupTextKit() {
        
        textStorage?.removeLayoutManager(layoutManager)
                                
        if let attributedText = attributedText {
            textStorage = NSTextStorage(attributedString: attributedText)
            textStorage?.addLayoutManager(layoutManager)
            layoutManager.textStorage = textStorage
        } else {
            textStorage = nil
        }
    }
    
    override func layoutSubviews() {
        
        super.layoutSubviews()
        
        print("layoutSubviews: \(bounds.size), \(preferredMaxLayoutWidth)")
        if preferredMaxLayoutWidth != bounds.width {
            print("preferredMaxLayoutWidth is different from bounds.width")
            preferredMaxLayoutWidth = bounds.width
            invalidateIntrinsicContentSize()
            setNeedsDisplay()
        } else {
            print("preferredMaxLayoutWidth is same as bounds.width")
        }
        
    }
    
    override var intrinsicContentSize: CGSize {
        
        guard let currentTextStorage = textStorage, currentTextStorage.length > 0 else {
            return .zero
        }
        
        var calculationWidth: CGFloat
        if let pMLW = preferredMaxLayoutWidth {
            calculationWidth = pMLW
        } else {
            calculationWidth = CGFloat.greatestFiniteMagnitude
        }

        print("preferredMaxLayoutWidth: \(preferredMaxLayoutWidth), calculationWidth: \(calculationWidth)")
        
        textContainer.size = CGSize(width: calculationWidth, height: CGFloat.greatestFiniteMagnitude)
        
        layoutManager.ensureLayout(for: textContainer)
        
        let usedRect = layoutManager.usedRect(for: textContainer)
        
        print("usedRect: \(usedRect)")
                
        return usedRect.size
    }
    
}

I am placing it in a UITableViewCell:

import UIKit
import SnapKit

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    
    let tableView = UITableView(frame: .zero, style: .plain)
    
    let records = ["Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. "]

    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.register(Cell.self, forCellReuseIdentifier: "Cell")
        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        tableView.dataSource = self
        tableView.delegate = self
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        UITableView.automaticDimension
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        records.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
        cell.label.attributedText = NSAttributedString(string: records[indexPath.row], attributes: [.foregroundColor: UIColor.label, .font: UIFont.systemFont(ofSize: 18)])
        return cell
    }


}

class Cell: UITableViewCell {
    
    let label = MyLabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        contentView.addSubview(label)
        label.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(10)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

This however doesn't render my full view:

enter image description here

Even though I have implemented my own intrinsicContentSize which does return the correct size of (420.0, 128.8828125), the draw(_ rect: CGRect) is being called with the wrong sized rect. This is observable from the print logs:

preferredMaxLayoutWidth: nil, calculationWidth: 1.7976931348623157e+308
usedRect: (0.0, 0.0, 2018.9322268366814, 21.48046875)
layoutSubviews: (420.0, 21.666666666666668), nil
preferredMaxLayoutWidth is different from bounds.width
preferredMaxLayoutWidth: Optional(420.0), calculationWidth: 420.0
usedRect: (0.0, 0.0, 420.0, 128.8828125)
draw!!! rect: (0.0, 0.0, 420.0, 21.666666666666664), preferredMaxLayoutWidth: Optional(420.0)

As you can see, in second last line, my intrinsicContentSize returns the correct size of 420.0, 128.8828125. However, in the next line, the draw function has the old size still of 420.0, 21.666666666666664.

How can I fix this?

EDIT:

Could I be running into the same problem as one described here?:

https://developer.apple.com/forums/thread/72243

EDIT 2:

Following DonMag's answer below which requires overriding the systemLayoutSizeFitting in the UITableViewCell, I was able to use method swizzling to avoid having to override systemLayoutSizeFitting in each instance of my custom cells.

Add a global swizzleStuff closure.

private var swizzleStuff: Void = {
    UITableViewCell.swizzle1()
    UITableViewCell.swizzle2()
}()

Add _ = swizzleStuff to the commonInit() of MyLabel.

extension UITableViewCell {

    fileprivate static func swizzle1() {
                
        let originalSelector = NSSelectorFromString("systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:")
        let swizzledSelector = NSSelectorFromString("swizzledSystemLayoutSizeFitting:withHorizontalFittingPriority:verticalFittingPriority:")
        
        guard
            let originalMethod = class_getInstanceMethod(self, originalSelector),
            let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)
        else { return }
        
        print("swizzle1")

        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
    
    fileprivate static func swizzle2() {
                
        let originalSelector = NSSelectorFromString("systemLayoutSizeFittingSize:")
        let swizzledSelector = NSSelectorFromString("swizzledSystemLayoutSizeFitting:")
        
        guard
            let originalMethod = class_getInstanceMethod(self, originalSelector),
            let swizzledMethod = class_getInstanceMethod(self, swizzledSelector)
        else { return }
        
        print("swizzle2")

        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
    
    @objc fileprivate func swizzledSystemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        print("Swizzled: \(type(of: self)) systemLayoutSizeFittingSize withHorizontalFittingPriority verticalFittingPriority \(targetSize)")
        setNeedsLayout()
        layoutIfNeeded()
        return swizzledSystemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
    }
    
    @objc fileprivate func swizzledSystemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
        print("Swizzled: \(type(of: self)) systemLayoutSizeFittingSize \(targetSize)")
        setNeedsLayout()
        layoutIfNeeded()
        return swizzledSystemLayoutSizeFitting(targetSize)
    }
}

Solution

  • This is a common issue with custom "self sizing" UI components in a UITableViewCell (also happens with UICollectionViewCell).

    The problem is related to the timing / order in which auto layout evaluates and sizes the cell.

    Adding cell.label.layoutIfNeeded() // add this line to your code in cellForRowAt only partially solves it.

    Note that on initial layout, we sometimes get an extra, blank line:

    partial fix

    Also note that if the cell size changes, such as on device rotation, the layout fails:

    fail 1

    and rotating back:

    fail 2

    The most reliable approach that I've found involves overriding systemLayoutSizeFitting in the cell class:

    class Cell: UITableViewCell {
        
        let label = MyLabel()
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            contentView.addSubview(label)
            
            // since I'm not using SnapKit
            label.translatesAutoresizingMaskIntoConstraints = false
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            ])
    
    //      label.snp.makeConstraints { make in
    //          make.edges.equalToSuperview().inset(10)
    //      }
        }
    
        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)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
    }
    

    Now, initial layout becomes:

    better 1

    and it behaves on size change / device rotation:

    better 2