skip to Main Content

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?

2

Answers


  1. 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()
    
    Login or Signup to reply.
  2. 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


    Edit

    Quick example of another "quirk" when it comes to hiding a stack view’s arranged subview (excess code in here, but I stripped down the above example):

    class MyViewController : UIViewController {
        
        var btnStacks: [UIStackView] = []
        
        override func viewDidLoad() {
            
            view.backgroundColor = .systemYellow
            
            let outerStack = UIStackView()
            outerStack.axis = .vertical
            outerStack.spacing = 12
            
            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
            
            let colors: [UIColor] = [
                .cyan, .green, .yellow, .orange, .white
            ]
            
            label.text = "Labels"
            for j in 0..<colors.count {
                let v = UILabel()
                v.text = "Label"
                v.textAlignment = .center
                v.backgroundColor = colors[j]
                if j == 2 {
                    v.text = "Height Constraint = 80.0"
                    v.heightAnchor.constraint(equalToConstant: 80.0).isActive = true
                }
                st.addArrangedSubview(v)
            }
            
            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: 300.0),
                
            ])
            
            btnStacks.append(st)
            
            outerStack.addArrangedSubview(label)
            outerStack.addArrangedSubview(cv)
            outerStack.setCustomSpacing(2.0, after: label)
            
            
            // 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(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),
                
            ])
            
        }
        
        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[2].isHidden.toggle()
                    }
                }
            }
        }
        
    }
    

    When this is run and the "Toggle" button is tapped, it will be painfully obvious what’s "not-quite-right."

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search