4

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()
    }
JEL
  • 1,540
  • 4
  • 23
  • 51

1 Answers1

8

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()
        })
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks for helping! I tested this out and it's almost perfect! The animations you made fix the blue label but the pink label still moves "moving up and down". The blue label stays in places and only changes `alpha` and `isHidden`. Can we also do this for the pink label? – JEL Apr 06 '22 at 21:08
  • @JEL - the pink label is "moving up and down" because you're removing the vertical space taken up by the blue label, in the same way the Brown label moves up and down. Do you want the Brown label to slide up and cover both the pink and blue labels, without those labels moving or changing size? – DonMag Apr 06 '22 at 22:34
  • this is amazing! Your example code works perfectly! Thank you – JEL Apr 08 '22 at 20:10
  • Accepted answer! By the way, just for me to learn, could you explain more how this works? For example, this part is weird: `let isvBottom: NSLayoutConstraint = innerStackView.bottomAnchor.constraint(equalTo: innerStackContainer.bottomAnchor, constant: -8.0)` and `isvBottom.priority = .defaultHigh`. Like why does this work? I read your comment but not sure I would have known to do this – JEL Apr 08 '22 at 21:03
  • @JEL - I added an **Edit 2** to my answer with an explanation. – DonMag Apr 09 '22 at 14:32
  • If I could upvote this more, I would, thanks so much for such as thorough explanation and taking the time to do so! – JEL Apr 09 '22 at 15:46
  • Really appreciate the deep explanation and so many industrial wisdom behind to make it works - clipToBound, defaultHigh, ... I need to re-read the example for many times, to understand all the industrial wisdom behind it. Thank you! – Cheok Yan Cheng Apr 17 '22 at 08:57
  • Hi @DonMag, I do notice that, if I do not include the "brown three" and "grey footer", the "appear" animation somehow become incorrect. During "appear" animation, instead of starting at the bottom of "yellow header", the animation will start at top of "yellow header" - https://i.imgur.com/Z72eL7D.gif Do you know why, and how we can fix this? Thanks. – Cheok Yan Cheng Apr 17 '22 at 10:27
  • A possible "hacking" way would be placing an 0 height view below the stack of `innerStackContainer` to ensure "appearing" animation is correct. – Cheok Yan Cheng Apr 17 '22 at 10:34
  • @CheokYanCheng - there are many ways to animate "reveal / cover" ... rarely in programming and UI do we find a "one size fits all" solution. And, yes, one solution is to add another "empty" `UIView` as the last arranged subview in `outerStackContainer` (although I wouldn't call it a "hacking" way). – DonMag Apr 17 '22 at 13:24
  • @DonMag I have learnt and applied your techniques, into UICollectionView (plus an unknown required low priority bottom constraint on parent view). The outcome is almost perfect, except it doesn't have height shrinking animation during collapse. Do you mind to provide 1 or 2 opinion on this matter? Thanks - https://stackoverflow.com/questions/71903731/collapse-animation-is-imperfect-with-no-height-shrinking-animation-for-the-hidd I have tried zero height constraint, UIView.animate, ... but the height shrinking animation still not there. – Cheok Yan Cheng Apr 17 '22 at 17:04