2

I have a custom UIView that draws its contents using Core Graphics calls. All working well, but now I want to animate a change in value that affects the display. I have a custom property to achieve this in my custom UView:

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

And I have started an animation from the 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
    }
}

This doesn't work - the animated value does change from 0.5 to 1.0 but it does so instantly. There are two calls to the anime setter, once with value 0.5 then immediately a call with 1.0. If I change the property I'm animating to a standard UIView property, e.g. alpha, it works correctly.

I'm coming from an Android background, so this whole iOS animation framework looks suspiciously like black magic to me. Is there any way of animating a property other than predefined UIView properties?

Below is what the animated view is supposed to look like - it gets a new value about every 1/2 second and I want the pointer to move smoothly over that time from the previous value to the next. The code to update it is:

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

And calling draw() after it's updated will make it redraw with the new pointer position, interpolating between initialValue and targetValue

enter image description here

Clyde
  • 7,389
  • 5
  • 31
  • 57
  • What are you trying to achieve with this property change? If the property changes some aspect of the drawing performed in draw(in: rect), you won't be able to animate it like that. – twiz_ Jan 09 '17 at 04:14
  • @twiz_ So it seems. Any suggestions on how else to do it? – Clyde Jan 09 '17 at 04:22
  • Have a look at using `CAAnimation` to modify custom draw rect drawings! I'm not sure if `UIView.animate` way works for custom draw rect drawings! – Rikh Jan 09 '17 at 04:33
  • @Clyde, indeed I would if I knew what you were trying to animate, ie what aspect of the view was changing. Do you have a before and after pic or general description? It'll likely end up that you will have to put the animating aspect into a CALayer and animate the layer. Also, the code for `func animate` would be helpful – twiz_ Jan 09 '17 at 04:45
  • @twiz_ I updated the question with more detail – Clyde Jan 09 '17 at 06:53
  • For anyone googling here, it's absolutely trivial to do this. You simply make the needle **a separate UIView**. That's all there is to it. Using one line of code, rotate the needle as desired. – Fattie Dec 09 '22 at 12:27
  • Doesn't help with my fundamental question: "Is there any way of animating a property other than predefined UIView properties?" – Clyde Dec 10 '22 at 19:43

3 Answers3

4

Short answer: use CADisplayLink to get called every n frames. Sample code:

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

}

The code could be refined and generalised but it's doing the job I needed.

Clyde
  • 7,389
  • 5
  • 31
  • 57
0

You will need to call layoufIfNeeded() instead of setNeedsDisplay() if you modify any auto layout constraints in your gauge.animate(newValue) function.

https://stackoverflow.com/a/12664093/255549

Community
  • 1
  • 1
Joey
  • 2,912
  • 2
  • 27
  • 32
  • 1
    The layout is not being modified. `setNeedsDisplay()` triggers a call to `draw()` which is all that is needed to update the display. – Clyde Jan 09 '17 at 04:21
  • Yes, but the answer makes a critical point. Usually for something so simple, you'd simply animate a layer, you wouldn't literally draw it by hand. This answer says how. – Fattie Dec 08 '22 at 19:37
0

If that is drawn entirely with CoreGraphics there is a pretty simple way to animate this if you want to do a little math. Fortunately you have a scale there that tells you the number of radians exactly to rotate, so the math is minimal and no trigonometry is involved. The advantage of this is you won't have to redraw the entire background, or even the pointer. It can be a bit tricky to get angles and stuff right, I can help out if the following doesn't work.

Draw the background of the view normally in draw(in rect). The pointer you should put into a CALayer. You can pretty much just move the draw code for the pointer, including the centre dark gray circle into a separate method that returns a UIImage. The layer will be sized to the frame of the view (in layout subviews), and the anchor point has to be set to (0.5, 0.5), which is actually the default so you should be ok leaving that line out. Then your animate method just changes the layer's transform to rotate according to what you need. Here's how I would do it. I'm going to change the method and variable names because anime and animate were just a bit too obscure.

Because layer properties implicitly animate with a duration of 0.25 you might be able to get away without even calling an animation method. It's been a while since I've worked with CoreAnimation, so test it out obviously.

The advantage here is that you just set the RPM of the dial to what you want, and it will rotate over to that speed. And no one will read your code and be like WTF is _anime! :) I have included the init methods to remind you to change the contents scale of the layer (or it renders in low quality), obviously you may have other things in your 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

    }

}

The pointer's identity transform has it pointing at 0 RPM, so every time you up the RPM to what you want, it will rotate up to that value.

edit: tested it, it works. Except I made a couple errors - you don't need to change the layers position, I updated the code accordingly. Also, changing the layer's transform triggers layoutSubviews in the immediate parent. I forgot about this. The easiest way around this is to put the pointer layer into a UIView that is a subview of SpeedDial. I've updated the code. Good luck! Maybe this is overkill, but its a bit more reusable than animating the entire rendering of the view, background and all.

twiz_
  • 1,178
  • 10
  • 16
  • Thanks for the detailed answer. Yes, it is all done with Core Graphics, and the CGContext is scaled and translated so that I'm drawing in a 100x100 box centered on the needle. What about animating the numeric value? That requires redrawing each animation cycle anyway. Also, there are multiple gauges like this in the same UIView, all getting updated at the same time, so it makes sense to animate them all together. Adding multiple subviews is going to get messy - right now there is one class to draw the gauges, and it's very lightweight, with one parameterised instance per gauge. – Clyde Jan 09 '17 at 20:14
  • I also have other elements that require animation, which can't be done with standard UIView or layer properties - e.g. bars that change length and colour. – Clyde Jan 09 '17 at 20:17
  • No sweat. Bars that change length and color are best animated as CAShapeLayers. Changing the RPM text - I would just add a UILabel subview to the speed dial, positioned accordingly, and change the value in the didSet clause of rpm variable. – twiz_ Jan 09 '17 at 20:37
  • It sounds like you have all of these already drawn up, so the code above would require quite a bit of work, in which case maybe the CADisplayLink is better. But changing the RPM values or multiple gauges won't be too much of an issue. It would be a lot less intensive to just change a layer's transform than redraw the whole gauge every time. – twiz_ Jan 09 '17 at 20:39
  • I doubt there's much saving in performance using subviews - the gauge background is predrawn and rendered as an image, and drawing the needle is just two simple paths and an ellipse. Whichever way it's done it all should be handed off to the graphics processor anyway. The puzzle is the lack of a general purpose animation framework like there is with Android. The app is [already working on Android](https://www.indiegogo.com/projects/bluemax-bluetooth-engine-monitoring-for-avidyne/x/15827273#/) Using `CADisplayLink` will work fine, but it is a bit of a hack. – Clyde Jan 09 '17 at 21:13
  • Draw the needle into a layer and just update the transform. Sorry I'm unfamiliar with Android. CoreAnimation was a pain to figure out, but isn't that tough in the end. – twiz_ Jan 09 '17 at 21:17