Home > Software engineering >  Weird horizontal shrinking animation when hiding UIButton with Configuration in UIStackView
Weird horizontal shrinking animation when hiding UIButton with Configuration in UIStackView

Time:01-18

I'm facing this weird animation issues when hiding UIButton in a StackView using the new iOS 15 Configuration. See playground:

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {
    private weak var contentStackView: UIStackView!
    
    override func viewDidLoad() {
        view.frame = CGRect(x: 0, y: 0, width: 300, height: 150)
        view.backgroundColor = .white
        
        let contentStackView = UIStackView()
        contentStackView.spacing = 8
        contentStackView.axis = .vertical
        
        for _ in 1...2 {
            contentStackView.addArrangedSubview(makeConfigurationButton())
        }
        
        let button = UIButton(type: .system)
        button.setTitle("Toggle", for: .normal)
        button.addAction(buttonAction, for: .primaryActionTriggered)
        
        view.addSubview(contentStackView)
        view.addSubview(button)
        
        contentStackView.translatesAutoresizingMaskIntoConstraints = false
        button.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            contentStackView.topAnchor.constraint(equalTo: view.topAnchor),
            contentStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            contentStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
        self.contentStackView = contentStackView
    }
    
    private var buttonAction: UIAction {
        UIAction { [weak self] _ in
            UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) {
                guard let toggleElement = self?.contentStackView.arrangedSubviews[0] else { return }
                toggleElement.isHidden.toggle()
                toggleElement.alpha = toggleElement.isHidden ? 0 : 1     
                
                self?.contentStackView.layoutIfNeeded()
            }
        }
    }
    
    private func makeSystemButton() -> UIButton {
        let button = UIButton(type: .system)
        button.setTitle("System Button", for: .normal)
        return button
    }
    
    private func makeConfigurationButton() -> UIButton {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.title = "Configuration Button"
        button.configuration = config
        return button
    }
}

PlaygroundPage.current.liveView = MyViewController()

Which results in this animation:

Button shrinks to the side when hidden

But I want the animation to look like this, where the button only shrinks vertically:

Expected button hide animation

Which you can replicate in the playground by just swapping contentStackView.addArrangedSubview(makeConfigurationButton()) for contentStackView.addArrangedSubview(makeSystemButton()).

I guess this has something to do with the stack view alignment, setting it to center gives me the desired animation, but then the buttons don't fill the stack view width anymore and setting the width through AutoLayout results in the same animation again... Also, having just one system button in the stack view results in the same weird animation, but why does it behave differently for two system buttons? What would be a good solution for this problem?

CodePudding user response:

You should add height constraint to buttons and update this constraint while animating. I edit your code just as below.

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {
    private weak var contentStackView: UIStackView!

    override func viewDidLoad() {
        view.frame = CGRect(x: 0, y: 0, width: 300, height: 150)
        view.backgroundColor = .white

        let contentStackView = UIStackView()
        contentStackView.spacing = 8
        contentStackView.axis = .vertical

        for _ in 1...2 {
            contentStackView.addArrangedSubview(makeConfigurationButton())
        }

        let button = UIButton(type: .system)
        button.setTitle("Toggle", for: .normal)
        button.addAction(buttonAction, for: .primaryActionTriggered)

        view.addSubview(contentStackView)
        view.addSubview(button)

        contentStackView.translatesAutoresizingMaskIntoConstraints = false
        button.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            contentStackView.topAnchor.constraint(equalTo: view.topAnchor),
            contentStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            contentStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),

            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        self.contentStackView = contentStackView
    }

    private var buttonAction: UIAction {
        UIAction { [weak self] _ in
            UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1, delay: 0) {
                guard let toggleElement = self?.contentStackView.arrangedSubviews[0] else { return }
                toggleElement.isHidden.toggle()
                toggleElement.alpha = toggleElement.isHidden ? 0 : 1
                toggleElement.heightAnchor.constraint(equalToConstant: toggleElement.isHidden ? 0 : 50)
                self?.contentStackView.layoutIfNeeded()
            }
        }
    }

    private func makeSystemButton() -> UIButton {
        let button = UIButton(type: .system)
        button.setTitle("System Button", for: .normal)
        return button
    }

    private func makeConfigurationButton() -> UIButton {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.heightAnchor.constraint(equalToConstant: 50)
        ])
        config.title = "Configuration Button"
        button.configuration = config
        return button
    }
}

PlaygroundPage.current.liveView = MyViewController()

CodePudding user response:

As you've seen, the built-in show/hide animation with UIStackView can be quirky (lots of other quirks when you really get into it).

It appears that, when using a button with UIButton.Configuration, the button's width changes from the width assigned by the stack view to its intrinsic width as the animation occurs.

We can get around that by giving the button an explicit height constraint -- but, what if we want to use the intrinsic height (which may not be known in advance)?

Instead of setting the constraint, set the button's Content Compression Resistance Priority::

    button.configuration = config

    // add this line
    button.setContentCompressionResistancePriority(.required, for: .vertical)

    return button

And we no longer get the horizontal sizing:

enter image description here

