嵌套的 UIStackViews 打破约束

Nested UIStackViews Broken Constraints

我有一个复杂的视图层次结构,内置在 Interface Builder 中,带有嵌套的 UIStackViews。每次我隐藏一些内部堆栈视图时,我都会收到 "unsatisfiable constraints" 通知。我已经追踪到它:

(
    "<NSLayoutConstraint:0x1396632d0 'UISV-canvas-connection' UIStackView:0x1392c5020.top == UILabel:0x13960cd30'Also available on iBooks'.top>",
    "<NSLayoutConstraint:0x139663470 'UISV-canvas-connection' V:[UIButton:0x139554f80]-(0)-|   (Names: '|':UIStackView:0x1392c5020 )>",
    "<NSLayoutConstraint:0x139552350 'UISV-hiding' V:[UIStackView:0x1392c5020(0)]>",
    "<NSLayoutConstraint:0x139663890 'UISV-spacing' V:[UILabel:0x13960cd30'Also available on iBooks']-(8)-[UIButton:0x139554f80]>"
)

具体来说,UISV-spacing 约束:当隐藏 UIStackView 时,它的高约束得到一个 0 常量,但这似乎与内部 stackview 的间距约束冲突:它需要我的 Label 和 Button 之间有 8 个点,这与隐藏约束不可调和,因此约束崩溃。

有办法解决这个问题吗?我已经尝试递归隐藏隐藏堆栈视图的所有内部 StackView,但这会导致内容浮出屏幕的奇怪动画,并导致启动时 FPS 严重下降,但仍未解决问题。

理想情况下,我们可以将 UISV-spacing 约束的优先级设置为较低的值,但似乎没有任何方法可以做到这一点。 :)

我在隐藏之前成功地将嵌套堆栈视图的 spacing 属性 设置为 0,并在使其再次可见后恢复到正确的值。

我认为在嵌套堆栈视图上递归执行此操作是可行的。您可以将 spacing 属性 的原始值存储在字典中,稍后再恢复它。

我的项目只有一层嵌套,所以我不确定这是否会导致 FPS 问题。只要您不对间距的变化进行动画处理,我认为它不会造成太大的影响。

我在 UISV 隐藏方面遇到了类似的问题。对我来说,解决方案是将我自己的约束的优先级从 Required (1000) 降低到更低的水平。添加 UISV 隐藏约束时,它们优先,约束不再冲突。

这是隐藏嵌套堆栈视图的已知问题。

这个问题基本上有 3 种解决方案:

  1. 将间距更改为 0,但您需要记住之前的间距值。
  2. 调用 innerStackView.removeFromSuperview(),但随后您需要记住在何处插入堆栈视图。
  3. 用至少一个 999 约束将堆栈视图包装在 UIView 中。例如。 top@1000, leading@1000, trailing@1000, bottom@999.

我认为第三个选项是最好的。有关此问题、发生原因、不同解决方案以及如何实施解决方案 3 的更多信息,请参阅

这里是 Senseful 建议 #3 的实现,写成 Swift 3 class 使用 SnapKit 约束。我也尝试过覆盖属性,但是没有警告就没有让它工作,所以我会坚持包装 UIStackView:

class NestableStackView: UIView {
    private var actualStackView = UIStackView()

    override init(frame: CGRect) {
        super.init(frame: frame);
        addSubview(actualStackView);
        actualStackView.snp.makeConstraints { (make) in
            // Lower edges priority to allow hiding when spacing > 0
            make.edges.equalToSuperview().priority(999);
        }
    }

