如何在 iOS 中为自定义 属性 设置动画

How to animate a custom property in iOS

我有一个自定义 UIView,它使用 Core Graphics 调用绘制其内容。一切正常,但现在我想为影响显示的值变化设置动画。我有一个自定义 属性 来在我的自定义 UView 中实现此目的:

var _anime: CGFloat = 0
var anime: CGFloat {
    set {
        _anime = newValue
        for(gauge) in gauges {
            gauge.animate(newValue)
        }
        setNeedsDisplay()
    }
    get {
        return _anime
    }
}

我已经从 ViewController:

开始制作动画
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    self.emaxView.anime = 0.5
    UIView.animate(withDuration: 4) {
        DDLogDebug("in animations")
        self.emaxView.anime = 1.0
    }
}

这不起作用 - 动画值确实从 0.5 变为 1.0,但它会立即改变。对动漫 setter 有两次调用,一次调用值为 0.5,然后立即调用值为 1.0。如果我将 属性 更改为标准 UIView 属性 的动画,例如阿尔法,它工作正常。

我来自 Android 背景,所以整个 iOS 动画框架对我来说看起来像是黑魔法。除了预定义的 UIView 属性之外,还有什么方法可以为 属性 设置动画吗?

下面是动画视图的外观 - 它大约每 1/2 秒获得一个新值,我希望指针在这段时间内从前一个值平滑移动到下一个值。更新它的代码是:

open func animate(_ progress: CGFloat) {
    //DDLogDebug("in animate: progress \(progress)")
    if(dataValid) {
        currentValue = targetValue * progress + initialValue * (1 - progress)
    }
}

并且在更新后调用 draw() 将使它使用新的指针位置重绘,在 initialValuetargetValue

之间进行插值

如果您在 gauge.animate(newValue) 函数中修改任何自动布局约束,则需要调用 layoufIfNeeded() 而不是 setNeedsDisplay()

简短回答:使用 CADisplayLink 每 n 帧调用一次。示例代码:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    let displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
    displayLink.preferredFramesPerSecond = 50
    displayLink.add(to: .main, forMode: .defaultRunLoopMode)
    updateValues()
}

var animationComplete = false
var lastUpdateTime = CACurrentMediaTime()
func updateValues() {
    self.emaxView.animate(0);
    lastUpdateTime = CACurrentMediaTime()
    animationComplete = false
}

func animationDidUpdate(displayLink: CADisplayLink) {

    if(!animationComplete) {
        let now = CACurrentMediaTime()
        let interval = (CACurrentMediaTime() - lastUpdateTime)/animationDuration
        self.emaxView.animate(min(CGFloat(interval), 1))
        animationComplete = interval >= 1.0
    }
}

}

代码可以改进和概括,但它正在做我需要的工作。

如果完全使用 CoreGraphics 绘制,如果您想做一些数学运算,可以使用一种非常简单的方法来制作动画。幸运的是你有一个刻度可以告诉你要旋转的弧度数,所以数学是最小的并且不涉及三角函数。这样做的好处是您不必重新绘制整个背景,甚至指针。正确设置角度和东西可能有点棘手,如果以下方法不起作用,我可以提供帮助。

在draw(in rect)中正常绘制view的背景。您应该放入 CALayer 的指针。您几乎可以将指针的绘制代码(包括中心深灰色圆圈)移动到 returns UIImage 的单独方法中。该层将根据视图的框架调整大小(在布局子视图中),并且锚点必须设置为 (0.5, 0.5),这实际上是默认设置,所以您应该可以将那条线排除在外。然后你的动画方法只是改变图层的变换以根据你的需要旋转。这是我会怎么做。我将更改方法和变量名称,因为 anime 和 animate 有点太晦涩了。

因为图层属性隐式设置了持续时间为 0.25 的动画,您甚至可以在不调用动画方法的情况下逃脱。我已经有一段时间没有使用 CoreAnimation 了,所以显然要测试一下。

这里的优点是您只需将转盘的 RPM 设置为您想要的值,它就会以该速度旋转。没有人会在阅读您的代码时觉得 WTF 就是一部动漫! :) 我已经包含了 init 方法来提醒你改变图层的内容比例(或者它以低质量呈现),显然你的 init 中可能还有其他东西。

class SpeedDial: UIView {

    var pointer: CALayer!
    var pointerView: UIView!

    var rpm: CGFloat = 0 {
        didSet {
            pointer.setAffineTransform(rpm == 0 ? .identity : CGAffineTransform(rotationAngle: rpm/25 * .pi))
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        pointer = CALayer()
        pointer.contentsScale = UIScreen.main.scale

        pointerView = UIView()
        addSubview(pointerView)
        pointerView.layer.addSublayer(pointer)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        pointer = CALayer()
        pointer.contentsScale = UIScreen.main.scale

        pointerView = UIView()
        addSubview(pointerView)
        pointerView.layer.addSublayer(pointer)
    }

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }

        context.saveGState()

        //draw background with values
        //but not the pointer or centre circle

        context.restoreGState()
    }


    override func layoutSubviews() {
        super.layoutSubviews()

        pointerView.frame = bounds

        pointer.frame = bounds
        pointer.anchorPoint = CGPoint(x: 0.5, y: 0.5)

        pointer.contents = drawPointer(in: bounds)?.cgImage
    }


    func drawPointer(in rect: CGRect) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(rect.size, false, 0)
        guard let context = UIGraphicsGetCurrentContext() else { return nil }

        context.saveGState()

        // draw the pointer Image. Make sure to draw it pointing at zero. ie at 8 o'clock
        // I'm not sure what your drawing code looks like, but if the pointer is pointing
        // vertically(at 12 o'clock), you can get it pointing at zero by rotating the actual draw context like so:
        // perform this context rotation before actually drawing the pointer
        context.translateBy(x: rect.width/2, y: rect.height/2)
        context.rotate(by: -17.5/25 * .pi) // the angle judging by the dial - remember .pi is 180 degrees
        context.translateBy(x: -rect.width/2, y: -rect.height/2)



        context.restoreGState()
        let pointerImage = UIGraphicsGetImageFromCurrentImageContext()

        UIGraphicsEndImageContext()
        return pointerImage

    }

}

指针的身份变换使其指向 0 RPM,因此每次将 RPM 提高到您想要的值时,它都会旋转到该值。

编辑:测试过,有效。除了我犯了几个错误——你不需要改变图层位置,我相应地更新了代码。此外,更改层的转换会触发直接父级中的 layoutSubviews。我忘了这个。解决此问题的最简单方法是将指针层放入作为 SpeedDial 子视图的 UIView。我已经更新了代码。祝你好运!也许这有点矫枉过正,但它比对视图、背景和所有内容的整个渲染进行动画处理更具可重用性。