As you will notice, though, the button doesn't "squeeze" vertically... it gets "pushed up" outside the stack view's bounds.

We can avoid that by setting .clipsToBounds = true on the stack view:

enter image description here

If this effect is satisfactory, we're all set.

However, as we can see, the button is still not getting "squeezed." If that is the visual effect we want, we can use a custom "self-stylized" button instead of a Configuration button:

enter image description here

Of course, there is very little visual difference - and looking closely the button's text is not squeezing. If we really, really, really want that to happen, we need to animate a transform instead of using the stack view's default animation.

And... if we are taking advantage of some of the other conveniences with Configurations, using a self-stylized UIButton might not be an option.

If you want to play with the differences, here's some sample code:

class ViewController : UIViewController {
    
    var btnStacks: [UIStackView] = []
    
    override func viewDidLoad() {
        
        view.backgroundColor = .systemYellow
        
        let outerStack = UIStackView()
        outerStack.axis = .vertical
        outerStack.spacing = 12
        
        for i in 1...3 {
            let cv = UIView()
            cv.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            let label = UILabel()
            label.backgroundColor = .yellow
            label.font = .systemFont(ofSize: 15, weight: .light)
            
            let st = UIStackView()
            st.axis = .vertical
            st.spacing = 8
            
            if i == 1 {
                label.text = "Original Configuration Buttons"
                for _ in 1...2 {
                    st.addArrangedSubview(makeOrigConfigurationButton())
                }
            }
            if i == 2 {
                label.text = "Resist Compression Configuration Buttons"
                for _ in 1...2 {
                    st.addArrangedSubview(makeConfigurationButton())
                }
            }
            if i == 3 {
                label.text = "Custom Buttons"
                for _ in 1...2 {
                    st.addArrangedSubview(makeCustomButton())
                }
            }

            st.translatesAutoresizingMaskIntoConstraints = false
            cv.addSubview(st)
            NSLayoutConstraint.activate([
                
                label.heightAnchor.constraint(equalToConstant: 28.0),
                
                st.topAnchor.constraint(equalTo: cv.topAnchor),
                st.leadingAnchor.constraint(equalTo: cv.leadingAnchor),
                st.trailingAnchor.constraint(equalTo: cv.trailingAnchor),
                
                cv.heightAnchor.constraint(equalToConstant: 100.0),
                
            ])
            
            btnStacks.append(st)
            
            outerStack.addArrangedSubview(label)
            outerStack.addArrangedSubview(cv)
            outerStack.setCustomSpacing(2.0, after: label)
            
        }
        
        // a horizontal stack view to hold a label and UISwitch
        let ctbStack = UIStackView()
        ctbStack.axis = .horizontal
        ctbStack.spacing = 8
        
        let label = UILabel()
        label.text = "Clips to Bounds"
        
        let ctbSwitch = UISwitch()
        ctbSwitch.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
        
        ctbStack.addArrangedSubview(label)
        ctbStack.addArrangedSubview(ctbSwitch)
        
        // put the label/switch stack in a view so we can center it
        let ctbView = UIView()
        ctbStack.translatesAutoresizingMaskIntoConstraints = false
        ctbView.addSubview(ctbStack)
        
        // button to toggle isHidden/alpha on the first
        //  button in each stack view
        let button = UIButton(type: .system)
        button.setTitle("Toggle", for: .normal)
        button.backgroundColor = .white
        button.addAction(buttonAction, for: .primaryActionTriggered)
        
        outerStack.addArrangedSubview(ctbView)
        outerStack.addArrangedSubview(button)
        
        outerStack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(outerStack)
        
        // respect safe-area
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            outerStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            outerStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            outerStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),

            ctbStack.topAnchor.constraint(equalTo: ctbView.topAnchor),
            ctbStack.bottomAnchor.constraint(equalTo: ctbView.bottomAnchor),
            ctbStack.centerXAnchor.constraint(equalTo: ctbView.centerXAnchor),

        ])
        
    }
    
    @objc func switchChanged(_ sender: UISwitch) {
        btnStacks.forEach { v in
            v.clipsToBounds = sender.isOn
        }
    }
    
    private var buttonAction: UIAction {
        UIAction { [weak self] _ in
            UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1.0, delay: 0) {
                guard let self = self else { return }
                self.btnStacks.forEach { st in
                    st.arrangedSubviews[0].isHidden.toggle()
                    st.arrangedSubviews[0].alpha = st.arrangedSubviews[0].isHidden ? 0 : 1
                }
            }
        }
    }
    
    private func makeOrigConfigurationButton() -> UIButton {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.title = "Configuration Button"
        button.configuration = config
        return button
    }
    
    private func makeConfigurationButton() -> UIButton {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.title = "Configuration Button"
        button.configuration = config

        // add this line
        button.setContentCompressionResistancePriority(.required, for: .vertical)

        return button
    }
    
    private func makeCustomButton() -> UIButton {
        let button = UIButton()
        button.setTitle("Custom Button", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.setTitleColor(.lightGray, for: .highlighted)
        button.backgroundColor = .systemBlue
        button.layer.cornerRadius = 6
        return button
    }
    
}

Looks like this:

enter image description here

  • Related