Search code examples
iosswiftanimationuiviewintrinsic-content-size

Animating from top and not center of intrinsic size


I'm trying to get my views to animate from top to bottom. Currently, when changing the text of my label, between nil and some "error message", the labels are animated from the center of its intrinsic size, but I want the regular "label" to be "static" and only animate the errorlabel. Basically the error label should be located directly below the regular label and the errorlabel should be expanded according to its (intrinsic)height. This is essentially for a checkbox. I want to show the error message when the user hasn't checked the checkbox yet, but are trying to proceed further. The code is just a basic implementation that explains the problem. I've tried adjusting anchorPoint and contentMode for the containerview but those doesn't seem to work the way I thought. Sorry if the indentation is weird

import UIKit
class ViewController: UIViewController {

    let container = UIView()
    let errorLabel = UILabel()


    var bottomLabel: NSLayoutConstraint!
    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(container)
        container.contentMode = .top
        container.translatesAutoresizingMaskIntoConstraints = false
        container.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        container.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor).isActive = true
        container.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        container.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true

        let label = UILabel()
        label.text = "Very long text that i would like to show to full extent and eventually add an error message to. It'll work on multiple rows obviously"
        label.numberOfLines = 0
        container.contentMode = .top
        container.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
        label.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor).isActive = true
        label.leadingAnchor.constraint(equalTo: container.leadingAnchor).isActive = true
        label.trailingAnchor.constraint(equalTo: container.trailingAnchor).isActive = true

        container.addSubview(errorLabel)
        errorLabel.setContentHuggingPriority(UILayoutPriority(300), for: .vertical)
        errorLabel.translatesAutoresizingMaskIntoConstraints = false
        errorLabel.topAnchor.constraint(equalTo: label.bottomAnchor).isActive = true
        errorLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor).isActive = true
        errorLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor).isActive = true

        bottomLabel = errorLabel.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor)
        bottomLabel.isActive = false

        errorLabel.numberOfLines = 0

        container.backgroundColor = .green
        let tapRecognizer = UITapGestureRecognizer()
        tapRecognizer.addTarget(self, action: #selector(onTap))
        container.addGestureRecognizer(tapRecognizer)
    }

    @objc func onTap() {

        self.container.layoutIfNeeded()
        UIView.animate(withDuration: 0.3, animations: {
            let active = !self.bottomLabel.isActive
            self.bottomLabel.isActive = active
            self.errorLabel.text = active ? "A veru very veru very veru very veru very veru very veru very veru very veru very long Error message" : nil


            self.container.layoutIfNeeded()
        })
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

Solution

  • So, since it's a control that I wanted to create (checkbox) in this case with an error message, I manipulated the frames directly, based on the bounds. So to get it to work properly, I used a combination of overriding intrinsicContentSize and layoutSubviews and some minor extra stuff. The class contains a bit more than provided, but the provided code should hopefully explain the approach I went with.

    open class Checkbox: UIView {
    
    
    
    let imageView = UIImageView()
    let textView = ThemeableTapLabel()
    private let errorLabel = UILabel()
    var errorVisible: Bool = false
    let checkboxPad: CGFloat = 8
    
    override open var bounds: CGRect {
        didSet {
            // fixes layout when bounds change
            invalidateIntrinsicContentSize()
        }
    }
    
    
    open var errorMessage: String? {
        didSet {
            self.errorVisible = self.errorMessage != nil
            UIView.animate(withDuration: 0.3, animations: {
                if self.errorMessage != nil {
                    self.errorLabel.text = self.errorMessage
                }
                self.setNeedsLayout()
                self.invalidateIntrinsicContentSize()
                self.layoutIfNeeded()
            }, completion: { success in
    
                if self.errorMessage == nil {
                    self.errorLabel.text = nil
                }
            })
        }
    }
    
    
    func checkboxSize() -> CGSize {
        return CGSize(width: imageView.image?.size.width ?? 0, height: imageView.image?.size.height ?? 0)
    }
    
    
    override open func layoutSubviews() {
        super.layoutSubviews()
    
        frame = bounds
        let imageFrame = CGRect(x: 0, y: 0, width: checkboxSize().width, height: checkboxSize().height)
        imageView.frame = imageFrame
    
        let textRect = textView.textRect(forBounds: CGRect(x: (imageFrame.width + checkboxPad), y: 0, width: bounds.width - (imageFrame.width + checkboxPad), height: 10000), limitedToNumberOfLines: textView.numberOfLines)
        textView.frame = textRect
    
    
        let largestHeight = max(checkboxSize().height, textRect.height)
        let rect = errorLabel.textRect(forBounds: CGRect(x: 0, y: 0, width: bounds.width, height: 10000), limitedToNumberOfLines: errorLabel.numberOfLines)
        //po bourect = rect.offsetBy(dx: 0, dy: imageFrame.maxY)
        let errorHeight = errorVisible ? rect.height : 0
        errorLabel.frame = CGRect(x: 0, y: largestHeight, width: bounds.width, height: errorHeight)
    
    }
    
    override open var intrinsicContentSize: CGSize {
        get {
    
            let textRect = textView.textRect(forBounds: CGRect(x: (checkboxSize().width + checkboxPad), y: 0, width: bounds.width - (checkboxSize().width + checkboxPad), height: 10000), limitedToNumberOfLines: textView.numberOfLines)
    
            let rect = errorLabel.textRect(forBounds: CGRect(x: 0, y: 0, width: bounds.width, height: 10000), limitedToNumberOfLines: errorLabel.numberOfLines)
            let errorHeight = errorVisible ? rect.height : 0
            let largestHeight = max(checkboxSize().height, textRect.height)
            return CGSize(width: checkboxSize().width + 200, height: largestHeight + errorHeight)
        }
    }
    
    
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    func setup() {
    
    //...
        addSubview(imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
    
        addSubview(textView)
        textView.translatesAutoresizingMaskIntoConstraints = false
        textView.numberOfLines = 0
        contentMode = .top
    
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(checkboxTap(sender:)))
        self.isUserInteractionEnabled = true
        self.addGestureRecognizer(tapGesture)
    
        addSubview(errorLabel)
        errorLabel.contentMode = .top
        errorLabel.textColor = .red
        errorLabel.numberOfLines = 0
    
    }
    }