UIStackView con restricciones personalizadas: "No se pueden satisfacer simultáneamente las restricciones" al cambiar el eje

Aug 20 2020

Me he topado con un problema con UIStackView. Tiene que ver con el caso en que existen algunas restricciones personalizadas entre UIStackView y sus subvistas organizadas. Al cambiar el eje de la vista de pila y las restricciones personalizadas, [LayoutConstraints] Unable to simultaneously satisfy constraints.aparece el mensaje de error en la consola.

Aquí hay un ejemplo de código que puede pegar en un nuevo proyecto para observar el problema:

import UIKit

public class ViewController: UIViewController {
    private var stateToggle = false

    private let viewA: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.accessibilityIdentifier = "viewA"
        view.backgroundColor = .red
        return view
    }()

    private let viewB: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.accessibilityIdentifier = "viewB"
        view.backgroundColor = .green
        return view
    }()

    private lazy var stackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [viewA, viewB])
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.distribution = .fill
        stackView.alignment = .fill
        stackView.accessibilityIdentifier = "stackView"
        return stackView
    }()

    private lazy var viewAOneFourthOfStackViewHightConstraint = viewA.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.25)
    private lazy var viewAOneFourthOfStackViewWidthConstraint = viewA.widthAnchor.constraint(equalTo: stackView.widthAnchor, multiplier: 0.25)

    public override func viewDidLoad() {
        super.viewDidLoad()
        view.accessibilityIdentifier = "rootView"

        view.addSubview(stackView)

        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
        ])

        let button = UIButton(type: .system)
        button.frame = .init(x: 50, y: 50, width: 100, height: 44)
        button.setTitle("toggle layout", for: .normal)
        button.tintColor = .white
        button.backgroundColor = .black
        button.addTarget(self, action: #selector(buttonTap), for: .touchUpInside)
        view.addSubview(button)

        updateConstraintsBasedOnState()
    }

    @objc func buttonTap() {
        stateToggle.toggle()
        updateConstraintsBasedOnState()
    }

    private func updateConstraintsBasedOnState() {
        switch (stateToggle) {
        case true:
            stackView.axis = .horizontal
            viewAOneFourthOfStackViewHightConstraint.isActive = false
            viewAOneFourthOfStackViewWidthConstraint.isActive = true
        case false:
            stackView.axis = .vertical
            viewAOneFourthOfStackViewWidthConstraint.isActive = false
            viewAOneFourthOfStackViewHightConstraint.isActive = true
        }
    }
}

En este ejemplo, estamos creando una vista de pila con dos subvistas. Queremos que uno de ellos ocupe el 25 % del área, por lo que tenemos las restricciones para lograrlo (uno para cada orientación), pero debemos poder cambiarlos en consecuencia. Cuando ocurre el cambio (de pila vertical a horizontal en este caso), aparece el mensaje de error:

[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:0x60000089ca00 stackView.leading == UILayoutGuide:0x6000012b48c0'UIViewSafeAreaLayoutGuide'.leading   (active, names: stackView:0x7fe269d07b50 )>",
    "<NSLayoutConstraint:0x60000089c9b0 stackView.trailing == UILayoutGuide:0x6000012b48c0'UIViewSafeAreaLayoutGuide'.trailing   (active, names: stackView:0x7fe269d07b50 )>",
    "<NSLayoutConstraint:0x60000089c730 viewA.width == 0.25*stackView.width   (active, names: viewA:0x7fe269e14fd0, stackView:0x7fe269d07b50 )>",
    "<NSLayoutConstraint:0x6000008ba990 'UISV-canvas-connection' stackView.leading == viewA.leading   (active, names: stackView:0x7fe269d07b50, viewA:0x7fe269e14fd0 )>",
    "<NSLayoutConstraint:0x6000008ba9e0 'UISV-canvas-connection' H:[viewA]-(0)-|   (active, names: stackView:0x7fe269d07b50, viewA:0x7fe269e14fd0, '|':stackView:0x7fe269d07b50 )>",
    "<NSLayoutConstraint:0x6000008bab20 'UIView-Encapsulated-Layout-Width' rootView.width == 375   (active, names: rootView:0x7fe269c111a0 )>",
    "<NSLayoutConstraint:0x60000089ce60 'UIViewSafeAreaLayoutGuide-left' H:|-(0)-[UILayoutGuide:0x6000012b48c0'UIViewSafeAreaLayoutGuide'](LTR)   (active, names: rootView:0x7fe269c111a0, '|':rootView:0x7fe269c111a0 )>",
    "<NSLayoutConstraint:0x60000089cdc0 'UIViewSafeAreaLayoutGuide-right' H:[UILayoutGuide:0x6000012b48c0'UIViewSafeAreaLayoutGuide']-(0)-|(LTR)   (active, names: rootView:0x7fe269c111a0, '|':rootView:0x7fe269c111a0 )>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x6000008ba990 'UISV-canvas-connection' stackView.leading == viewA.leading   (active, names: stackView:0x7fe269d07b50, viewA:0x7fe269e14fd0 )>

Observe la restricción: "<NSLayoutConstraint:0x6000008ba9e0 'UISV-canvas-connection' H:[viewA]-(0)-| (active, names: stackView:0x7fe269d07b50, viewA:0x7fe269e14fd0, '|':stackView:0x7fe269d07b50 )>"que dice "el seguimiento de viewA debe ser el mismo que el seguimiento de la vista de pila" . Esta no es una restricción válida para el diseño del eje horizontal. Pero no es una restricción que haya agregado, es algo interno a la vista de pila ( UISV-canvas-connection).

Me parece que debido a que estamos activando/desactivando la restricción personalizada y también la vista de pila hace lo mismo internamente al cambiar de eje, hay un conflicto y un error temporalmente.

Posibles soluciones / soluciones alternativas:

  • Solución alternativa 1: haga que las restricciones personalizadas tengan priority = 999. Esta no es una buena solución, no solo porque es un truco, sino también porque en algunos casos daría lugar a otros problemas de diseño (por ejemplo, cuando viewA tiene algunos requisitos de diseño internos que entran en conflicto, como subvistas con prioridad de abrazo requerida).
  • Solución alternativa 2: elimine las vistas organizadas antes del cambio de eje y vuelva a agregarlas después del cambio de eje. Esto funciona, pero también es complicado y puede ser difícil en la práctica para algunos casos complicados.
  • Solución alternativa 3: no lo use UIStackViewen absoluto: implemente el uso de regular UIViewcomo contenedor y cree las restricciones requeridas según sea necesario.

¿Alguna idea si esto debería considerarse un error (e informar a Apple) y si hay otras soluciones (¿mejores?)?

Por ejemplo, ¿hay alguna manera de decirle a AutoLayout: "Estoy a punto de cambiar algunas restricciones y también el eje de una vista de pila; solo espere hasta que termine y luego continúe evaluando el diseño".

Respuestas

1 vacawama Aug 20 2020 at 19:11

Tal como lo veo, tanto usted como iOS tienen restricciones que debe desactivar y activar. Para hacerlo correctamente y evitar conflictos, tanto usted como iOS deben desactivar sus restricciones anteriores antes de poder comenzar a activar sus nuevas restricciones.

Aquí hay una solución que funciona. Dígale stackViewa layoutIfNeeded()después de haber desactivado su antigua restricción. Luego obtendrá sus restricciones en orden y podrá activar su nueva restricción.

private func updateConstraintsBasedOnState() {
    switch (stateToggle) {
    case true:
        stackView.axis = .horizontal
        viewAOneFourthOfStackViewHightConstraint.isActive = false
        stackView.layoutIfNeeded()
        viewAOneFourthOfStackViewWidthConstraint.isActive = true
    case false:
        stackView.axis = .vertical
        viewAOneFourthOfStackViewWidthConstraint.isActive = false
        stackView.layoutIfNeeded()
        viewAOneFourthOfStackViewHightConstraint.isActive = true
    }
}
DonMag Aug 20 2020 at 20:10

Si bien la respuesta de vacawama hará el trabajo, otras dos opciones ...

1 - Dé a las restricciones una Prioridad menor que .requiredpara permitir que el diseño automático rompa temporalmente la restricción sin quejarse.

Agrega esto en viewDidLoad():

viewAOneFourthOfStackViewHightConstraint.priority = .defaultHigh
viewAOneFourthOfStackViewWidthConstraint.priority = .defaultHigh

y déjelo updateConstraintsBasedOnState()como lo tenía originalmente, o

2 - Use diferentes niveles de prioridad e intercámbielos en lugar de desactivar/activar restricciones.

Agrega esto en viewDidLoad():

viewAOneFourthOfStackViewHightConstraint.priority = .defaultHigh
viewAOneFourthOfStackViewWidthConstraint.priority = .defaultLow
viewAOneFourthOfStackViewHightConstraint.isActive = true
viewAOneFourthOfStackViewWidthConstraint.isActive = true
    

y cambiar updateConstraintsBasedOnState()a esto:

private func updateConstraintsBasedOnState() {
    switch (stateToggle) {
    case true:
        stackView.axis = .horizontal
        viewAOneFourthOfStackViewHightConstraint.priority = .defaultLow
        viewAOneFourthOfStackViewWidthConstraint.priority = .defaultHigh
    case false:
        stackView.axis = .vertical
        viewAOneFourthOfStackViewWidthConstraint.priority = .defaultLow
        viewAOneFourthOfStackViewHightConstraint.priority = .defaultHigh
    }
}

No sé si cambiar prioridades es más o menos eficiente que cambiar activaciones. Pero podría ser algo a considerar.