Animate a layout constraint by changing its priority value - ios

I have a label containing quite a lot of text. There's a toggle for collapsing and expanding the height of the label (here it's named "lire la suite") so it truncates the end of the text.
I have meticulously set the vertical content hugging priority and compression resistance so the intrinsic size has higher priority over the compression resistance.
The height constraint (the optional constraint directly at the right of the label) is set with a constant of 71, just the height for 4 lines. It never changes.
Then this same constraint has a priority switching between 747 and 749 so the following happens:
height constraint priority = 749:
compression resistance < constraint priority < hugging priority
Compression resistance collapses under the constraint priority, its height is 71 or less if its intrinsic size (hugging priority) is smaller.
height constraint priority = 747:
constraint priority < compression resistance < hugging priority
The greater compression resistance forces the height to follow its intrinsic size.
This works perfectly. My issue is that I can't figure out how to animate this constraint, since every solution animates the constant property and not the priority.
I would like to know if there's a solution or a workaround.

By experimenting with it, it seems that you cannot animate constraints using priorities, and you are stuck either with activating/deactivating constraints, or changing their constants.
I've had a similar task a couple of days ago. An easy but a bit naive approach is to drop the constraint and use only intrinsic content size - you can set the label.numberOfLines = 4 when it should be collapsed (thus the size won't expand over 4 lines), and label.numberOfLines = 0 when expanded. This is very easy and clean, however, I am not sure how that goes with animation.
A second approach is to use only the constraint and animate the constant. You have a height for 4 lines already, all you need is the height of the expanded label. You can use following extension on UILabel to calculate the height:
extension UILabel {
func heightNeeded() -> CGFloat {
self.layoutIfNeeded()
let myText = self.text! as NSString
let boundingRectangle = CGSize(width: self.bounds.width, height: CGFloat.greatestFiniteMagnitude)
let labelSize = myText.boundingRect(with: boundingRectangle,
options: .usesLineFragmentOrigin,
attributes: [NSAttributedStringKey.font: self.font],
context: nil)
return labelSize.height
}
}
Then all you need to animate is the:
labelHeightConstraint.constant = label.heightNeeded()
Don't forget on how to animate that constant using autolayout, see for example my following answer to another SO question.

Related

Horizontal UIStackView : Does value 999 or 1000 carry any special meaning in Horizontal Content Compression Resistance?

I have the following 3 components within a Horizontal stack view.
Yellow UILabel
Red UIImageView
Green UIImageView
The distribution for the Horizontal stack view is Fill.
The content mode for UIImageView is Aspect fit. They are using SFSymbol named "pin.fill".
The number of line for UILabel is 0, so that it supports multilines.
All the 3 components are having same Content Hugging Priority (250) and Content Compression Resistance Priority (750)
When Horizontal Content Compression Resistance for UILabel is 750 to 998
My questions are
Why the red UIImage take up most space, even though all 3 of them are having same Content Hugging Priority (250) and Content Compression Resistance Priority (750)?
My intention is letting yellow UILabel fill up most space. However, I can only achieve so, if I increase the Horizontal Content Compression Resistance (For UILabel only) up to 999 or 1000. I thought, as long as any value is higher than 750 will be good enough? Does 999 or 1000 value in Horizontal Content Compression Resistance carry any special meaning?
When Horizontal Content Compression Resistance for UILabel is 999 or 1000
Please do take note that, even when Horizontal Content Compression Resistance for UILabel is 999 or 1000, both red & green UIImageView's width will be compressed. But, they are compressed with different strength, which end up 2 UIImageView are having slightly different width. Why?
p/s
The complete project code is located at https://github.com/yccheok/stackoverflow/tree/master/66444344/test
You're over-thinking things a bit.
What you want to do is tell the two image views to "hug" their content, and leave everything else at the default Hugging and Compression Resistance values.
So, Label properties:
Both "pin" image views:
It will look like this in your Storyboard:
Give this code a try... connect the label to the #IBOutlet and run the app. Each tap will cycle through 4 different length strings for the label:
class ViewController: UIViewController {
#IBOutlet var label: UILabel!
let strs: [String] = [
"Very Short",
"A little longer, but still one line.",
"Button - You can set the title, image, and other appearance properties of a button.",
"UIStackView - creates and manages the constraints necessary to create horizontal or vertical stacks of views. It will dynamically add and remove its constraints to react to views being removed or added to its stack. With customization it can also react and influence the layout around it.",
]
var idx: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
label.text = strs[idx % strs.count]
// tap anywhere in the view
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
#objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
idx += 1
label.text = strs[idx % strs.count]
}
}
and the result will be:

Label doesn't show all the text inside scrollView

I want the DecriptionLabel (the Lorem Ipsum one) to have all the text inside it visible. As you can see, it is getting trimmed.
The two buttons should be under everything else, but in the case where DescriptionLabel contains a small text, the buttons should stick to the bottom of the view.
This is why I chose a >= 20 distance between the buttons and DescriptionLabel if it makes any sense.
How can I solve the trimming of the text?
Thanks.
I was originally answering How to make button stick to bottom of scroll view if the content isn't large enough? but since it is marked as duplicate of this one I am posting my answer here. Please try to set your constraints the following way: https://imageshack.com/a/img923/6671/Txzu98.png
The trick is that you set Button.Bottom Equal To ContainerView.Bottom with lower priority (I use 750) than Button.Top Greater Than Or Equal To Label.Bottom (Here I use default 1000)
The Label has to have number of lines set to 0. The height of the button should be set by height constraint (in this case is 50). The Container View Height constraint should be with low priority (in this case 250)
You should run the code to see actual result on device or simulator. Storyboard shows it a bit differently. For the current question:
https://imageshack.com/a/img923/7276/tQeT0h.png The basic idea is the same. Button Down has the same constraints as Button from above answer without Button.Top Greater Than Or Equal To Label.Bottom. There should be fixed vertical constraint between Button Up and Button Down. I am setting Button Up with fixed Height and setting trailing and leading constraint equal to trailing and leading of Button Down. The constraint Button.Top Greater Than Or Equal To Label.Bottom is now Button Up.Top Greater Than Or Equal To Label.Bottom
Have you set numberOfLines for label to 0 (that means autosize the label according to its text length)?
You should add the following constraints:
(following in sudo code)
// Constraints for ScrollView
scrollView.top = ViewController.view.top
scrollView.leading = ViewController.view.leading
scrollView.trailing = ViewController.view.trailing
scrollView.bottom = ViewController.view.bottom
// Constraints for View
view.top = scrollView.top
view.leading = scrollView.leading
view.trailing = scrollView.trailing
view.bottom = scrollView.bottom
// Width of view
view.width = ViewController.view.width
Now you just need to make sure you have layout constraints for each child of the 'view' and it's height will be correct and display the full size of the textview.
Add the following constraint:
scrollview.contentview.height >= safearea.height
This may show an error in interface builder but works in my tests:
To remove the design time error you could set a design time intrinsic content size for the scrollview's contentview (in my case I used the safe area's height of 554):
Another option (without placeholder values in IB) is to create the following constraint...
scrollview.contentview.height = safearea.height
... and change its priority to a value lower than the label's vertical content compression resistancy.

UIStackView proportional layout with only intrinsicContentSize

