更改默认的 StackView 动画

Change default StackView animation

解释得不好还请见谅。基本上,下面的视频展示了在堆栈视图中隐藏标签的标准动画。请注意,它看起来像标签“幻灯片”和“折叠在一起”。

我仍然想隐藏标签,但想要一个 alpha 变化但标签不“滑动”的动画。相反,标签会更改 alpha 并保持原位。堆栈视图可能吗?

这是我必须制作动画的代码:

    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
      }
    } 

更新 1

似乎即使没有堆栈视图,如果我为高度约束设置动画,您也会得到这种“挤压”效果。示例:

    UIView.animate(withDuration: 3.0) {
      self.heightConstraint.constant = 20
      self.view.layoutIfNeeded()
    }

这里有几个选项:

  1. 在标签上设置 .contentMode = .top。我从来没有找到明确描述使用 .contentModeUILabel 的 Apple 文档,但它有效并且 应该 有效。

  2. UIView中嵌入标签,约束在顶部,Content Compression Resistance Priority设置为.required,less-than-required优先为底部约束, 和 .clipsToBounds = true 在视图上。

示例 1 - 内容模式:

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()
            
        }
        
    }
}

示例 2 - 标签嵌入 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()
            
        }
        
    }
}

编辑

如果您的目标是让棕色标签“向上滑动并覆盖”同时蓝色和粉色标签,两者都没有那些压缩或移动的标签,采取类似的方法:

  • 使用标准 UILabel 而不是 TopAlignedLabelView
  • 将蓝色和粉色标签嵌入到它们自己的堆栈视图中
  • 在“容器”视图中嵌入那个堆栈视图
  • 限制 堆栈视图为“top-aligned”,就像我们在 TopAlignedLabelView
  • 中对标签所做的那样

“外部”堆栈视图的排列子视图现在为:

  • 黄色标签
  • “容器”视图
  • 棕色标签
  • 灰色标签

为了制作动画,我们将切换“容器”视图上的 .alpha.isHidden,而不是蓝色和粉色标签。

我编辑了控制器 class -- 试一试,看看这是否是您想要的效果。

如果是,我强烈建议您尝试自己进行这些更改...如果您 运行 遇到问题,请使用此示例代码作为指南:

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()
            
        }
        
    }
}

编辑 2

快速解释为什么这有效...

将典型的 UILabel 视为 UIView 的子视图。我们用一点“填充”将标签限制在所有 4 个边上的视图:

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),

现在我们可以限制视图的顶部/前导/尾随——但不能限制底部或高度——标签的固有高度将控制视图的高度。

很基础。

但是,如果我们想“让它不复存在”,改变视图的高度将改变高度标签,从而产生“挤压”效果。我们还会收到 auto-layout 投诉,因为无法满足约束条件。

因此,我们需要更改标签的 Bottom 约束的 .priority 以使其保持其固有高度,而其父视图的高度会发生变化。

这 4 个示例中的每一个都使用相同的 Top / Leading / Trailing 约束...唯一的区别是我们对 Bottom 约束所做的:

对于示例1,我们不设置任何底部约束。所以,我们甚至从来没有看到它的超级视图并且动画它的超级视图的高度对标签没有影响。

对于示例 2,我们设置了“正常”底部约束,我们看到了“挤压”效果。

对于示例3,我们给出标签的底部约束.priority = .defaultHigh。标签仍然控制其父视图的高度......直到我们激活父视图的高度约束(为零)。超级视图崩溃了,但我们已授予 auto-layout 打破 Bottom 约束的权限。

示例 43 相同,但我们还在容器视图上设置了 .clipsToBounds = true,因此标签高度保持不变,但不再延伸到其父视图之外。

在排列的子视图上设置 .isHidden 时,所有这些也适用于堆栈视图中的视图。

下面是生成该示例的代码,如果您想检查它并尝试各种变体:

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()
        })
    }
    
}