UILabel constraint animation bug

When animating a change in width or height constraint .constant on a UILabel, the animation only works if the constant value is larger. If the value is smaller, the label "snaps" to its new size instead of animating.

Quick, clear example... two views (green) and two labels (cyan). Tapping anywhere will toggle the respective height / width constraint constants between 300 and 100.

The green views animate smoothly whether "shrinking" or "growing."

The cyan labels only animate when "growing" ... when "shrinking" they "snap" to the new constant value:

import UIKit

class ViewController: UIViewController {

	let testHeightLabel: UILabel = {
		let v = UILabel()
		v.text = "Height"
		v.backgroundColor = .cyan
		return v
	}()

	let testHeightView: UIView = {
		let v = UIView()
		v.backgroundColor = .green
		return v
	}()

	let testWidthLabel: UILabel = {
		let v = UILabel()
		v.text = "Width"
		v.backgroundColor = .cyan
		return v
	}()

	let testWidthView: UIView = {
		let v = UIView()
		v.backgroundColor = .green
		return v
	}()

	var testViewHeightConstraint: NSLayoutConstraint!
	var testLabelHeightConstraint: NSLayoutConstraint!

	var testViewWidthConstraint: NSLayoutConstraint!
	var testLabelWidthConstraint: NSLayoutConstraint!

	override func viewDidLoad() {
		super.viewDidLoad()

		[testHeightView, testHeightLabel, testWidthView, testWidthLabel].forEach { v in
			v.translatesAutoresizingMaskIntoConstraints = false
			view.addSubview(v)
		}

		testViewHeightConstraint = testHeightView.heightAnchor.constraint(equalToConstant: 300.0)
		testLabelHeightConstraint = testHeightLabel.heightAnchor.constraint(equalToConstant: 300.0)

		testViewWidthConstraint = testWidthView.widthAnchor.constraint(equalToConstant: 300.0)
		testLabelWidthConstraint = testWidthLabel.widthAnchor.constraint(equalToConstant: 300.0)

		let g = view.safeAreaLayoutGuide

		NSLayoutConstraint.activate([

			// constrain testHeightView Top / Leading / Width
			testHeightView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
			testHeightView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 80.0),
			testHeightView.widthAnchor.constraint(equalToConstant: 60.0),

			// constrain testHeightLabel Top / Leading / Width
			testHeightLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
			testHeightLabel.leadingAnchor.constraint(equalTo: testHeightView.trailingAnchor, constant: 40.0),
			testHeightLabel.widthAnchor.constraint(equalToConstant: 60.0),

			// constrain testWidthView Top / Leading / Height
			testWidthView.topAnchor.constraint(equalTo: g.topAnchor, constant: 360.0),
			testWidthView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
			testWidthView.heightAnchor.constraint(equalToConstant: 40.0),

			// constrain testWidthLabel Top / Leading / Height
			testWidthLabel.topAnchor.constraint(equalTo: testWidthView.bottomAnchor, constant: 20.0),
			testWidthLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
			testWidthLabel.heightAnchor.constraint(equalToConstant: 40.0),

			// activate height and width constraints
			testViewHeightConstraint, testLabelHeightConstraint,
			testViewWidthConstraint, testLabelWidthConstraint,

		])

		let instructionLabel = UILabel()
		instructionLabel.text = "Tap to toggle height and width constraint constants.\nGreen Views smoothly animate to the new height and width.\nCyan Labels only smoothly animate when \"growing\"."
		instructionLabel.numberOfLines = 0
		instructionLabel.textAlignment = .center
		instructionLabel.translatesAutoresizingMaskIntoConstraints = false
		view.addSubview(instructionLabel)
		NSLayoutConstraint.activate([
			instructionLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
			instructionLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
			instructionLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
		])

	}

	override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

		// toggle height constraint constants between 300 and 100

		testViewHeightConstraint.constant = testViewHeightConstraint.constant == 300.0 ? 100.0 : 300.0
		testLabelHeightConstraint.constant = testLabelHeightConstraint.constant == 300.0 ? 100.0 : 300.0

		// toggle width constraint constants between 300 and 100
		testViewWidthConstraint.constant = testViewWidthConstraint.constant == 300.0 ? 100.0 : 300.0
		testLabelWidthConstraint.constant = testLabelWidthConstraint.constant == 300.0 ? 100.0 : 300.0

		// animate the constraint change
		UIView.animate(withDuration: 1.5, animations: { self.view.layoutIfNeeded() })

	}

}

It's possible I'm missing something obvious?

Note that the "snap" does not occur if the label is embedded in a container and the constraint on the container is animated - but that would be an undesirable workaround.

So, just looking for confirmation of this being a "bug."

UILabel uses drawRect: to draw its contents, so when you animate it smaller a new back buffer is created at the smaller size. Since this change of back buffer does not animate, you get an immediate clip of the label contents (because now there is less content to display) when going smaller. In the other direction the back buffer is getting larger, and so the animation can smoothly reveal the new content.

The solution for this is to keep the label a constant size during the animation, and use another view to clip out the label (e.g. animate that view's size). You can choose if you want to resize the label at the end of the animation.

UILabel constraint animation bug
 
 
Q