As I mentioned in my comments, this is what I would call a BUG.
When we animate the height of a UILabel
:
- if it's getting taller, no problem
- if it's getting shorter, it snaps to the shorter height
Quick demonstration:
class V1_LabelHeightAnimVC: UIViewController {
let testLabel = UILabel()
let testView = UIView()
let embeddedLabelView = UIView()
var tlh: NSLayoutConstraint!
var tvh: NSLayoutConstraint!
var elvh: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
testLabel.text = "ABC"
testLabel.textColor = .yellow
testLabel.textAlignment = .center
testView.backgroundColor = .red
testLabel.backgroundColor = .blue
testView.translatesAutoresizingMaskIntoConstraints = false
testLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(testView)
view.addSubview(testLabel)
let v = UILabel()
v.backgroundColor = .yellow
v.text = "ABC"
embeddedLabelView.backgroundColor = .systemBlue
v.translatesAutoresizingMaskIntoConstraints = false
embeddedLabelView.addSubview(v)
embeddedLabelView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(embeddedLabelView)
tvh = testView.heightAnchor.constraint(equalToConstant: 300.0)
tlh = testLabel.heightAnchor.constraint(equalToConstant: 300.0)
elvh = embeddedLabelView.heightAnchor.constraint(equalToConstant: 300.0)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
testView.widthAnchor.constraint(equalToConstant: 60.0),
testLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
testLabel.leadingAnchor.constraint(equalTo: testView.trailingAnchor, constant: 40.0),
testLabel.widthAnchor.constraint(equalToConstant: 60.0),
v.centerXAnchor.constraint(equalTo: embeddedLabelView.centerXAnchor),
v.centerYAnchor.constraint(equalTo: embeddedLabelView.centerYAnchor),
embeddedLabelView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
embeddedLabelView.leadingAnchor.constraint(equalTo: testLabel.trailingAnchor, constant: 40.0),
embeddedLabelView.widthAnchor.constraint(equalToConstant: 60.0),
tvh, tlh, elvh,
])
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
tvh.constant = tvh.constant == 300.0 ? 100.0 : 300.0
tlh.constant = tlh.constant == 300.0 ? 100.0 : 300.0
elvh.constant = elvh.constant == 300.0 ? 100.0 : 300.0
UIView.animate(withDuration: 1.0, animations: {
self.view.layoutIfNeeded()
})
}
}
It looks like this when running:

Tapping anywhere will toggle the Height constraint constants between 300 and 100 and animate to the new values.
- the Red rectangle is a
UIView
... it animates as expected
- the dark Blue rectangle is a
UILabel
... you'll see it snap
- the light Blue rectangle is a
UIView
with a UILabel
as a subview. It gives us the desired animations.
Here's an example to achieve your layout, using a simple UIView
subclass to hold the "centered" labels:
class EmbeddedLabelView: UIView {
var text: String = "" {
didSet {
label.text = text
}
}
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() {
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
label.widthAnchor.constraint(equalTo: widthAnchor),
])
}
}
and an example controller:
class V2_LabelHeightAnimVC: UIViewController {
let container = UIView()
var heightConstraints: [NSLayoutConstraint] = []
let testLabel = UILabel()
let testView = UIView()
var tlh: NSLayoutConstraint!
var tvh: NSLayoutConstraint!
var pcts: [[CGFloat]] = [
[25, 25, 25, 25],
[20, 10, 40, 30],
[10, 50, 30, 20],
[15, 15, 40, 30],
]
var idx: Int = 0
let infoLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
//view.backgroundColor = .systemYellow
let colors: [UIColor] = [
.init(red: 1.0, green: 0.8, blue: 0.8, alpha: 1.0),
.init(red: 0.8, green: 1.0, blue: 0.8, alpha: 1.0),
.init(red: 0.8, green: 0.8, blue: 1.0, alpha: 1.0),
.init(red: 0.9, green: 0.9, blue: 0.6, alpha: 1.0),
]
var prevView: UIView!
for i in 0..<colors.count {
let label = EmbeddedLabelView()
label.backgroundColor = colors[i]
label.text = "\(Int(pcts[0][i]))"
label.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor),
label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
label.widthAnchor.constraint(equalToConstant: 60.0),
])
if i == 0 {
label.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
} else {
label.topAnchor.constraint(equalTo: prevView.bottomAnchor).isActive = true
}
if i == colors.count - 1 {
label.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
}
prevView = label
let c = label.heightAnchor.constraint(equalTo: container.heightAnchor, multiplier: 0.25)
c.priority = .defaultHigh
heightConstraints.append(c)
}
heightConstraints.removeLast()
NSLayoutConstraint.activate(heightConstraints)
container.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(container)
let instructionLabel = UILabel()
instructionLabel.text = "\nTap to change the percentages:"
[instructionLabel, infoLabel].forEach { v in
v.font = .monospacedSystemFont(ofSize: 18, weight: .light)
v.numberOfLines = 0
}
let vStack = UIStackView()
vStack.axis = .vertical
vStack.spacing = 12
vStack.alignment = .center
vStack.backgroundColor = .init(red: 0.90, green: 0.90, blue: 1.0, alpha: 1.0)
[instructionLabel, infoLabel].forEach { v in
vStack.addArrangedSubview(v)
}
vStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(vStack)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
container.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
vStack.topAnchor.constraint(equalTo: container.bottomAnchor, constant: 20.0),
vStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
vStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
vStack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
])
updateInfo()
}
func updateInfo() {
var s: String = "\n"
for i in 0..<pcts.count {
s += "\(pcts[i])"
if i == idx % pcts.count {
s += " <--"
}
s += "\n"
}
infoLabel.text = s
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
NSLayoutConstraint.deactivate(heightConstraints)
heightConstraints = []
idx += 1
updateInfo()
let newPcts = pcts[idx % pcts.count]
for i in 0..<newPcts.count {
let p = newPcts[i] / 100.0
let v = container.subviews[i]
if i < newPcts.count - 1 {
let c = v.heightAnchor.constraint(equalTo: container.heightAnchor, multiplier: p)
heightConstraints.append(c)
}
if let vv = v as? EmbeddedLabelView {
vv.text = "\(Int(newPcts[i]))"
}
}
NSLayoutConstraint.activate(self.heightConstraints)
UIView.animate(withDuration: 1.0, animations: {
self.view.layoutIfNeeded()
})
}
}
That looks like this:

Each tap will cycle to the next set of percentages.
Edit - because I hate answering an Obj-C question with Swift code...
Here is a similar implementation as above, with a few "enhancements."
- EmbeddedLabelView class
- LabelBarsView class
- Values are translated into percentages of the sum, so...
- if we pass
[1, 1, 1, 1]
each bar height will be 25%
- if we pass
[5, 10, 15, 20]
the bar heights will be 10%, 20% 30%, 40%
EmbeddedLabelView.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface EmbeddedLabelView : UIView
@property (strong, nonatomic) NSString *text;
@end
NS_ASSUME_NONNULL_END
EmbeddedLabelView.m
#import "EmbeddedLabelView.h"
@interface EmbeddedLabelView ()
{
UILabel *label;
}
@end
@implementation EmbeddedLabelView
- (instancetype)init
{
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (self) {
[self commonInit];
}
return self;
}
- (void)commonInit {
label = [UILabel new];
label.textAlignment = NSTextAlignmentCenter;
label.numberOfLines = 0;
label.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:label];
[NSLayoutConstraint activateConstraints:@[
// constrain all 4 sides
[label.topAnchor constraintEqualToAnchor:self.topAnchor constant:0.0],
[label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:0.0],
[label.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:0.0],
[label.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:0.0],
]];
}
- (void)setText:(NSString *)text {
label.text = text;
_text = text;
}
@end
LabelBarsView.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface LabelBarsView : UIView
@property (strong, nonatomic) NSArray <UIColor *>*colors;
@property (strong, nonatomic) NSArray <NSNumber *>*values;
@end
NS_ASSUME_NONNULL_END
LabelBarsView.m
#import "LabelBarsView.h"
#import "EmbeddedLabelView.h"
@interface LabelBarsView ()
{
NSMutableArray <NSLayoutConstraint *>*heightConstraints;
}
@end
@implementation LabelBarsView
- (instancetype)init
{
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
self = [super initWithCoder:coder];
if (self) {
[self commonInit];
}
return self;
}
- (void)commonInit {
heightConstraints = [NSMutableArray new];
self.clipsToBounds = YES;
}
- (void)setColors:(NSArray<UIColor *> *)colors {
if (colors.count == self.subviews.count) {
// we're just changing the bar background colors
for (int i = 0; i < colors.count; i++) {
self.subviews[i].backgroundColor = colors[i];
}
return;
}
// we're either setting colors for the first time, or
// changing the number of bars
for (UIView *v in self.subviews) {
[v removeFromSuperview];
}
heightConstraints = [NSMutableArray new];
EmbeddedLabelView *prevView;
NSLayoutConstraint *c;
float m = 1.0 / colors.count;
for (int i = 0; i < colors.count; i++) {
EmbeddedLabelView *v = [EmbeddedLabelView new];
v.backgroundColor = colors[i];
v.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:v];
[NSLayoutConstraint activateConstraints:@[
[v.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:0.0],
[v.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:0.0],
]];
if (!prevView) {
[v.topAnchor constraintEqualToAnchor:self.topAnchor].active = YES;
} else {
[v.topAnchor constraintEqualToAnchor:prevView.bottomAnchor].active = YES;
}
if (i == colors.count - 1) {
[v.bottomAnchor constraintEqualToAnchor:self.bottomAnchor].active = YES;
}
prevView = v;
c = [v.heightAnchor constraintEqualToAnchor:self.heightAnchor multiplier:m];
[heightConstraints addObject:c];
}
// to avoid auto-layout complaints with fractional constraints
// we don't use Height constraint on bottom bar/view
// it will "fill" the remaining space
[heightConstraints removeLastObject];
[NSLayoutConstraint activateConstraints:heightConstraints];
_colors = colors;
}
- (void)setValues:(NSArray<NSNumber *> *)values {
if (values.count != self.subviews.count) {
// must send the same number of values as bars
return;
}
// convert values to percentages
float sum = [[values valueForKeyPath:@"@sum.self"] floatValue];
[NSLayoutConstraint deactivateConstraints:heightConstraints];
heightConstraints = [NSMutableArray new];
for (int i = 0; i < values.count; i++) {
EmbeddedLabelView *v = self.subviews[i];
CGFloat p = [values[i] floatValue];
v.text = [NSString stringWithFormat:@"%ld", (unsigned long)p];
NSLayoutConstraint *c = [v.heightAnchor constraintEqualToAnchor:self.heightAnchor multiplier:p / sum];
[heightConstraints addObject:c];
}
// to avoid auto-layout complaints with fractional constraints
// we don't use Height constraint on bottom bar/view
// it will "fill" the remaining space
[heightConstraints removeLastObject];
[NSLayoutConstraint activateConstraints:heightConstraints];
_values = values;
}
@end
LabelBarsViewController.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface LabelBarsViewController : UIViewController
@end
NS_ASSUME_NONNULL_END
LabelBarsViewController.m
#import "LabelBarsViewController.h"
#import "LabelBarsView.h"
@interface LabelBarsViewController ()
{
LabelBarsView *barsView;
NSArray <NSArray *>*someValues;
NSInteger valIDX;
UILabel *infoLabel;
}
@end
@implementation LabelBarsViewController
- (void)viewDidLoad {
[super viewDidLoad];
// some sample values
someValues = @[
@[@1, @1, @1, @1],
@[@1, @2, @3, @4],
@[@4, @3, @2, @1],
@[@5, @10, @15, @20],
@[@20, @10, @40, @30],
@[@350, @120, @500, @280],
@[@10, @50, @30, @20],
@[@15, @15, @40, @30],
];
NSArray *colors = @[
[UIColor colorWithRed:1.0 green:0.9 blue:0.9 alpha:1.0],
[UIColor colorWithRed:0.6 green:1.0 blue:0.6 alpha:1.0],
[UIColor colorWithRed:0.8 green:0.9 blue:1.0 alpha:1.0],
[UIColor colorWithRed:1.0 green:0.9 blue:0.0 alpha:1.0],
];
barsView = [LabelBarsView new];
[barsView setColors:colors];
barsView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:barsView];
UILabel *instructionLabel = [UILabel new];
instructionLabel.font = [UIFont monospacedSystemFontOfSize:14.0 weight:UIFontWeightLight];
instructionLabel.numberOfLines = 0;
instructionLabel.textAlignment = NSTextAlignmentCenter;
instructionLabel.text = @"Tap to cycle through value sets...";
instructionLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:instructionLabel];
infoLabel = [UILabel new];
infoLabel.font = [UIFont monospacedSystemFontOfSize:14.0 weight:UIFontWeightLight];
infoLabel.numberOfLines = 0;
infoLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:infoLabel];
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[barsView.topAnchor constraintEqualToAnchor:g.topAnchor constant:20.0],
[barsView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
[barsView.bottomAnchor constraintEqualToAnchor:g.bottomAnchor constant:-20.0],
[barsView.widthAnchor constraintEqualToConstant:80.0],
[instructionLabel.topAnchor constraintEqualToAnchor:g.topAnchor constant:40.0],
[instructionLabel.leadingAnchor constraintEqualToAnchor:barsView.trailingAnchor constant:20.0],
[instructionLabel.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
[infoLabel.topAnchor constraintEqualToAnchor:instructionLabel.bottomAnchor constant:20.0],
[infoLabel.leadingAnchor constraintEqualToAnchor:barsView.trailingAnchor constant:20.0],
[infoLabel.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
]];
valIDX = -1;
[self nextValues];
}
- (void) nextValues {
++valIDX;
[self updateInfo];
[barsView setValues:someValues[valIDX % someValues.count]];
}
- (void) updateInfo {
NSMutableString *s = [NSMutableString new];
for (int i = 0; i < someValues.count; i++) {
[s appendString:(i == valIDX % someValues.count ? @"--> [" : @" [")];
for (int j = 0; j < someValues[i].count; j++) {
[s appendFormat:@"%@", someValues[i][j]];
if (j < someValues[i].count - 1) {
[s appendString:@", "];
}
}
[s appendString:@"]"];
[s appendString:@"\n"];
}
infoLabel.text = s;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self nextValues];
[UIView animateWithDuration:1.0 animations:^{
[self.view layoutIfNeeded];
}];
}
@end
Looks like this when running - each tap cycles to the next values set:
