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:
but will then smoothly animate the sizes until everything looks correct, like so:
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.
CodePudding user response:
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 aUILabel
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.