Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Change default StackView animation

Forgive me if explanation is not excellent. Basically, the video below shows the standard animation for hiding labels in a stack view. Notice it looks like the labels "slide" and "collapse together".

I still want to hide the labels, but want an animation where the alpha changes but the labels don't "slide". Instead, the labels change alpha and stay in place. Is this possible with stack views?

This is the code I have to animate:

    UIView.animate(withDuration: 0.5) {
      if self.isExpanded {
        self.topLabel.alpha = 1.0
        self.bottomLabel.alpha = 1.0
        self.topLabel.isHidden = false
        self.bottomLabel.isHidden = false
      } else {
        self.topLabel.alpha = 0.0
        self.bottomLabel.alpha = 0.0
        self.topLabel.isHidden = true
        self.bottomLabel.isHidden = true
      }
    } 

animation

Update 1

It seems that even without a stack view, if I animate the height constraint, you get this "squeeze" effect. Example:

    UIView.animate(withDuration: 3.0) {
      self.heightConstraint.constant = 20
      self.view.layoutIfNeeded()
    }
like image 263
JEL Avatar asked Oct 17 '25 13:10

JEL


1 Answers

Here are a couple options:

  1. Set .contentMode = .top on the labels. I've never found Apple docs that clearly describe using .contentMode with UILabel, but it works and should work.

  2. Embed the label in a UIView, constrained to the top, with Content Compression Resistance Priority set to .required, less-than-required priority for the bottom constraint, and .clipsToBounds = true on the view.

Example 1 - content mode:

class StackAnimVC: UIViewController {
    
    let stackView: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.spacing = 0
        return v
    }()
    
    let topLabel: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        return v
    }()
    
    let botLabel: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        return v
    }()
    
    let headerLabel = UILabel()
    let threeLabel = UILabel()
    let footerLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // label setup
        let colors: [UIColor] = [
            .systemYellow,
            .cyan,
            UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
            UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
            UIColor(white: 0.9, alpha: 1.0),
        ]
        for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
            v.backgroundColor = c
            v.font = .systemFont(ofSize: 24.0, weight: .light)
            stackView.addArrangedSubview(v)
        }
        
        headerLabel.text = "Header"
        threeLabel.text = "Three"
        footerLabel.text = "Footer"
        
        topLabel.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
        botLabel.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
        
        // we want 8-pts "padding" under the "collapsible" labels
        stackView.setCustomSpacing(8.0, after: topLabel)
        stackView.setCustomSpacing(8.0, after: botLabel)

        // let's add a label and a Switch to toggle the labels .contentMode
        let promptView = UIView()
        let hStack = UIStackView()
        hStack.spacing = 8
        let prompt = UILabel()
        prompt.text = "Content Mode Top:"
        prompt.textAlignment = .right
        let sw = UISwitch()
        sw.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
        hStack.addArrangedSubview(prompt)
        hStack.addArrangedSubview(sw)
        hStack.translatesAutoresizingMaskIntoConstraints = false
        promptView.addSubview(hStack)
        
        // add an Animate button
        let btn = UIButton(type: .system)
        btn.setTitle("Animate", for: [])
        btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
        btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
        
        let g = view.safeAreaLayoutGuide
        
        // add elements to view and give them all the same Leading and Trailing constraints
        [promptView, stackView, btn].forEach { v in
            
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            
            NSLayoutConstraint.activate([
                v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            ])
        }
        
        NSLayoutConstraint.activate([
            
            promptView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            stackView.topAnchor.constraint(equalTo: promptView.bottomAnchor, constant: 0.0),
            
            // center the hStack in the promptView
            hStack.centerXAnchor.constraint(equalTo: promptView.centerXAnchor),
            hStack.centerYAnchor.constraint(equalTo: promptView.centerYAnchor),
            promptView.heightAnchor.constraint(equalTo: hStack.heightAnchor, constant: 16.0),
            
            // put button near bottom
            btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            
        ])
        
    }
    
    @objc func switchChanged(_ sender: UISwitch) {
        [topLabel, botLabel].forEach { v in
            v.contentMode = sender.isOn ? .top : .left
        }
    }
    @objc func btnTap(_ sender: UIButton) {
        
        UIView.animate(withDuration: 0.5) {
            
            // toggle hidden and alpha on stack view labels
            self.topLabel.alpha = self.topLabel.isHidden ? 1.0 : 0.0
            self.botLabel.alpha = self.botLabel.isHidden ? 1.0 : 0.0
            
            self.topLabel.isHidden.toggle()
            self.botLabel.isHidden.toggle()
            
        }
        
    }
}

Example 2 - label embedded in a UIView:

class TopAlignedLabelView: UIView {
    
