Search code examples
iosswiftuilabelswift5

How to update a UILabel width dynamically without overloading the CPU


Labels in iOS are create like (1), no horizontal margin and no beauty at all. I would like to create a label like in (2), curved edges and a margin left and right

enter image description here

The contents of this label is updated 2 times per second and its width must change dynamically.

So I have created this class

@IBDesignable
class BeautifulLabel : UILabel {
  
//  private var internalRect : CGRect? = .zero

  override func drawText(in rect: CGRect) {
    let insets = UIEdgeInsets(top: marginTop,
                              left: marginLeft,
                              bottom: marginBottom,
                              right: marginRight)
    super.drawText(in: rect.inset(by: insets))
  }
  
  
  @IBInspectable var cornerRadius: CGFloat = 0 {
    didSet {
      self.layer.cornerRadius = cornerRadius
      self.layer.masksToBounds = cornerRadius > 0
    }
  }
  
  @IBInspectable var marginTop: CGFloat = 0
  @IBInspectable var marginBottom: CGFloat = 0
  @IBInspectable var marginLeft: CGFloat = 0
  @IBInspectable var marginRight: CGFloat = 0

  override func layoutSubviews() {
    super.layoutSubviews()
    var bounds = self.bounds
    bounds.size.width += marginLeft + marginRight
    bounds.size.height += marginTop + marginBottom
    self.bounds = bounds
  }

This works but adjusting self.bounds inside layoutSubviews(), makes this method to be called again, resulting in a huge loop, CPU spike and memory leak.

Then I tried this:

override var text: String? {
    didSet {
      let resizingLabel = UILabel(frame: self.bounds)
      resizingLabel.text = self.text
      
      var bounds = resizingLabel.textRect(forBounds: CGRect(x: 0, y: 0, width: 500, height: 50), limitedToNumberOfLines: 1)
      
      bounds.size.width += marginLeft + marginRight
      bounds.size.height += marginTop + marginBottom
      self.bounds = bounds
    }
  }

this simply does not work. Label is not adjusted to the proper size.

The label must have just one line, fixed height, truncated tail and fixed font size (System 17). I am interested in its width.

Any ideas?


Solution

  • A view should not change its own size. It should only change its intrinsicContentSize.

    When you add a view to the view hierarchy, that’s when you specify whether it should observe the intrinsic content size or not (e.g. content hugging settings, compression resistance, absence of explicit width and height constraints, etc.). If you do this, the auto layout engine will do everything for you.

    So, by way of example, a minimalist approach would be something that just overrides intrinsicContentSize:

    @IBDesignable
    class BeautifulLabel: UILabel {
        @IBInspectable var marginX: CGFloat = 0       { didSet { invalidateIntrinsicContentSize() } }
        @IBInspectable var marginY: CGFloat = 0       { didSet { invalidateIntrinsicContentSize() } }
        @IBInspectable var cornerRadius: CGFloat = 0  { didSet { layer.cornerRadius = cornerRadius } }
    
        override var intrinsicContentSize: CGSize {
            let size = super.intrinsicContentSize
            return CGSize(width: size.width + marginX * 2, height: size.height + marginY * 2)
        }
    }
    

    A more complete example might be a UIView subclass, where the label is a subview, inset by the appropriate margins:

    @IBDesignable
    class BeautifulLabel: UIView {
        @IBInspectable var marginTop: CGFloat = 0       { didSet { didUpdateInsets() } }
        @IBInspectable var marginBottom: CGFloat = 0    { didSet { didUpdateInsets() } }
        @IBInspectable var marginLeft: CGFloat = 0      { didSet { didUpdateInsets() } }
        @IBInspectable var marginRight: CGFloat = 0     { didSet { didUpdateInsets() } }
    
        @IBInspectable var cornerRadius: CGFloat = -1   { didSet { setNeedsLayout() } }
        
        @IBInspectable var text: String? {
            get {
                label.text
            }
            
            set {
                label.text = newValue
                invalidateIntrinsicContentSize()
            }
        }
    
        @IBInspectable var font: UIFont? {
            get {
                label.font
            }
            
            set {
                label.font = newValue
                invalidateIntrinsicContentSize()
            }
        }
    
        private var topConstraint: NSLayoutConstraint!
        private var leftConstraint: NSLayoutConstraint!
        private var rightConstraint: NSLayoutConstraint!
        private var bottomConstraint: NSLayoutConstraint!
    
        private let label: UILabel = {
            let label = UILabel()
            label.translatesAutoresizingMaskIntoConstraints = false
            return label
        }()
        
        override var intrinsicContentSize: CGSize {
            let size = label.intrinsicContentSize
            return CGSize(width: size.width + marginLeft + marginRight,
                          height: size.height + marginTop + marginBottom)
        }
    
        override init(frame: CGRect = .zero) {
            super.init(frame: frame)
            configure()
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            configure()
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let maxCornerRadius = min(bounds.width, bounds.height) / 2
    
            if cornerRadius < 0 || cornerRadius > maxCornerRadius {
                layer.cornerRadius = maxCornerRadius
            } else {
                layer.cornerRadius = cornerRadius
            }
        }
    }
    
    private extension BeautifulLabel {
        func configure() {
            addSubview(label)
            
            topConstraint = label.topAnchor.constraint(equalTo: topAnchor, constant: marginTop)
            leftConstraint = label.leftAnchor.constraint(equalTo: leftAnchor, constant: marginLeft)
            rightConstraint = rightAnchor.constraint(equalTo: label.rightAnchor, constant: marginRight)
            bottomConstraint = bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: marginBottom)
            
            NSLayoutConstraint.activate([leftConstraint, rightConstraint, topConstraint, bottomConstraint])
        }
        
        func didUpdateInsets() {
            topConstraint.constant = marginTop
            leftConstraint.constant = marginLeft
            rightConstraint.constant = marginRight
            bottomConstraint.constant = marginBottom
            
            invalidateIntrinsicContentSize()
        }
    }
    

    Now in this case, I'm only exposing text and font, but you'd obviously repeat for whatever other properties you want to expose.

    But let’s not get lost in the details of the above implementation. The bottom line is that a view should not attempt to adjust its own size, but rather merely its own intrinsicContentSize. And it should perform invalidateIntrinsicContentSize where necessary.