在iOS UIView Swift中制作一个扩张式图形的动画

7

我希望在一个UIView中创建一个自定义的动态绘图,使用CGContext,这样当我对它执行UIView动画时,绘图将在动画/插值期间被渲染。我尝试使用自定义的CALayer并覆盖其draw方法,但失败了。我尝试通过子类化UIView draw(_ in:CGRect)来实现,但只会绘制第一次。这就是我想要做的:

class RadioWaveView : UIView {
    override func draw(_ in:CGRect) {
        // draw something, for example an ellipse based on frame size, something like this:
        // let context = CGGraphicsCurrentContext()
        // context.addEllipse(self.frame)
        // Unfortunately this method is called only once, not during animation
    }
}

viewDidAppear()方法中:

UIView.animate(withDuration: 5, delay: 1, options: [.repeat], animations: {
        self.myRadioWaveView.frame = CGRect(x:100,y:100,width:200,heigh:300)
    }, completion: { flag in
    })

动画的实现是因为我设置了myRadioWaveView的背景颜色并在屏幕上看到它扩展。在draw中放置断点表明它只执行一次。
编辑:本质上,我正在问这个问题:我们如何创建一个自定义UIView并使用UIView.animate()来动态渲染该视图?让我们保留UIView.animate()方法,如何在UIView内绘制一个扩展的圆形(或任何其他自定义绘制)?
编辑:这里是我创建的一个自定义类,仅绘制三角形。当我对其边界进行动画处理时,三角形会随着视图底层图像的缩放而缩放,但在动画期间不会调用绘制方法。我不想要那样;我希望每帧动画都能重新绘制绘图。
open class TriangleView : UIView {

override open func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()!
    let path = CGMutablePath()
    path.move(to: CGPoint.zero)
    path.addLine(to: CGPoint(x:self.frame.width,y:0))
    path.addLine(to: CGPoint(x:self.frame.width/2,y:self.frame.height))
    path.addLine(to: CGPoint.zero)
    path.closeSubpath()
    context.beginPath()
    context.setStrokeColor(UIColor.white.cgColor)
    context.setLineWidth(3)
    context.addPath(path)
    context.closePath()
    context.strokePath()
}

}
3个回答

13

我一直在研究你的问题,这种方法是使用CABasicAnimationCAShapeLayer。我定义了一个自定义的UIView类来完成这项工作,接下来我将添加更多改进@IBDesignable@IBInspectable

import UIKit

class RadioWaveAnimationView: UIView {

    var animatableLayer : CAShapeLayer?

    override func awakeFromNib() {
        super.awakeFromNib()
        self.layer.cornerRadius = self.bounds.height/2

        self.animatableLayer = CAShapeLayer()
        self.animatableLayer?.fillColor = self.backgroundColor?.cgColor
        self.animatableLayer?.path = UIBezierPath(roundedRect: self.bounds, cornerRadius: self.layer.cornerRadius).cgPath
        self.animatableLayer?.frame = self.bounds
        self.animatableLayer?.cornerRadius = self.bounds.height/2
        self.animatableLayer?.masksToBounds = true
        self.layer.addSublayer(self.animatableLayer!)
        self.startAnimation()
    }


    func startAnimation()
    {
        let layerAnimation = CABasicAnimation(keyPath: "transform.scale")
        layerAnimation.fromValue = 1
        layerAnimation.toValue = 3
        layerAnimation.isAdditive = false

        let layerAnimation2 = CABasicAnimation(keyPath: "opacity")
        layerAnimation2.fromValue = 1
        layerAnimation2.toValue = 0
        layerAnimation2.isAdditive = false

        let groupAnimation = CAAnimationGroup()
        groupAnimation.animations = [layerAnimation,layerAnimation2]
        groupAnimation.duration = CFTimeInterval(2)
        groupAnimation.fillMode = kCAFillModeForwards
        groupAnimation.isRemovedOnCompletion = true
        groupAnimation.repeatCount = .infinity

        self.animatableLayer?.add(groupAnimation, forKey: "growingAnimation")
    }
    /*
    // Only override draw() if you perform custom drawing.
    // An empty implementation adversely affects performance during animation.
    override func draw(_ rect: CGRect) {
        // Drawing code
    }
    */

}

结果

enter image description here

希望这有所帮助。


