Search code examples
iosuilabeluianimationios-animations

How to get resizing of UILabel objects with constraints to animate smoothly?


I have a vertical rectangle -- a simple UIView -- that is divided into 4 sections, sort of like a pie chart, and each section will grow and shrink dynamically (as data rolls in), and I'm trying to get that to happen smoothly. Am using constraints to keep their sides united tightly to one another.

Part of the animation happens smoothly, but initially the four colored sections, which are just empty UILabel objects, are resized abruptly, revealing the background color of the container and then the animation seems to kick in and resolve the boundaries of the UILabel objects smoothly. I have a good video captured from the Simulator that shows the behavior, but don't have a way to provide that in the question here. Link perhaps coming later. The animation code right now is very simple. When a timer fires I simply alternate between two different states wherein I assign the constant value for the height constraints. Like so:

-(void)relayoutSubviewsAnimated {

    static int ctr = 1;

    [self layoutIfNeeded];
    
    [UIView animateWithDuration:1.5
            animations:^{
        
                if (ctr == 1) {
                    self->_outletBucketMastersHeight.constant = 0.25 * nHeightOfPieBar;
                    self->_outletBucketMeetsHeight.constant = 0.25 * nHeightOfPieBar;
                    self->_outletBucketApproachesHeight.constant = 0.25 * nHeightOfPieBar;
                    self->_outletBucketDidNotMeetHeight.constant = 0.25 * nHeightOfPieBar;
                    ctr++;
                }
                else if (ctr == 2) {
                    self->_outletBucketMastersHeight.constant = 0.2 * nHeightOfPieBar;
                    self->_outletBucketMeetsHeight.constant = 0.1 * nHeightOfPieBar;
                    self->_outletBucketApproachesHeight.constant = 0.4 * nHeightOfPieBar;
                    self->_outletBucketDidNotMeetHeight.constant = 0.4 * nHeightOfPieBar;
                    ctr = 1;
                }

                [self layoutIfNeeded];
            }];
}

So, initially the sections will resize suddenly, with no animation, and will momentarily look like so:

enter image description here

but will then smoothly animate the sizes until everything looks correct, like so:

enter image description here

The other usual constraints (horizontal and vertical space constraints) bind the UILabel objects to each other and leading and trailing constraints bind the UILabel objects to the sides of their container.

What could I be doing wrong? How do I smoothly animate the growth and shrinkage of these 4 UILabels without the white background of the container suddenly showing through? I have read a number of SO questions and other articles.


Solution

  • 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:

    enter image description here

    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:

    enter image description here

    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:

    enter image description here enter image description here