    let label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        self.addSubview(label)
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
        ])
        // we need bottom anchor to have
        //  less-than-required Priority
        let c = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
        c.priority = .required - 1
        c.isActive = true
        
        // don't allow label to be compressed
        label.setContentCompressionResistancePriority(.required, for: .vertical)
        
        // we need to clip the label
        self.clipsToBounds = true
    }
}

class StackAnimVC: UIViewController {
    
    let stackView: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.spacing = 0
        return v
    }()
    
    let topLabel: TopAlignedLabelView = {
        let v = TopAlignedLabelView()
        return v
    }()
    
    let botLabel: TopAlignedLabelView = {
        let v = TopAlignedLabelView()
        return v
    }()
    
    let headerLabel = UILabel()
    let threeLabel = UILabel()
    let footerLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
                
        // label setup
        let colors: [UIColor] = [
            .systemYellow,
            .cyan,
            UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
            UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
            UIColor(white: 0.9, alpha: 1.0),
        ]
        for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
            v.backgroundColor = c
            if let vv = v as? UILabel {
                vv.font = .systemFont(ofSize: 24.0, weight: .light)
            }
            if let vv = v as? TopAlignedLabelView {
                vv.label.font = .systemFont(ofSize: 24.0, weight: .light)
            }
            stackView.addArrangedSubview(v)
        }
        
        headerLabel.text = "Header"
        threeLabel.text = "Three"
        footerLabel.text = "Footer"
        
        topLabel.label.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
        botLabel.label.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
        
        // we want 8-pts "padding" under the "collapsible" labels
        stackView.setCustomSpacing(8.0, after: topLabel)
        stackView.setCustomSpacing(8.0, after: botLabel)
        
        // add an Animate button
        let btn = UIButton(type: .system)
        btn.setTitle("Animate", for: [])
        btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
        btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
        
        let g = view.safeAreaLayoutGuide
        
        // add elements to view and give them all the same Leading and Trailing constraints
        [stackView, btn].forEach { v in
            
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            
            NSLayoutConstraint.activate([
                v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            ])
        }
        
        NSLayoutConstraint.activate([
            
            stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            
            // put button near bottom
            btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            
        ])
        
    }
    
    @objc func btnTap(_ sender: UIButton) {

        UIView.animate(withDuration: 0.5) {
            
            // toggle hidden and alpha on stack view labels
            self.topLabel.alpha = self.topLabel.isHidden ? 1.0 : 0.0
            self.botLabel.alpha = self.botLabel.isHidden ? 1.0 : 0.0
            
            self.topLabel.isHidden.toggle()
            self.botLabel.isHidden.toggle()
            
        }
        
    }
}

Edit

If your goal is to have the Brown label "slide up and cover" both the Blue and Pink labels, with neither of those labels compressing or moving, take a similar approach:

  • use standard UILabel instead of the TopAlignedLabelView
  • embed the Blue and Pink labels in their own stack view
  • embed that stack view in a "container" view
  • constrain that stack view to be "top-aligned" like we did with the label in the TopAlignedLabelView

The arranged subviews of the "outer" stack view will now be:

  • Yellow label
  • "container" view
  • Brown label
  • Gray label

and to animate we'll toggle the .alpha and .isHidden on the "container" view instead of the Blue and Pink labels.

I edited the controller class -- give it a try and see if that's the effect you're after.

enter image description here

If it is, I strongly suggest you try to make those changes yourself... if you run into problems, use this example code as a guide:

class StackAnimVC: UIViewController {
    