这是另一种答案。您是否知道为什么在我的原始问题中,在UIView animate()期间未调用覆盖的draw()方法? - andrewz
我很感激你在这个答案中所付出的努力,但这不是我正在寻找的答案——扩展圆形动画不应该如此困难。必须有一种更简单的解决方案,可以使用UIView.animate()静态方法。 - andrewz

2
两个答案.. 首先,如果你真的想在每次绘制时调用drawRect,你应该使用CADisplayLink并手动动画化视图的框架。 其次: 如果你不想这样做但仍然希望它平滑地绘制,则将contentMode设置为scaleAspectFit。这将使它与UIView.animateWithDuration平滑地绘制。然而,它不会在每个周期调用drawRect,而CADisplayLink解决方案会。
使用一个CADisplayLink来更新边界框。添加needsDisplayOnBoundsChange = true,以便在边界改变时重新绘制视图。

http://i.imgur.com/JjarYsq.gif

enter image description here

例子:

//
//  ViewController.swift
//  StackOverflow
//
//  Created by Brandon T on 2017-07-22.
//  Copyright © 2017 XIO. All rights reserved.
//

import UIKit

class RadioWave : UIView {

    private var dl: CADisplayLink?
    private var startTime: CFTimeInterval!
    private var fromFrame: CGRect!
    private var toFrame: CGRect!
    private var duration: CFTimeInterval!
    private var completion: (() -> Void)?

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

        self.isOpaque = false
        self.layer.needsDisplayOnBoundsChange = true;
        self.fromFrame = frame;
        self.toFrame = frame
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func animateToFrame(frame: CGRect, duration: CFTimeInterval, completion:(() -> Void)? = nil) {
        self.dl?.remove(from: .current, forMode: .commonModes)
        self.dl?.invalidate()
        self.dl = CADisplayLink(target: self, selector: #selector(onUpdate(dl:)))

        self.completion = completion
        self.fromFrame = self.frame
        self.toFrame = frame
        self.duration = duration
        self.startTime = CACurrentMediaTime()
        self.dl?.add(to: .current, forMode: .commonModes)
    }

    override func draw(_ rect: CGRect) {
        let ctx = UIGraphicsGetCurrentContext()
        ctx?.clear(rect)

        ctx?.addEllipse(in: rect)
        ctx?.clip()
        ctx?.setFillColor(UIColor.blue.cgColor)
        ctx?.fill(rect)
    }

    @objc
    func onUpdate(dl: CADisplayLink) {
        let dt = CGFloat((dl.timestamp - self.startTime) / self.duration)

        if (dt > 1.0) {
            self.frame = self.toFrame
            self.dl?.remove(from: .current, forMode: .commonModes)
            self.dl?.invalidate()
            self.dl = nil

            completion?()
            completion = nil
            return;
        }

        var frame: CGRect! = self.toFrame;
        frame.origin.x = (self.toFrame.origin.x - self.fromFrame.origin.x) * dt + fromFrame.origin.x
        frame.origin.y = (self.toFrame.origin.y - self.fromFrame.origin.y) * dt + fromFrame.origin.y
        frame.size.width = (self.toFrame.size.width - self.fromFrame.size.width) * dt + fromFrame.size.width
        frame.size.height = (self.toFrame.size.height - self.fromFrame.size.height) * dt + fromFrame.size.height
        self.frame = frame
    }
}

class ViewController: UIViewController {

    var radioWave: RadioWave!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.radioWave = RadioWave(frame: CGRect(x: self.view.center.x - 10.0, y: self.view.center.y - 10.0, width: 20.0, height: 20.0))

        self.view.addSubview(self.radioWave)

        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            let smallFrame = CGRect(x: self.view.center.x - 10.0, y: self.view.center.y - 10.0, width: 20.0, height: 20.0)
            let largeFrame = CGRect(x: self.view.center.x - 100.0, y: self.view.center.y - 100.0, width: 200.0, height: 200.0)

            self.radioWave.animateToFrame(frame: largeFrame, duration: 2.0, completion: {

                self.radioWave.animateToFrame(frame: smallFrame, duration: 2.0)

                })
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

通常情况下,只有当视图边界发生变化时才会重新绘制视图。在 iOS 上的动画实际上只是您的视图的快照被插值(呈现层)。这样做可以大大提高性能,而不必在每个动画步骤中重新绘制和布局视图。
使用 [UIView animateWithDuration] 动画绘制一个圆形。
class RadioWave : UIView {

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