    convenience init() {
        self.init(frame: CGRect.zero);
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func addArrangedSubview(_ view: UIView) {
        actualStackView.addArrangedSubview(view);
    }

    func removeArrangedSubview(_ view: UIView) {
        actualStackView.removeArrangedSubview(view);
    }

    var axis: UILayoutConstraintAxis {
        get {
            return actualStackView.axis;
        }
        set {
            actualStackView.axis = newValue;
        }
    }

    open var distribution: UIStackViewDistribution {
        get {
            return actualStackView.distribution;
        }
        set {
            actualStackView.distribution = newValue;
        }
    }

    var alignment: UIStackViewAlignment {
        get {
            return actualStackView.alignment;
        }
        set {
            actualStackView.alignment = newValue;
        }
    }

    var spacing: CGFloat {
        get {
            return actualStackView.spacing;
        }
        set {
            actualStackView.spacing = newValue;
        }
    }
}

所以,你有这个:

问题是,当您第一次折叠内部堆栈时,您会遇到自动布局错误:

2017-07-02 15:40:02.377297-0500 nestedStackViews[17331:1727436] [LayoutConstraints] Unable to simultaneously satisfy constraints.
    Probably at least one of the constraints in the following list is one you don't want. 
    Try this: 
        (1) look at each constraint and try to figure out which you don't expect; 
        (2) find the code that added the unwanted constraint or constraints and fix it. 
(
    "<NSLayoutConstraint:0x62800008ce90 'UISV-canvas-connection' UIStackView:0x7fa57a70fce0.top == UILabel:0x7fa57a70ffb0'Top Label of Inner Stack'.top   (active)>",
    "<NSLayoutConstraint:0x62800008cf30 'UISV-canvas-connection' V:[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']-(0)-|   (active, names: '|':UIStackView:0x7fa57a70fce0 )>",
    "<NSLayoutConstraint:0x62000008bc70 'UISV-hiding' UIStackView:0x7fa57a70fce0.height == 0   (active)>",
    "<NSLayoutConstraint:0x62800008cf80 'UISV-spacing' V:[UILabel:0x7fa57a70ffb0'Top Label of Inner Stack']-(8)-[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x62800008cf80 'UISV-spacing' V:[UILabel:0x7fa57a70ffb0'Top Label of Inner Stack']-(8)-[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.

如您所述,问题是外部堆栈视图将高度 = 0 约束应用于内部堆栈视图。这与内部堆栈视图在其自己的子视图之间应用的 8 点填充约束冲突。两个约束不能同时满足。

外部堆栈视图使用此 height = 0 约束,我相信,因为它在动画时看起来比只让内部视图隐藏而不先收缩更好。

对此有一个简单的解决方法:将内部堆栈视图包装在普通 UIView 中,然后隐藏该包装器。我来演示一下。

以上是破损版本的场景大纲:

要解决此问题,select 内部堆栈视图。从菜单栏中,选择编辑器 > 嵌入 > 查看:

当我这样做时,Interface Builder 在包装器视图上创建了一个宽度约束,因此请删除该宽度约束:

接下来,在包装器的所有四个边缘和内部堆栈视图之间创建约束:

此时,布局实际上在运行时是正确的,但 Interface Builder 绘制不正确。您可以通过将内部堆栈的子级的垂直拥抱优先级设置得更高来修复它。我将它们设置为 800:

目前我们还没有真正解决无法满足的约束问题。为此,找到您刚刚创建的底部约束并将其优先级设置为低于要求。让我们将其更改为 800:

最后,您可能在视图控制器中有一个出口连接到内部堆栈视图,因为您正在更改它的 hidden 属性。更改该出口以连接到包装器视图而不是内部堆栈视图。如果您的插座类型是 UIStackView,您需要将其更改为 UIView。我的已经是 UIView 类型了,所以我只是在故事板中重新连接它:

现在,当您切换包装器视图的 hidden 属性 时,堆栈视图将显示为折叠,没有无法满足的约束警告。它看起来几乎一模一样,所以我不会费心发布另一个应用程序的 GIF 运行。

你可以找到我的测试项目in this github repository

另一种方法

尽量避免嵌套的 UIStackViews。我爱他们,几乎所有的东西都是和他们一起建造的。但是当我认识到它们秘密地添加约束时,我尝试只在最高级别使用它们并且尽可能不嵌套。通过这种方式,我可以将第二高优先级 .defaultHigh 指定为解决我的警告的间距约束。

这个优先级足以防止大多数布局问题。

当然你需要指定更多的约束,但这样你就可以完全控制它们并使你的视图布局明确。

在我的例子中,我向导航栏按钮添加了宽度和高度约束,根据上面的建议,我只向约束添加了较低的优先级。

open func customizeNavigationBarBackButton() {
        let _selector = #selector(UIViewController._backButtonPressed(_:))
        let backButtonView = UIButton(type: .custom)
        backButtonView.setImage(UIImage(named: "icon_back"), for: .normal)
        backButtonView.imageEdgeInsets = UIEdgeInsets.init(top: 0, left: -30, bottom: 0, right: 0)
        backButtonView.snp.makeConstraints { [=10=].width.height.equalTo(44).priority(900) }
        backButtonView.addTarget(self, action: _selector, for: .touchUpInside)

        let backButton = UIBarButtonItem(customView: backButtonView)
        self.navigationItem.leftBarButtonItem = backButton
    }