在UIView.animate期间如何为形状图层自定义的UIView添加动画效果?

7

想象一种高而瘦的视图,我们可以说它看起来像一把梯子。

它高达100个单位。我们对其进行动画处理,使其达到整个屏幕的高度。因此,伪代码如下:

func expandLadder() {
view.layoutIfNeeded()
UIView.animate(withDuration: 4 ...
  ladderTopToTopOfScreenConstraint = 0
  ladderBottomToBottomOfScreenConstraint = 0
  view.layoutIfNeeded()
}

没问题。
不过,我们在 layoutSubviews 中绘制梯子。 (我们的梯子只是一条粗黑线。)
@IBDesignable class LadderView: UIView {
override func layoutSubviews() {
  super.layoutSubviews()
  setup()
}

func setup() {
  heightNow = frame size height
  print("I've recalculated the height!")
  shapeLayer ...
  topPoint = (0, 0)
  bottomPoint = (0, heightNow)
  p.addLines(between: [topPoint, bottomPoint])
  shapeLayer!.path = p
  shapeLayer add a CABasicAnimation or whatever
  layer.addSublayer(shapeLayer!)
}

没问题。

所以我们只是创建一条填充视图高度“heightNow”的线段。

当然,问题在于,当您这样做时...

func expandLadder() {
view.layoutIfNeeded()
UIView.animate(withDuration: 4 ...
  ladderToTopOfScreenConstraint = 0
  ladderBottomOfScreenConstraint = 0
  view.layoutIfNeeded()
}

当你这样做时,LayoutSubviews(或setBounds、draw#rect或其他任何你能想到的东西)只在UIView.animate动画之前和之后调用。这真的很麻烦。

在示例中,“实际”的梯子,即shapeLayer,在你动画视图L的大小之前会跳转到下一个值。也就是说:“我已经重新计算了高度!”在UIView.animate动画之前和之后只被调用一次。假设你不剪切子视图,你会在每个L长度的动画之前看到“错误的,即将到来的”大小。

解决方案是什么?


顺便说一句,当然你可以使用draw#rect并自己进行动画,即使用CADisplayLink(如Josh所提到的)。所以没问题。只是使用图层/路径绘制形状更加容易:我的问题是如何使示例中的setup()代码在涉及的视图约束的UIView动画的每个步骤中运行。


谢谢@JoshCaswell,这两个似乎相关 - 不过真的很奇怪,为什么不能只是“在每个步骤中运行layoutSubviews来执行UIView动画” - 你明白吗?我会仔细研究那些答案,谢谢。 - Fattie
2个回答

4
你提到的行为与iOS动画的工作方式有关。具体而言,当您在UIAnimation块中设置视图的框架时(这就是layoutIfNeeded所做的——它强制按照自动布局规则同步重新计算框架),视图的框架立即更改为新值,该值是动画结束时明显的值(在layoutIfNeeded之后添加打印语句,可以看到这是真的)。顺便说一句,直到调用layoutIfNeeded,更改约束不会产生任何效果,因此您甚至不需要将约束更改放在动画块中。
然而,在动画期间,系统显示演示层而不是后备层,并且它从演示层插值出框架的值,从初始值到最终值,持续时间为动画时间长度。这使得看起来像是视图正在移动,尽管检查值会发现视图已经达到了其最终位置。
因此,如果您想在动画进行时获得屏幕上图层的实际坐标,则必须使用view.layer.presentationLayer来获得它们。请参阅:https://developer.apple.com/reference/quartzcore/calayer/1410744-presentationlayer 这并不能完全解决您的问题,因为我假设您正在尝试随着时间的推移产生更多的图层。如果无法使用简单插值,则最好在开始时生成所有图层,然后使用关键帧动画在动画进行到一定时间间隔时打开它们。您可以基于view.layer.presentationLayer.bounds来确定图层位置(您的子层位于其超级层的坐标系中,因此不要使用frame)。如果不知道您要完成什么任务,很难提供确切的解决方案。
另一个可能的解决方案是使用CADisplayLink的drawRect,它允许您根据需要重新绘制每个帧,因此非常适用于不能轻松从帧到帧插值的事物(即游戏或您的情况下添加/删除和动画几何形状作为时间函数)。
编辑:这里有一个示例Playground,展示了如何与视图并行动画层。
    //: Playground - noun: a place where people can play

    import UIKit
    import PlaygroundSupport

    class V: UIViewController {
        var a = UIView()
        var b = CAShapeLayer()
        var constraints: [NSLayoutConstraint] = []
        override func viewDidLoad() {
            view.addSubview(a)
            a.translatesAutoresizingMaskIntoConstraints = false
            constraints = [
                a.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
                a.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100),
                a.widthAnchor.constraint(equalToConstant: 200),
                a.heightAnchor.constraint(equalToConstant: 200)

            ]
            constraints.forEach {$0.isActive = true}
            a.backgroundColor = .blue
        }

        override func viewDidAppear(_ animated: Bool) {
            b.path = UIBezierPath(ovalIn: a.bounds).cgPath
            b.fillColor = UIColor.red.cgColor
            a.layer.addSublayer(b)
            constraints[3].constant = 400
            constraints[2].constant = 400
            let oldBounds = a.bounds
            UIView.animate(withDuration: 3, delay: 0, options: .curveLinear, animations: {
                self.view.layoutIfNeeded()
            }, completion: nil)
            let scale = a.bounds.size.width / oldBounds.size.width
            let animation = CABasicAnimation(keyPath: "transform.scale")
            animation.fromValue = 1
            animation.toValue = scale
            animation.duration = 3
            b.add(animation, forKey: "scale")
            b.transform = CATransform3DMakeScale(scale, scale, 1)

        }
    }

    let v = V()
    PlaygroundPage.current.liveView = v

Josh:关于presentationLayer的观点很好,谢谢。但是,我仍然不知道如何让它在UIView动画的“每一步”中运行setup()。你知道吗? - Fattie
检查它! - Fattie

-1

layoutIfNeeded() 仅在需要时调用 layoutSubviews()。我在你的代码中没有看到调用 setNeedsLayout()。这是在没有约束条件下完成的方式!如果您有约束条件,应该调用 setNeedsUpdateConstraints() 并在您的 animationBlock 中使用 layoutIfNeeded

view.setNeedsUpdateConstraints()
UIView.animate(...) {
    ...
    view.layoutIfNeeded()
}

这不正确。 setNeedsLayout执行异步重新计算布局并向下级联视图层次结构。layoutIfNeeds执行同步(即立即)重新计算布局并向下级联视图层次结构(这是用于约束动画的正确方法,因为您需要在动画块中发生框架更改)。setNeedsUpdateConstraints执行异步约束重新计算并向上移动视图层次结构。文档在这一点上非常糟糕,但我很少发现updateConstraints有用。 - Josh Homann
实际上,它既不是同步的也不是异步的。setNeedsLayout只是为视图设置一个标志,在下一个CFRunLoop事件中更新视图。如果帧更新尚未发生,则使用setNeedsLayout一次或多次都没有关系。正如文档中所述,如果您想要立即重新布局视图,则必须同时使用setNeedsLayout(); layoutIfNeeded();(对于约束也是如此)。使用layoutIfNeeded会消耗电池并导致帧丢失,这就是为什么唯一可以使用它的地方是在动画块内部,因为它可以自我管理并在CFRunLoop事件中运行。 - farzadshbfn
所有与UI相关的事件都在主线程上运行。 CFRunLoop事件每秒最多调用60次,并且每个事件都有16ms(实际上为13ms,其中3ms为系统)的时间来计算包括AutoLayout和框架更改在内的所有内容。使用layoutIfNeeded将立即调用CFRunLoop,这可能会导致所有runloop事件被移动,从而导致帧丢失,并且调用超过每分钟60次的事件,这也会消耗电池。真的不建议直接使用layoutIfNeeded。(再次强调,在动画块中使用是可以的) - farzadshbfn
很抱歉,这似乎完全是错误的,与问题无关。我进行了广泛的测试,以及其他变化的 setNeedsUpdateConstraints,但 setNeedsUpdateConstraints 似乎与手头的问题无关 - 如果我没有清楚地解释问题,那么请原谅。 - Fattie
@Fattie 你是对的,我误解了你的问题。@JoshHomann的答案(presentationLayer)是正确的方法。 - farzadshbfn

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接