        self.isOpaque = false
        self.layer.needsDisplayOnBoundsChange = true
        self.contentMode = .scaleAspectFit
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ rect: CGRect) {
        let ctx = UIGraphicsGetCurrentContext()
        ctx?.clear(rect)

        ctx?.addEllipse(in: rect)
        ctx?.clip()
        ctx?.setFillColor(UIColor.blue.cgColor)
        ctx?.fill(rect)
    }
}

class ViewController: UIViewController {

    var radioWave: RadioWave!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.radioWave = RadioWave(frame: CGRect(x: self.view.center.x - 10.0, y: self.view.center.y - 10.0, width: 20.0, height: 20.0))

        self.view.addSubview(self.radioWave)

        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            let largeFrame = CGRect(x: self.view.center.x - 100.0, y: self.view.center.y - 100.0, width: 200.0, height: 200.0)

            self.radioWave.setNeedsDisplay()

            UIView.animate(withDuration: 2.0, delay: 0.0, options: [.layoutSubviews, .autoreverse, .repeat], animations: {

                self.radioWave.frame = largeFrame
            }, completion: { (completed) in

            })
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

上述代码使用了contentMode为scaleAspectFit,这允许动画在缩放时保持清晰而不会模糊!这是我所知道的唯一一种绘制平滑圆形动画的方法。它只会在每个动画的开始和结束时调用 drawRect 方法。

我很感谢你在这个答案上付出的辛勤努力,但这不是我要找的答案——扩展圆形动画不应该如此困难。必须有一种更简单的解决方案,可以使用UIView.animate()静态方法。 - andrewz
@andrewz; 我添加了使用 UIView.animateWithDuration 的代码,通过设置 contentMode 解决了模糊问题。 - Brandon
是的,我希望在每一帧都调用draw()函数...现在我意识到使用CADisplayLink是实现这一目标的唯一方法。我之前没有意识到的是UIKit在动画期间的工作方式是尝试通过对已经组合好的图像执行变换和重新绘制动态绘图(如贝塞尔曲线)来优化,而不是在每一帧重新创建绘图。我猜重新创建绘图需要重新分配一个图像,这将是昂贵的。 - andrewz

2

请使用最简单的方式

完整示例代码

import UIKit

class ViewController: UIViewController {
    
    
    @IBOutlet weak var animatableView: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        //Circle
        //animatableView.layer.cornerRadius = animatableView.bounds.size.height/2
        //animatableView.layer.masksToBounds = true
        //triangle
        self.applyTriangle(givenView: animatableView)
        
        UIView.animate(withDuration: 2, delay: 1, options: [.repeat], animations: {
            self.animatableView.transform = CGAffineTransform(scaleX: 2, y: 2)
        }) { (finished) in
            self.animatableView.transform = CGAffineTransform.identity
        }
        
    }
    
    func applyTriangle(givenView: UIView){
        
        let bezierPath = UIBezierPath()
        bezierPath.move(to: CGPoint(x: givenView.bounds.width / 2, y: givenView.bounds.width))
        bezierPath.addLine(to: CGPoint(x: 0, y: 0))
        bezierPath.addLine(to: CGPoint(x: givenView.bounds.width, y: 0))
        bezierPath.close()
        
        let shapeLayer = CAShapeLayer(layer: givenView.layer)
        shapeLayer.path = bezierPath.cgPath
        shapeLayer.frame = givenView.bounds
        shapeLayer.masksToBounds = true
        givenView.layer.mask = shapeLayer
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }


}

结果

这里输入图片描述

希望这可以帮助到您


也许我应该尝试画一个三角形——你能改变你的答案来制作一个展开的三角形动画吗?实际上,我正在尝试制作一个自定义绘图的动画,圆只是一个例子。 - andrewz
为了使三角形正常工作,您只需要在UIView中自定义绘制一个三角形,它必须正常工作。如果不正常,请告诉我好吗? - Reinier Melian
你会如何在你的例子中编码绘制一个三角形?我尝试重写draw()方法,但那并不起作用。 - andrewz
@andrewz 看一下 - Reinier Melian
我理解你的做法 - 这看起来完全符合我的要求。然而,这与我理解的解决方案不同。图纸并没有重新创建,而是重新绘制,但这没关系。谢谢。 - andrewz

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