    let outerStackView: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.spacing = 0
        return v
    }()
    
    // create an "inner" stack view
    //  this will hold topLabel and botLabel
    let innerStackView: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.spacing = 8
        return v
    }()

    // container for the inner stack view
    let innerStackContainer: UIView = {
        let v = UIView()
        v.clipsToBounds = true
        return v
    }()
    
    // we can use standard UILabels instead of custom views
    let topLabel = UILabel()
    let botLabel = UILabel()
    
    let headerLabel = UILabel()
    let threeLabel = UILabel()
    let footerLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // label setup
        let colors: [UIColor] = [
            .systemYellow,
            .cyan,
            UIColor(red: 1.0, green: 0.85, blue: 0.9, alpha: 1.0),
            UIColor(red: 0.7, green: 0.5, blue: 0.4, alpha: 1.0),
            UIColor(white: 0.9, alpha: 1.0),
        ]
        for (c, v) in zip(colors, [headerLabel, topLabel, botLabel, threeLabel, footerLabel]) {
            v.backgroundColor = c
            v.font = .systemFont(ofSize: 24.0, weight: .light)
            v.setContentCompressionResistancePriority(.required, for: .vertical)
        }
        
        // add top and bottom labels to inner stack view
        innerStackView.addArrangedSubview(topLabel)
        innerStackView.addArrangedSubview(botLabel)

        // add inner stack view to container
        innerStackView.translatesAutoresizingMaskIntoConstraints = false
        innerStackContainer.addSubview(innerStackView)
        
        // constraints for inner stack view
        //  bottom constraint must be less-than-required
        //  so it doesn't compress when the container compresses
        let isvBottom: NSLayoutConstraint = innerStackView.bottomAnchor.constraint(equalTo: innerStackContainer.bottomAnchor, constant: -8.0)
        isvBottom.priority = .defaultHigh
        
        NSLayoutConstraint.activate([
            innerStackView.topAnchor.constraint(equalTo: innerStackContainer.topAnchor, constant: 0.0),
            innerStackView.leadingAnchor.constraint(equalTo: innerStackContainer.leadingAnchor, constant: 0.0),
            innerStackView.trailingAnchor.constraint(equalTo: innerStackContainer.trailingAnchor, constant: 0.0),
            isvBottom,
        ])

        topLabel.numberOfLines = 0
        botLabel.numberOfLines = 0
        
        topLabel.text = "It seems that even without a stack view, if I animate the height constraint, you get this \"squeeze\" effect."
        botLabel.text = "I still want to hide the labels, but want an animation where the alpha changes but the labels don't \"slide\"."
        
        headerLabel.text = "Header"
        threeLabel.text = "Three"
        footerLabel.text = "Footer"

        // add views to outer stack view
        [headerLabel, innerStackContainer, threeLabel, footerLabel].forEach { v in
            outerStackView.addArrangedSubview(v)
        }
        
        // add an Animate button
        let btn = UIButton(type: .system)
        btn.setTitle("Animate", for: [])
        btn.titleLabel?.font = .systemFont(ofSize: 24.0, weight: .regular)
        btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
        
        let g = view.safeAreaLayoutGuide
        
        // add elements to view and give them all the same Leading and Trailing constraints
        [outerStackView, btn].forEach { v in
            
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            
            NSLayoutConstraint.activate([
                v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            ])
        }
        
        NSLayoutConstraint.activate([
            
            outerStackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            
            // put button near bottom
            btn.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
            
        ])
        
    }
    
    @objc func btnTap(_ sender: UIButton) {

        UIView.animate(withDuration: 0.5) {
            
            // toggle hidden and alpha on inner stack container
            self.innerStackContainer.alpha = self.innerStackContainer.isHidden ? 1.0 : 0.0
            self.innerStackContainer.isHidden.toggle()
            
        }
        
    }
}

Edit 2

A quick explanation of why this works...

Consider a typical UILabel as a subview of a UIView. We constrain the label to the view on all 4 sides with a little "padding":

aLabel.topAnchor.constraint(equalTo: aView.topAnchor, constant: 8.0),
aLabel.leadingAnchor.constraint(equalTo: aView.leadingAnchor, constant: 8.0),
aLabel.trailingAnchor.constraint(equalTo: aView.trailingAnchor, constant: -8.0),
aLabel.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: -8.0),

Now we can constrain the view's Top / Leading / Trailing -- but not Bottom or Height -- and the label's intrinsic Height will control the Height of the view.

Pretty basic.

But, if we want to "animate it out of existence," changing the Height of the view will also change the Height of the label, resulting in a "squeeze" effect. We'll also get auto-layout complaints, because the constraints cannot be satisfied.

So, we need to change the .priority of the label's Bottom constraint to allow it to remain at its intrinsic Height, while its superview's Height changes.

Each of these 4 examples uses the same Top / Leading / Trailing constraints... the only difference is what we do with the Bottom constraint:

enter image description here

For Example 1, we don't set any Bottom constraint. So, we never even see its superview and animating the Height of its superview has no effect on the label.

For Example 2, we set the "normal" Bottom constraint, and we see the "squeezing" effect.

For Example 3, we give the label's Bottom constraint .priority = .defaultHigh. The label still controls the Height of its superview... until we activate the superview's Height constraint (of zero). The superview collapses, but we've given auto-layout permission to break the Bottom constraint.

Example 4 is the same as 3, but we've also set .clipsToBounds = true on the container view so the label Height remains constant, but no longer extends outside its superview.

All of that also applies to views in a stack view when setting .isHidden on an arranged subview.

Here's the code that generates that example, if you want to inspect it and play around with the variations:

class DemoVC: UIViewController {

    var containerViews: [UIView] = []
    var heightConstraints: [NSLayoutConstraint] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let g = view.safeAreaLayoutGuide