I'm experiencing problems with layout of arranged subviews in UIStackView and was wondering if someone could help me understand what's going on.
So I have UIStackView with some spacing (for example 1, but this does not matter) and .fillProportionally distribution. I'm adding arranged subviews with only intrinsicContentSize of 1x1 (could be anything, just square views) and I need them to be stretched proportionally within stackView.
The problem is that if I add views without actual frame, only with intrinsic sizes, then I get this wrong layout
Otherwise, if I add views with frames of the same size, everything works as expected,
but I really prefer not to set view's frame at all.
I'm pretty sure that this is all about hugging and compression resistance priority, but can't figure out what right answer is.
Here is an Playground example:
import UIKit
import PlaygroundSupport
class LView: UIView {
// If comment this and leave only intrinsicContentSize - result is wrong
convenience init() {
self.init(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
}
// If comment this and leave only convenience init(), then everything works as expected
public override var intrinsicContentSize: CGSize {
return CGSize(width: 1, height: 1)
}
}
let container = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
container.backgroundColor = UIColor.white
let sv = UIStackView()
container.addSubview(sv)
sv.leftAnchor.constraint(equalTo: container.leftAnchor).isActive = true
sv.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true
sv.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
sv.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
sv.translatesAutoresizingMaskIntoConstraints = false
sv.spacing = 1
sv.distribution = .fillProportionally
// Adding arranged subviews to stackView, 24 elements with intrinsic size 1x1
for i in 0..<24 {
let a = LView()
a.backgroundColor = (i%2 == 0 ? UIColor.red : UIColor.blue)
sv.addArrangedSubview(a)
}
sv.layoutIfNeeded()
PlaygroundPage.current.liveView = container
This is obviously a bug in the implementation of UIStackView (i.e. a system bug).
DonMag already gave a hint pointing in the right direction in his comment:
When you set the stack view's spacing to 0, everything works as expected. But when you set it to any other value, the layout breaks.
Here's the explanation why:
ℹ️ For the sake of simplicity I will assume that the stack view has
a horizontal axis and
10 arranged subviews
With the .fillProportionally distribution UIStackView creates system-constraints as follows:
For each arranged subview, it adds an equal width constraint (UISV-fill-proportionally) that relates to the stack view itself with a multiplier:
arrangedSubview[i].width = multiplier[i] * stackView.width
If you have n arranged subviews in the stack view, you get n of these constraints. Let's call them proportionalConstraint[i] (where i denotes the position of the respective view in the arrangedSubviews array).
These constraints are not required (i.e. their priority is not 1000). Instead, the constraint for the first element in the arrangedSubviews array is assigned a priority of 999, the second is assigned a priority of 998 etc.:
proportionalConstraint[0].priority = 999
proportionalConstraint[1].priority = 998
proportionalConstraint[2].priority = 997
proportionalConstraint[3].priority = 996
...
proportionalConstraint[n–1].priority = 1000 – n
This means that required constraints will always win over these proportional constraints!
For connecting the arranged subviews (possibly with a spacing) the system also creates n–1 constraints called UISV-spacing:
arrangedSubview[i].trailing + spacing = arrangedSubview[i+1].leading
These constraints are required (i.e. priority = 1000).
(The system will also create some other constraints (e.g. for the vertical axis and for pinning the first and last arranged subview to the edge of the stack view) but I won't go into detail here because they're not relevant for understanding what's going wrong.)
Apple's documentation on the .fillProportionally distribution states:
A layout where the stack view resizes its arranged views so that they fill the available space along the stack view’s axis. Views are resized proportionally based on their intrinsic content size along the stack view’s axis.
So according to this the multiplier for the proportionalConstraints should be computed as follows for spacing = 0:
totalIntrinsicWidth = ∑i intrinsicWidth[i]
multiplier[i] = intrinsicWidth[i] / totalIntrinsicWidth
If our 10 arranged subviews all have the same intrinsic width, this works as expected:
multiplier[i] = 0.1
for all proportionalConstraints. However, as soon as we change the spacing to a non-zero value, the calculation of the multiplier becomes a lot more complex because the widths of the spacings have to be taken into account. I've done the maths and the formula for multiplier[i] is:
Example:
For a stack view configured as follows:
stackView.width = 400
stackView.spacing = 2
the above equation would yield:
multiplier[i] = 0.0955
You can prove this correct by adding it up:
(10 * width) + (9 * spacing)
= (10 * multiplier * stackViewWidth) + (9 * spacing)
= (10 * 0.0955 * 400) + (9 * 2)
= (0.955 * 400) + 18
= 382 + 18
= 400
= stackViewWidth
However, the system assigns a different value:
multiplier[i] = 0.0917431
which adds up to a total width of
(10 * width) + (9 * spacing)
= (10 * 0.0917431 * 400) + (9 * 2)
= 384,97
< stackViewWidth
Obviously, this value is wrong.
As a consequence the system has to break a constraint. And of course, it breaks the constraint with the lowest priority which is the proportionalConstraint of the last arranged subview item.
That's the reason why the last arranged subview in your screenshot is stretched.
If you try out different spacings and stack view widths you'll end up with all sorts of weird-looking layouts. But they all have one thing in common:
The spacings always take precedence. (If you set the spacing to a greater value like 30 or 40 you'll only see the first two or three arranged subviews because the rest of the space is fully occupied by the required spacings.)
To sum things up:
The .fillProportionally distribution only works properly with spacing = 0.
For other spacings the system creates constraints with an incorrect multiplier.
This breaks the layout as
either one of the arranged subviews (the last) has to be stretched if the multiplier is smaller than it should be
multiple arranged subviews have to be compressed if the multiplier is greater than it should be.
The only way out of this is to "misuse" plain UIViews with a required fixed-width constraint as spacings between the views. (Normally, UILayoutGuides were introduced for this purpose but you cannot even use those either because you cannot add layout guides to a stack view.)
I'm afraid that due to this bug, there is no clean solution to do this.
As of Xcode 9.2 at least, the playground provided works as intended provided the initializer and the intrinsic content size are both commented out. In that case, the proportional filling works as expected, even with spacing > 0
This is an example with spacing = 5
That seems to make sense because the arranged subviews have no intrinsic content size and the StackView determines their widths to proportionally fill the designated axis.
If only the initializer is enabled (and not the intrinsic content size override), then I get this, which doesn't match the comment in the playground, so I guess this behaviour must have changed since the question was posted:
I don't understand that behaviour, because it would seem to me that setting the frame manually should be ignored when using Auto Layout.
If only the intrinsic content size override is enabled (and not the initializer) then I get the problematic image that originated this post (here with spacing = 5):
Essentially, the design now is conflicting and can't be realized, because views want to be 1 point wide, due to specified intrinsic content size. The total space here should be
24 views * 1 point/view + 23 spaces * 5 points/space = 139 < 300 = sv.bounds.width
with the last arranged view's constraint broken due to lowest priority as pointed out by Mischa.
Upon pixel-per-pixel inspection the first 23 views above are wider than 1 pixel though, 2 or 3 pixels actually, except for the last one, thus the math doesn't quite match, I don't know why, possibly rounding up of decimal numbers?
For reference, this is what it looks like in that case with intrinsic content size of width 5, still failing to satisfy the constraints.

How can you set a UILabel's height to exactly match its font size?

I've got a UI design spec to follow where the spacing between text is precisely specified i.e. here's a spec for vertical spacing in a table view cell:
The problem when using a storyboard and constraints is that when you place a UILabel in the storyboard its height is larger then its contained font size (so you can't for example set a vertical constraint of 6 between the two line in the spec for example, as that would set the UILabels 6 points apart, not the contained text 6 apart).
Here's a diagram to make it clear what I mean:
Here the constraint is 6px. But I want to be able to set the distance between the bottom of the text of Label1 and the top of the text of label 2 to be 6.
By setting a vertical constraint of 6 between the labels, the distance between the text within those labels is greater than 6.
I attempted to solve this by using a custom label:
#IBDesignable
class NoPaddingLabel: UILabel {
override var alignmentRectInsets: UIEdgeInsets {
return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
}
But this doesn't adjust the height of the UILabel in the storyboard despite the #IBDesignable - for example if you drag and drop a UILabel then by default its System font size 17, with a height of 21. If its type is changed from UILabel to NoPaddingLabel its height is still 21.
If constraints are then added there's tones of errors regarding vertical compression and vertical hugging priorities conflicts.
So, is there a way to calculate the height of the UILabel in the storyboard to correspond to the height of its font so that its easier to set exact vertical constraints between text? -
Whats the solution to set exact constraint distances between text, rather than between labels in a storyboard?

Two labels alignment and its constraints

I have 2 labels: the description label (w/ red background) and the results label (gray text)
How do i set constraints for this example in order to have the results label with the size of its content and the description label until the results leadingAnchor? (like i have in the second row)
Objective C
[self.customTextLabel.trailingAnchor constraintLessThanOrEqualToAnchor:self.counterLabel.leadingAnchor].active = YES;
[self.counterLabel.widthAnchor constraintGreaterThanOrEqualToConstant:0].active = YES;
swift
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: counterLabel.leadingAnchor).isActive = true
counterLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 0).isActive = true
I have a solution that i think it's ugly.
self.counterLabelWidthConstraint = [self.counterLabel.widthAnchor constraintEqualToConstant:0];
self.counterLabelWidthConstraint.active = YES;
And then after i set the text:
self.counterLabelWidthConstraint.constant = [self.counterLabel sizeThatFits:CGSizeMake(CGFLOAT_MAX, self.counterLabel.height)].width;
The way to do this with auto layout is by using the contentCompressionResistancePriority of the 2 labels. Set the pririty to NSLayoutPriorityRequired for the second label and something lower like NSLayoutPriorityDefaultLow for the first label. Then, as long as the 2 labels have proper constraints anchoring them to their superview and each other, the first label should compress while the second label should not.
You just need to increase the horizontal compression resistance of the right/gray label to be higher than that of the left/red label. This tells the visual layout that, in the event that there is not enough space for both labels, the one on the left will be compressed before shrinking the label on the right. 750 is the default for all views, so just increase the right/gray label's horizontal compression resistance to 751 and you should be good to go.
Swift 5 programatically:
<#label#>.setContentCompressionResistancePriority(.required, for: .horizontal)
Labels with this property will not compress horizontally.
You can set constraints for in storyboard itself. Select Label 1 (red back ground) and label's superview set widths are equal constraint. Select Label 1 and double tap on its width constraint, from the resultant window, you could see Lable 1 width equal to superview with value constant as '1'. change '1' to 0.7 or whichever the percentage you want.

Resources