英文:
How to obtain an approximately square multiline UILabel using Autolayout
问题
我将一个多行的UILabel放入一个弹出窗口中。文本的长度是可变的。我真正想做的是获得一个大致是正方形的尺寸,这样在显示时会看起来很好。
如果我创建一个将标签宽度设置为等于高度的约束,我会得到一个与高度匹配的单行标签。
有一个(我认为很糟糕的)解决方案:获取单行标签的宽度,然后在循环中将宽度约束设置为原始宽度的逐渐减小的分数,然后布局,直到标签的边界大致符合所需的尺寸。
毫无疑问,应该有一个更好的解决方案。
英文:
I'm putting a multiline UILabel into a popup. The text has a variable length. What I'd really like to do is get a size that is approximately square—so that when displayed it has a nice appearance.
If I create a constraint that sets the label width to equal the height, I get a one line label with a matching height.
There is a (IMHO a terrible) solution: get the width of the one line label, then in a loop set a width constraint to an ever decreasing a fraction of the original width, layout, then stop when the label bound's is more or less what's desired.
Surely there is a better solution.
答案1
得分: 1
以下是翻译好的部分:
我认为没有办法在不遍历尺寸的情况下完成这个任务。
我无法完全理解你在答案中的代码中在做什么——无法让它产生预期的结果。然而...
首先,我们可以利用systemLayoutSizeFitting
(Apple文档)而不是设置约束并强制进行自动布局传递。例如:
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 16.0, weight: .regular)
// 设置标签的文本
v.text = "示例字符串:快速的红狐跳过懒惰的棕色狗。"
// 获取最宽的尺寸
let sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
print("尺寸:", sz)
// 输出 "尺寸:(497.0, 19.333333333333332)"
这为我们提供了字符串作为单行的宽度和高度。不过,如果字符串包含嵌入的换行字符:
"示例字符串:\n\n快速的红狐跳过懒惰的棕色狗。"
它的“原生”布局将是:
示例字符串:
快速的红狐跳过懒惰的棕色狗。
所以同样的代码行:
let sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
输出的是 "尺寸:(355.6666666666667, 57.333333333333336)",这确实是我们想要的。
所以,让我们将width
更改为非约束宽度的一半(我们将删除换行字符):
v.text = "示例字符串:快速的红狐跳过懒惰的棕色狗。"
var sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
print("尺寸:", sz)
sz = v.systemLayoutSizeFitting(.init(width: sz.width * 0.5, height: 1.0))
print("尺寸:", sz)
// 输出 "尺寸:(497.0, 19.333333333333332)"
// 输出 "尺寸:(244.66666666666666, 57.333333333333336)"
现在,比率可以表示为带有 width / height
的小数值。
例如,5:3
是 5.0 / 3.0 = 1.666...
通过上面两个得到的尺寸,我们得到了:
尺寸:(497.0, 19.333333333333332)
比率:497.0 / 19.333 = 25.70689655172414
又称:5.0 : 0.195
尺寸:(244.66666666666666, 57.333333333333336)
比率:244.666 / 57.333 = 4.267441860465116
又称:5.0 : 1.172
如果我们想找到给定宽高比的“最佳”尺寸,我们可以循环遍历,每次将宽度减半并比较结果:
// 我们想要一个5:3的结果
let targetWidth: CGFloat = 5.0
let targetHeight: CGFloat = 3.0
let targetRatio: CGFloat = targetWidth / targetHeight
print("目标比率:", targetRatio)
print("又称:", String(format: "%0.1f : %0.3f", targetWidth, targetHeight))
var sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
while (sz.width / sz.height) > targetRatio {
print()
print("新尺寸:", sz)
print("新比率:", sz.width / sz.height)
print("又称:", String(format: "%0.1f : %0.3f", targetWidth, sz.height / (sz.width / targetWidth)))
// 将宽度减半
// 获取新的受宽度约束的尺寸
sz = v.systemLayoutSizeFitting(.init(width: sz.width * 0.5, height: .greatestFiniteMagnitude))
}
print()
print("现在的比率小于目标比率")
print("新尺寸:", sz)
print("新比率:", sz.width / sz.height)
print("又称:", String(format: "%0.1f : %0.3f", targetWidth, sz.height / (sz.width / targetWidth)))
控制台输出将是:
目标比率:1.6666666666666667
又称:5.0 : 3.000
新尺寸:(497.0, 19.333333333333332)
新比率:25.70689655172414
又称:5.0 : 0.195
新尺寸:(244.66666666666666, 57.333333333333336)
新比率:4.267441860465116
又称:5.0 : 1.172
我们现在有一个比目标比率小的比率
新尺寸:(115.0, 95.66666666666667)
新比率:1.2020905923344947
又称:5.0 : 4.159
这看起来像这样的可视化效果:
(图片链接已省略)
最终的比率 5.0 : 4.159
并不非常接近 5 : 3
,而且与之前的 5.0 : 1.172
比率之间存在很大的差距... 我们可以用这个尺寸更接近目标:
(图片链接已省略)
我们可以简单地“蛮力”来解决它
英文:
I don't think there is any way to do this without looping through sizes.
I can't quite work out what you're doing in the code in your answer -- couldn't get it to produce the expected results. However...
First, instead of setting constraints and forcing auto-layout passes, we can take advantage of systemLayoutSizeFitting
(Apple Docs). For example:
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 16.0, weight: .regular)
// set the label's text
v.text = "An Example String: The quick red fox jumps over the lazy brown dog."
// get the widest size
let sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
print("Size:", sz)
// prints "Size: (497.0, 19.333333333333332)"
That gives us the width and height of the string as a single line. Well, not really... if the string has embedded newline characters:
"An Example String:\n\nThe quick red fox jumps over the lazy brown dog."
Its "native" layout will be:
An Example String:
The quick red fox jumps over the lazy brown dog.
So that same line of code:
let sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
// prints "Size: (355.6666666666667, 57.333333333333336)"
Which is what we want.
So, let's change the width
to one-half of the non-constrained width (we'll remove the newline chars):
v.text = "An Example String: The quick red fox jumps over the lazy brown dog."
var sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
print("Size:", sz)
sz = v.systemLayoutSizeFitting(.init(width: sz.width * 0.5, height: 1.0))
print("Size:", sz)
// prints "Size: (497.0, 19.333333333333332)"
// prints "Size: (244.66666666666666, 57.333333333333336)"
Now, a ratio can be expressed as a decimal value with width / height
.
For example, 5:3
is 5.0 / 3.0 = 1.666...
With the above two resulting sizes, we get:
Size: (497.0, 19.333333333333332)
Ratio: 497.0 / 19.333 = 25.70689655172414
AKA: 5.0 : 0.195
Size: (244.66666666666666, 57.333333333333336)
Ratio: 244.666 / 57.333 = 4.267441860465116
AKA: 5.0 : 1.172
If we want to find the "optimal" dimensions for a given aspect ratio, we can loop through, dividing the width in half each time, and compare the result:
// we want a 5:3 result
let targetWidth: CGFloat = 5.0
let targetHeight: CGFloat = 3.0
let targetRatio: CGFloat = targetWidth / targetHeight
print("Target Ratio:", targetRatio)
print("AKA: ", String(format: "%0.1f : %0.3f", targetWidth, targetHeight))
var sz = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: 1.0))
while (sz.width / sz.height) > targetRatio {
print()
print("New Size:", sz)
print("New Ratio:", sz.width / sz.height)
print("AKA: ", String(format: "%0.1f : %0.3f", targetWidth, sz.height / (sz.width / targetWidth)))
// cut the Width in half
// get the new Size contstrained to Width
sz = v.systemLayoutSizeFitting(.init(width: sz.width * 0.5, height: .greatestFiniteMagnitude))
}
print()
print("We now have a smaller ratio than targetRatio")
print("New Size:", sz)
print("New Ratio:", sz.width / sz.height)
print("AKA: ", String(format: "%0.1f : %0.3f", targetWidth, sz.height / (sz.width / targetWidth)))
and the console output will be:
Target Ratio: 1.6666666666666667
AKA: 5.0 : 3.000
New Size: (497.0, 19.333333333333332)
New Ratio: 25.70689655172414
AKA: 5.0 : 0.195
New Size: (244.66666666666666, 57.333333333333336)
New Ratio: 4.267441860465116
AKA: 5.0 : 1.172
We now have a smaller ratio than targetRatio
New Size: (115.0, 95.66666666666667)
New Ratio: 1.2020905923344947
AKA: 5.0 : 4.159
The visualization of that looks like this:
The final ratio of 5.0 : 4.159
isn't very close to 5 : 3
though, and there's a big gap between that and the previous 5.0 : 1.172
ratio... and we can get much closer with this size:
We could simply "brute force" it, adding another loop increasing the width by 1.0
each time, until we get closer to the target ratio. But, that would be inefficient... and potentially a lot of loops.
So one approach would be to evaluate the size/ratio changes using effectively a binary search pattern, increasing or decreasing the width by one-half of the previous gap, until we find our "optimal" size.
Here's an example function:
func sizeForOptimalDimension(label: UILabel, targetWidth: CGFloat, targetHeight: CGFloat) -> (CGSize, CGSize)? {
guard let str = label.text, !str.isEmpty else { return nil }
let targetRatio: CGFloat = targetWidth / targetHeight
var curWidth: CGFloat = 0
var curSize: CGSize = .zero
var lastSize: CGSize = .zero
var prevDiff: CGFloat = 0
var wInc: CGFloat = 0
// create a new label for calculations
let v = UILabel()
v.numberOfLines = 0
v.font = label.font
// set the label's text
v.text = str
// get the widest size (single-line height)
curSize = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: font.lineHeight + 1.0))
curWidth = curSize.width
// if currentRatio is less than targetRatio to begin with,
// for example, a string with embedded newlines
// such as: ""One\nTwo\nThree"
// -------
// One
// Two
// Three
// Four
// Five
// -------
// we can't do anything to it (and we'd get an infinite loop)
if (curSize.width / curSize.height) < targetRatio {
lastSize = curSize
} else {
prevDiff = curWidth
// while the calculated Ratio is Greater-Than the targetRatio
while (curSize.width / curSize.height) > targetRatio {
// cut the Width in half
curWidth *= 0.5
// get the new Size contstrained to Width
curSize = v.systemLayoutSizeFitting(.init(width: curWidth, height: .greatestFiniteMagnitude))
prevDiff -= curWidth
}
// we now have a smaller ratio than targetRatio
lastSize = curSize
curWidth = curSize.width
// we'll start incrementing by one-half of the last difference
wInc = floor(prevDiff * 0.5)
// this should always be true
// but sanity check to avoid an infinite loop
if (curSize.width / curSize.height) < targetRatio {
// while the calculated Ratio is Less-Than the targetRatio
while (curSize.width / curSize.height) < targetRatio {
// save the current size
lastSize = curSize
// increment Width
curWidth += wInc
// get the new Size contstrained to Width
curSize = v.systemLayoutSizeFitting(.init(width: curWidth, height: .greatestFiniteMagnitude))
// if the new ratio is again Greater than the targetRatio
// AND the increment is Greater than 1
if (curSize.width / curSize.height) > targetRatio && wInc > 1.0 {
// reset the current width
// reset the current size
// cut the increment in half
curWidth -= wInc
curSize = lastSize
wInc = floor(wInc * 0.5)
}
}
}
}
return (lastSize, curSize)
}
You'll notice that function returns two sizes -- the closest size smaller and the closest size larger than the target ratio. This will be most noticeable with shorter strings. For example:
Neither resulting ratio -- 5.0:5.059
or 5.0:1.917
-- is particularly close to 5.0:3.0
. So, depending on your overall layout goal, you could choose "closest to target ratio" or "prefer wider" for example.
Here is a complete view controller class to try out:
class ViewController: UIViewController {
// target ratio
let targetWidth: CGFloat = 5.0
let targetHeight: CGFloat = 3.0
let font: UIFont = .systemFont(ofSize: 16.0, weight: .regular)
let labelA: UILabel = UILabel()
let labelB: UILabel = UILabel()
let labelC: UILabel = UILabel()
var labelAWidth: NSLayoutConstraint!
var labelBWidth: NSLayoutConstraint!
var labelCWidth: NSLayoutConstraint!
let infoA: UILabel = UILabel()
let infoB: UILabel = UILabel()
let infoC: UILabel = UILabel()
var samples: [String] = [
"An Example String: The quick red fox jumps over the lazy brown dog.",
"Short string to use in testing.",
"UILabel: A label can contain an arbitrary amount of text, but UILabel may shrink, wrap, or truncate the text, depending on the size of the bounding rectangle and properties you set. You can control the font, text color, alignment, highlighting, and shadowing of the text in the label.",
"UIButton: Displays a plain styled button that can have a title, subtitle, image, and other appearance properties.",
"UIViewController: Provides view-management functionality for toolbars, navigation bars, and application views. The UIViewController class also supports modal views and rotating views when device orientation changes.",
"Linefeeds:\n\nNote that this also works with strings that contain embedded linefeed characters.",
// this will break UICollectionViewFlowLayoutInvalidationContext onto multiple lines
"But, if we have a very long word, such as UICollectionViewFlowLayoutInvalidationContext, word-wrapping is an issue.",
// this will be tall and very narrow, so we can't do anything with it
"And\nwe\ncannot\ndo\nanything\nwith\nthis.",
]
override func viewDidLoad() {
super.viewDidLoad()
[infoA, infoB, infoC].forEach { v in
v.numberOfLines = 0
v.textAlignment = .center
v.font = .systemFont(ofSize: 14.0, weight: .light)
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
[labelA, labelB, labelC].forEach { v in
v.numberOfLines = 0
v.font = font
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
labelA.backgroundColor = .cyan
labelB.backgroundColor = .green
labelC.backgroundColor = .yellow
// these will be set later
labelAWidth = labelA.widthAnchor.constraint(equalToConstant: 100.0)
labelBWidth = labelB.widthAnchor.constraint(equalToConstant: 100.0)
labelCWidth = labelC.widthAnchor.constraint(equalToConstant: 100.0)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
infoA.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
infoA.centerXAnchor.constraint(equalTo: g.centerXAnchor),
labelA.topAnchor.constraint(equalTo: infoA.bottomAnchor, constant: 2.0),
labelA.centerXAnchor.constraint(equalTo: g.centerXAnchor),
infoB.topAnchor.constraint(equalTo: labelA.bottomAnchor, constant: 16.0),
infoB.centerXAnchor.constraint(equalTo: g.centerXAnchor),
labelB.topAnchor.constraint(equalTo: infoB.bottomAnchor, constant: 2.0),
labelB.centerXAnchor.constraint(equalTo: g.centerXAnchor),
infoC.topAnchor.constraint(equalTo: labelB.bottomAnchor, constant: 16.0),
infoC.centerXAnchor.constraint(equalTo: g.centerXAnchor),
labelC.topAnchor.constraint(equalTo: infoC.bottomAnchor, constant: 2.0),
labelC.centerXAnchor.constraint(equalTo: g.centerXAnchor),
labelAWidth, labelBWidth, labelCWidth,
])
nextString()
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
nextString()
}
func nextString() {
// cycle to the next sample string
let str = samples.removeFirst()
samples.append(str)
[labelA, labelB, labelC].forEach { v in
v.text = str
}
updateLabels()
}
func updateLabels() {
var lastSize: CGSize = .zero
var curSize: CGSize = .zero
(lastSize, curSize) = sizeForOptimalDimension(label: labelA, targetWidth: targetWidth, targetHeight: targetHeight) ?? (.zero, .zero)
// get the ratios of the last smaller and last larger calculated sizes
let lastRatio: CGFloat = lastSize.width / lastSize.height
let curRatio: CGFloat = curSize.width / curSize.height
let targetRatio: CGFloat = targetWidth / targetHeight
let lastDiff: CGFloat = abs(targetRatio - lastRatio)
let curDiff: CGFloat = abs(targetRatio - curRatio)
// which new ratio is closest to targetRatio?
let finalSize: CGSize = curDiff < lastDiff ? curSize : lastSize
var w: CGFloat = 0
var h: CGFloat = 0
var r: CGFloat = 0
w = lastSize.width
h = lastSize.height
r = h / (w / targetWidth)
let lastR: String = String(format: "%0.1f:%0.3f", targetWidth, r)
w = curSize.width
h = curSize.height
r = h / (w / targetWidth)
let curR: String = String(format: "%0.1f:%0.3f", targetWidth, r)
w = finalSize.width
h = finalSize.height
r = h / (w / targetWidth)
let finalR: String = String(format: "%0.1f:%0.3f", targetWidth, r)
// update the width constraints
labelAWidth.constant = lastSize.width
labelBWidth.constant = curSize.width
labelCWidth.constant = finalSize.width
let infoStrings: [String] = [
String(format: "%0.3f x %0.3f = Ratio \(lastR)", lastSize.width, lastSize.height),
String(format: "%0.3f x %0.3f = Ratio \(curR)", curSize.width, curSize.height),
String(format: "Closest to %0.1f x %0.1f\n%0.3f x %0.3f = Ratio \(finalR)", targetWidth, targetHeight, curSize.width, curSize.height),
]
for (s, v) in zip(infoStrings, [infoA, infoB, infoC]) {
v.text = s
}
}
func sizeForOptimalDimension(label: UILabel, targetWidth: CGFloat, targetHeight: CGFloat) -> (CGSize, CGSize)? {
guard let str = label.text, !str.isEmpty else { return nil }
let targetRatio: CGFloat = targetWidth / targetHeight
var curWidth: CGFloat = 0
var curSize: CGSize = .zero
var lastSize: CGSize = .zero
var prevDiff: CGFloat = 0
var wInc: CGFloat = 0
// create a new label for calculations
let v = UILabel()
v.numberOfLines = 0
v.font = label.font
// set the label's text
v.text = str
// get the widest size (single-line height)
curSize = v.systemLayoutSizeFitting(.init(width: .greatestFiniteMagnitude, height: font.lineHeight + 1.0))
curWidth = curSize.width
// if currentRatio is less than targetRatio to begin with,
// for example, a string with embedded newlines
// such as: ""One\nTwo\nThree"
// -------
// One
// Two
// Three
// Four
// Five
// -------
// we can't do anything to it (and we'd get an infinite loop)
if (curSize.width / curSize.height) < targetRatio {
lastSize = curSize
} else {
prevDiff = curWidth
// while the calculated Ratio is Greater-Than the targetRatio
while (curSize.width / curSize.height) > targetRatio {
// cut the Width in half
curWidth *= 0.5
// get the new Size contstrained to Width
curSize = v.systemLayoutSizeFitting(.init(width: curWidth, height: .greatestFiniteMagnitude))
prevDiff -= curWidth
}
// we now have a smaller ratio than targetRatio
lastSize = curSize
curWidth = curSize.width
// we'll start incrementing by one-half of the last difference
wInc = floor(prevDiff * 0.5)
// this should always be true
// but sanity check to avoid an infinite loop
if (curSize.width / curSize.height) < targetRatio {
// while the calculated Ratio is Less-Than the targetRatio
while (curSize.width / curSize.height) < targetRatio {
// save the current size
lastSize = curSize
// increment Width
curWidth += wInc
// get the new Size contstrained to Width
curSize = v.systemLayoutSizeFitting(.init(width: curWidth, height: .greatestFiniteMagnitude))
// if the new ratio is again Greater than the targetRatio
// AND the increment is Greater than 1
if (curSize.width / curSize.height) > targetRatio && wInc > 1.0 {
// reset the current width
// reset the current size
// cut the increment in half
curWidth -= wInc
curSize = lastSize
wInc = floor(wInc * 0.5)
}
}
}
}
return (lastSize, curSize)
}
}
Tapping anywhere will cycle through the sample strings array:
There will be some "edge cases" that can cause issues... One is when you have a very long word - such as UICollectionViewFlowLayoutInvalidationContext - that can result in quirky word-wrapping:
The other case is when the "native" layout cannot be modified to fit the target ratio - such as this string: "And\nwe\ncannot\ndo\nanything\nwith\nthis."
which gives us this result:
This is just one possible approach - but maybe it can help you out. Please keep in mind: this is EXAMPLE CODE ONLY!!!. I didn't spend much time on it... the "fit algorithm" could likely be improved, and I'm sure there are some edge cases that would need some additional error handling.
答案2
得分: 0
以下是您的代码的翻译:
对于任何感兴趣的人,这是我的当前解决方案:
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "...您的文本..."
label.textAlignment = .left
label.numberOfLines = 0
label.adjustsFontForContentSizeCategory = false
label.adjustsFontSizeToFitWidth = false
label.lineBreakMode = .byWordWrapping
// 要动态调整大小,标签必须安装在具有窗口属性的某个视图中
view.addSubview(label)
constrainToOptimalDimension(label: label)
label.removeFromSuperview()
private func constrainToOptimalDimension(label: UILabel) {
// 标签现在有一个强制横向图片框架方向的宽度约束
private func constrainToOptimalDimension(label: UILabel) {
label.sizeToFit()
let oneLineWidth = Int(label.bounds.size.width)
var optimalWidth = oneLineWidth
for i in 2..<10 {
label.snp.remakeConstraints { make in
make.width.equalTo(oneLineWidth/i) // .multipliedBy(0.66)
}
label.setNeedsLayout()
label.layoutIfNeeded()
// 我的目标 - 图片框宽度为5,高度为3
if label.bounds.size.height * 5 > label.bounds.size.width * 3 {
optimalWidth = oneLineWidth/(i - 1)
break
}
}
// 使用SnapKit,但您可以使用任何您喜欢的约束引擎
label.snp.remakeConstraints { make in
make.width.equalTo(optimalWidth) // .multipliedBy(0.66)
}
label.setNeedsLayout()
return
}
}
请注意,代码中的HTML实体(如"
、<
、>
)已经被正确翻译为相应的字符。
英文:
For anyone interested, here is my current solution:
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "... your text..."
label.textAlignment = .left
label.numberOfLines = 0
label.adjustsFontForContentSizeCategory = false
label.adjustsFontSizeToFitWidth = false
label.lineBreakMode = .byWordWrapping
// to dynamically resize, the label must be installed in some view that has a window property
view.addSubview(label)
constrainToOptimalDimension(label: label)
label.removeFromSuperview() private func constrainToOptimalDimension(label: UILabel) {
// label now has a width constraint that forces a landscape picture frame orientation
private func constrainToOptimalDimension(label: UILabel) {
label.sizeToFit()
let oneLineWidth = Int(label.bounds.size.width)
var optimalWidth = oneLineWidth
for i in 2..<10 {
label.snp.remakeConstraints { make in
make.width.equalTo(oneLineWidth/i) // .multipliedBy(0.66)
}
label.setNeedsLayout()
label.layoutIfNeeded()
// my goal - a picture frame width of 5 height 3
if label.bounds.size.height * 5 > label.bounds.size.width * 3 {
optimalWidth = oneLineWidth/(i - 1)
break
}
}
// using SnapKit, but you can use any contraint engine you want
label.snp.remakeConstraints { make in
make.width.equalTo(optimalWidth) // .multipliedBy(0.66)
}
label.setNeedsLayout()
return
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论