只有 intrinsicContentSize 的 UIStackView 比例布局

UIStackView proportional layout with only intrinsicContentSize

我在 UIStackView 中排列子视图的布局时遇到问题,想知道是否有人可以帮助我了解发生了什么。

所以我有一些间距(例如 1,但这并不重要)和 .fillProportionally 分布的 UIStackView。我正在添加只有 intrinsicContentSize 为 1x1 的排列子视图(可以是任何东西,只是方形视图),我需要它们在 stackView 中按比例拉伸。

问题是,如果我添加没有实际框架的视图,只有内部尺寸,那么我得到这个错误的布局

否则,如果我添加具有相同大小框架的视图,一切都会按预期进行,

但我真的不想设置视图的框架。

我很确定这都是关于拥抱和抗压优先级的,但不知道正确答案是什么。

这是一个游乐场示例:

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

这明显是UIStackView实现的bug(即系统bug)

DonMag 已经在他的评论中给出了指向正确方向的提示:

当您将堆栈视图的 spacing 设置为 0 时,一切都会按预期进行。但是当您将它设置为任何其他值时,布局会中断。


原因如下:

ℹ️ 为了简单起见,我假设堆栈视图有

  • 一个水平轴
  • 10个排列的子视图

使用 .fillProportionally 分布 UIStackView 创建系统约束如下:

  • 对于每个排列的子视图,它添加一个等宽约束(UISV-fill-proportionally)相关使用 multiplier:

    到堆栈视图本身
    arrangedSubview[i].width = multiplier[i] * stackView.width
    

    如果您在堆栈视图中安排了 n 个子视图,您将得到 n 个这些约束。我们称它们为 proportionalConstraint[i](其中 i 表示相应视图在 arrangedSubviews 数组中的位置)。

  • 这些约束不需要(即它们的优先级不是 1000)。相反,arrangedSubviews 数组中第一个元素的约束被分配了 999 的优先级,第二个被分配了 998 的优先级等等:

    proportionalConstraint[0].priority = 999 
    proportionalConstraint[1].priority = 998 
    proportionalConstraint[2].priority = 997 
    proportionalConstraint[3].priority = 996
    ...         
    proportionalConstraint[n–1].priority = 1000 – n
    

    这意味着所需的约束将始终战胜这些比例约束!

  • 为了连接排列的子视图(可能有间距),系统还创建了 n–1 约束,称为 UISV-spacing:

    arrangedSubview[i].trailing + spacing = arrangedSubview[i+1].leading
    

    这些约束是必需的(即优先级 = 1000)。

  • (系统还会创建一些其他约束(例如,对于垂直轴以及将第一个和最后一个排列的子视图固定到堆栈视图的边缘)但我不会详细说明在这里是因为它们与理解问题无关。)

Apple's documentation.fillProportionally 分布状态:

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.

所以根据这个 proportionalConstraints 的 multiplier 应该计算如下 for spacing = 0:

  • totalIntrinsicWidth = ∑i intrinsicWidth[i]
  • multiplier[i] = intrinsicWidth[i] / totalIntrinsicWidth

如果我们的 10 个排列的子视图都具有相同的固有宽度,这将按预期工作:

multiplier[i] = 0.1

所有 proportionalConstraint。然而,一旦我们将间距更改为非零值,multiplier 的计算就会变得复杂得多,因为必须考虑间距的宽度。我计算过 multiplier[i] 的公式是:


示例:

对于堆栈视图配置如下:

  • stackView.width = 400
  • stackView.spacing = 2

以上等式将得出:

multiplier[i] = 0.0955

你可以通过加起来证明这个正确:

(10 * width) + (9 * spacing)
    = (10 * multiplier * stackViewWidth) + (9 * spacing)
    = (10 * 0.0955 * 400) + (9 * 2)
    = (0.955 * 400) + 18
    = 382 + 18
    = 400
    = stackViewWidth

但是,系统分配了一个不同的值:

multiplier[i] = 0.0917431

总宽度为

(10 * width) + (9 * spacing)
    = (10 * 0.0917431 * 400) + (9 * 2)
    = 384,97
    < stackViewWidth

很明显,这个值是错误的。

因此系统必须打破约束。当然,它打破了具有最低优先级的约束,即最后排列的子视图项的 proportionalConstraint

这就是为什么您的屏幕截图中最后排列的子视图被拉伸的原因。

如果您尝试不同的间距和堆栈视图宽度,您最终会得到各种看起来很奇怪的布局。但他们都有一个共同点: 间距始终优先。(如果您将间距设置为更大的值,如 30 或 40,您将只会看到前两个或三个排列的子视图,因为其余的 space 被所需的间距完全占据。)


总结一下:

.fillProportionally 发行版仅适用于 spacing = 0

对于其他间距,系统会使用不正确的乘数创建约束。

这破坏了布局

  • 如果乘数小于应有的值,则必须拉伸其中一个排列的子视图(最后一个)
  • 如果乘数大于应有的倍数,则必须压缩多个排列的子视图。

解决此问题的唯一方法是 "misuse" 纯 UIViews 具有所需的固定宽度约束作为视图之间的间距。 (通常,UILayoutGuide 是为此目的引入的,但您甚至不能使用它们,因为您无法将布局指南添加到堆栈视图。)

恐怕由于这个错误,没有干净的解决方案可以做到这一点。

至少从 Xcode 9.2 开始,提供的 playground 可以按预期工作,前提是初始化器和内在内容大小 both 都被注释掉了。在这种情况下,比例填充会按预期工作,即使间距 > 0

这是间距 = 5 的示例

这似乎是有道理的,因为排列的子视图没有固有的内容大小,StackView 确定它们的宽度以按比例填充指定的轴。

如果只启用了初始化器(而不是内在内容大小覆盖),那么我得到这个,它与操场上的评论不匹配,所以我想这个行为一定已经改变了,因为问题是post编辑:

我不明白这种行为,因为在我看来,使用自动布局时应该忽略手动设置框架。

如果仅启用内在内容大小覆盖(而不是初始化程序),那么我会得到源自此 post 的有问题的图像(此处间距 = 5):

从本质上讲,现在的设计是相互冲突的,无法实现,因为由于指定的固有内容大小,视图需要 1 点宽。这里的总数space应该是

24 views * 1 point/view + 23 spaces * 5 points/space = 139 < 300 = sv.bounds.width

由于 Mischa 指出的最低优先级,最后排列视图的约束被打破。

逐像素检查后,上面的前 23 个视图虽然比 1 个像素宽,但实际上是 2 或 3 个像素,除了最后一个,因此数学不太匹配,我不知道为什么,可能会四舍五入小数?

作为参考,这是在宽度为 5 的固有内容大小仍然无法满足约束的情况下的样子。