        // create 4 container views, each with a label as a subview
        let colors: [UIColor] = [
            .systemRed, .systemGreen, .systemBlue, .systemYellow,
        ]
        colors.forEach { bkgColor in
            let thisContainer = UIView()
            thisContainer.translatesAutoresizingMaskIntoConstraints = false
            
            let thisLabel = UILabel()
            thisLabel.translatesAutoresizingMaskIntoConstraints = false

            thisContainer.backgroundColor = bkgColor
            thisLabel.backgroundColor = UIColor(red: 0.75, green: 0.9, blue: 1.0, alpha: 1.0)

            thisLabel.numberOfLines = 0
            //thisLabel.font = .systemFont(ofSize: 20.0, weight: .light)
            thisLabel.font = .systemFont(ofSize: 12.0, weight: .light)
            thisLabel.text = "We want to animate compressing the \"container\" view vertically, without it squeezing or moving this label."

            // add label to container view
            thisContainer.addSubview(thisLabel)
            
            // add container view to array
            containerViews.append(thisContainer)
            
            // add container view to view
            view.addSubview(thisContainer)
            
            NSLayoutConstraint.activate([

                // each example gets the label constrained
                //  Top / Leading / Trailing to its container view
                thisLabel.topAnchor.constraint(equalTo: thisContainer.topAnchor, constant: 8.0),
                thisLabel.leadingAnchor.constraint(equalTo: thisContainer.leadingAnchor, constant: 8.0),
                thisLabel.trailingAnchor.constraint(equalTo: thisContainer.trailingAnchor, constant: -8.0),
                
                // we'll be using different bottom constraints for the examples,
                //  so don't set it here
                //thisLabel.bottomAnchor.constraint(equalTo: thisContainer.bottomAnchor, constant: -8.0),
                
                // each container view gets constrained to the top
                thisContainer.topAnchor.constraint(equalTo: g.topAnchor, constant: 60.0),

            ])

            // setup the container view height constraints, but don't activate them
            let hc = thisContainer.heightAnchor.constraint(equalToConstant: 0.0)
            
            // add the constraint to the constraints array
            heightConstraints.append(hc)

        }
        
        // couple vars to reuse
        var prevContainer: UIView!
        var aContainer: UIView!
        var itsLabel: UIView!
        var bc: NSLayoutConstraint!
        
        // -------------------------------------------------------------------
        // first example
        //  we don't add a bottom constraint for the label
        //  that means we'll never see its container view
        //  and changing its height constraint won't do anything to the label
        
        // -------------------------------------------------------------------
        // second example
        aContainer = containerViews[1]
        itsLabel = aContainer.subviews.first
        
        // we'll add a "standard" bottom constraint
        //  so now we see its container view
        bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
        bc.isActive = true
        
        // -------------------------------------------------------------------
        // third example
        aContainer = containerViews[2]
        itsLabel = aContainer.subviews.first
        
        // add the same bottom constraint, but give it a
        //  less-than-required Priority so it won't "squeeze"
        bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
        bc.priority = .defaultHigh
        bc.isActive = true
        
        // -------------------------------------------------------------------
        // fourth example
        aContainer = containerViews[3]
        itsLabel = aContainer.subviews.first
        
        // same less-than-required Priority bottom constraint,
        bc = itsLabel.bottomAnchor.constraint(equalTo: aContainer.bottomAnchor, constant: -8.0)
        bc.priority = .defaultHigh
        bc.isActive = true
        
        // we'll also set clipsToBounds on the container view
        //  so it will "hide / reveal" the label
        aContainer.clipsToBounds = true
        
        
        // now we need to layout the views
        
        // constrain first example leading
        aContainer = containerViews[0]
        aContainer.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0).isActive = true
        
        prevContainer = aContainer
        
        for i in 1..<containerViews.count {
            aContainer = containerViews[i]
            aContainer.leadingAnchor.constraint(equalTo: prevContainer.trailingAnchor, constant: 8.0).isActive = true
            aContainer.widthAnchor.constraint(equalTo: prevContainer.widthAnchor).isActive = true
            prevContainer = aContainer
        }
        
        // constrain last example trailing
        prevContainer.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0).isActive = true
    
        // and, let's add labels above the 4 examples
        for (i, v) in containerViews.enumerated() {
            let label = UILabel()
            label.translatesAutoresizingMaskIntoConstraints = false
            label.text = "Example \(i + 1)"
            label.font = .systemFont(ofSize: 14.0, weight: .light)
            view.addSubview(label)
            NSLayoutConstraint.activate([
                label.bottomAnchor.constraint(equalTo: v.topAnchor, constant: -4.0),
                label.centerXAnchor.constraint(equalTo: v.centerXAnchor),
            ])
        }
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        heightConstraints.forEach { c in
            c.isActive = !c.isActive
        }
        UIView.animate(withDuration: 1.0, animations: {
            self.view.layoutIfNeeded()
        })
    }
    
}
like image 56
DonMag Avatar answered Oct 20 '25 05:10

DonMag



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!