Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

iOS: Animating a circle slice into a wider one

Core-Animation treats angles as described in this image: (image from http://btk.tillnagel.com/tutorials/rotation-translation-matrix.html)

EDIT: Adding an animated gif to explain better what I'm needing:

enter image description hereenter image description here

I need to animate a slice to grow wider, starting at 300:315 degrees, and ending 300:060.

To create each slice I'm using this function:

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}
func createSlice(angle1:CGFloat, angle2:CGFloat) -> UIBezierPath! {
    let path: UIBezierPath = UIBezierPath()
    let width: CGFloat = self.frame.size.width/2
    let height: CGFloat = self.frame.size.height/2
    let centerToOrigin: CGFloat = sqrt((height)*(height)+(width)*(width));
    let ctr: CGPoint = CGPoint(x: width, y: height)
    path.move(to: ctr)
    path.addArc( withCenter: ctr,
                 radius: centerToOrigin,
                 startAngle: CGFloat(angle1).toRadians(),
                 endAngle: CGFloat(angle2).toRadians(),
                 clockwise: true
    )
    path.close()
    return path
}

I can now create the two slices and a sublayer with the smaller one, but I can't find how to proceed from this point:

func doStuff() {
    path1 = self.createSlice(angle1: 300,angle2: 315)
    path2 = self.createSlice(angle1: 300,angle2: 60)

    let shapeLayer = CAShapeLayer()
    shapeLayer.path = path1.cgPath
    shapeLayer.fillColor = UIColor.cyan.cgColor
    self.layer.addSublayer(shapeLayer)

I would highly appreciate any help here!

like image 378
ishahak Avatar asked Jul 03 '17 13:07

ishahak


2 Answers

Only a single color

If you want to animate the angle of a solid color filled pie segment like the one in your question, then you can do it by animating the strokeEnd of a CAShapeLayer.

The "trick" here is to make a very wide line. More specifically, you can create a path that is just an arc (the dashed line in the animation below) at half of the intended radius and then giving it the full radius as its line width. When you animate stroking that line it looks like the orange segment below:

Animating the stroke end of a shape layer

Depending on your use case, you can either:

  • create a path from one angle to the other angle and animate stroke end from 0 to 1
  • create a path for a full circle, set stroke start and stroke end to some fraction of the circle, and animate stroke end from the start fraction to the end fraction.

If your drawing is just a single color like this, then this will be the smallest solution to your problem.

However, if your drawing is more complex (e.g. also stroking the pie segment) then this solutions simply won't work and you'll have to do something more complex.


Custom drawing / Custom animations

If your drawing of the pie segment is any more complex, then you'll quickly find yourself having to create a layer subclass with custom animatable properties. Doing so is a bit more code - some of which might look a bit unusual1 - but not as scary as it might sound.

  1. This might be one of those things that is still more convenient to do in Objective-C.

Dynamic properties

First, create a layer subclass with the properties you're going to need. In Objective-C parlance these properties should be @dynamic, i.e. not synthesized. This isn't the same as dynamic in Swift. Instead we have to use @NSManaged.

class PieSegmentLayer : CALayer {
    @NSManaged var startAngle, endAngle, strokeWidth: CGFloat
    @NSManaged var fillColor, strokeColor: UIColor?

    // More to come here ...
}

This allows Core Animation to handle these properties dynamically allowing it to track changes and integrate them into the animation system.

Note: a good rule of thumb is that these properties should all be related to drawing / visual presentation of the layer. If they aren't then it's quite likely that they don't belong on the layer. Instead they could be added to a view that in turn uses the layer for its drawing.

Copying layers

During the custom animation, Core Animation is going to want to create and render different layer configurations for different frames. Unlike most of Apple's other frameworks, this happens using the copy constructor init(layer:). For the above five properties to be copied along, we need to override init(layer:) and copy over their values.

In Swift we also have to override the plain init() and init?(coder).

override init(layer: Any) {
    super.init(layer: layer)
    guard let other = layer as? PieSegmentLayer else { return }

    fillColor   = other.fillColor
    strokeColor = other.strokeColor
    startAngle  = other.startAngle
    endAngle    = other.endAngle
    strokeWidth = other.strokeWidth
}

override init() {
    super.init()
}

required init?(coder aDecoder: NSCoder) {
    return nil
}

Reacting to change

Core Animation is in many ways built for performance. One of the ways it achieves this is by avoiding unnecessary work. By default, a layer won't redraw itself when a property changes. But these properties is used for drawing, and we want the layer to redraw when any of them changes. To do that, we need to override needsDisplay(forKey:) and return true if the key was one of these properties.

override class func needsDisplay(forKey key: String) -> Bool {
    switch key {
    case #keyPath(startAngle), #keyPath(endAngle),
         #keyPath(strokeWidth),
         #keyPath(fillColor), #keyPath(strokeColor):
        return true

    default:
        return super.needsDisplay(forKey: key)
    }
}

Additionally, If we want the layers default implicit animations for these properties, we need to override action(forKey:) to return a partially configured animation object. If we only want some properties (e.g. the angles) to implicitly animate, then we only need to return an animation for those properties. Unless we need something very custom, it's good to just return a basic animation with the fromValue set to the current presentation value:

override func action(forKey key: String) -> CAAction? {
    switch key {
    case #keyPath(startAngle), #keyPath(endAngle):
        let anim = CABasicAnimation(keyPath: key)
        anim.fromValue = presentation()?.value(forKeyPath: key)
        return anim

    default:
        return super.action(forKey: key)
    }
}

Drawing

The last piece of a custom animation is the custom drawing. This is done by overriding draw(in:) and using the supplied context to draw the layer:

override func draw(in ctx: CGContext) {
    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    // subtract half the stroke width to avoid clipping the stroke
    let radius = min(center.x, center.y) - strokeWidth / 2

    // The two angle properties are in degrees but CG wants them in radians.
    let start = startAngle * .pi / 180
    let end   = endAngle   * .pi / 180

    ctx.beginPath()
    ctx.move(to: center)

    ctx.addLine(to: CGPoint(x: center.x + radius * cos(start),
                            y: center.y + radius * sin(start)))

    ctx.addArc(center: center, radius: radius,
               startAngle: start, endAngle: end,
               clockwise: start > end)

    ctx.closePath()

    // Configure the graphics context
    if let fillCGColor = fillColor?.cgColor {
        ctx.setFillColor(fillCGColor)
    }
    if let strokeCGColor = strokeColor?.cgColor {
        ctx.setStrokeColor(strokeCGColor)
    }
    ctx.setLineWidth(strokeWidth)

    ctx.setLineCap(.round)
    ctx.setLineJoin(.round)

    // Draw
    ctx.drawPath(using: .fillStroke)
}

Here I've filled and stroked a pie segment that extends from the center of the layer to the nearest edge. You should replace this with your custom drawing.

A custom animation in action

With all that code in place, we now have a custom layer subclass whose properties can be animated both implicitly (just by changing them) and explicitly (by adding a CAAnimation for their key). The results looks something like this:

Custom layer subclass animation

Final words

It might not be obvious with the frame rate of those animations but one strong benefit from leveraging Core Animation (in different ways) in both these solutions is that it decouples the drawing of a single state from the timing of an animations.

That means that the layer doesn't know and doesn't have to know about the duration, delays, timing curves, etc. These can all be configured and controlled externally.

like image 175
David Rönnqvist Avatar answered Oct 23 '22 17:10

David Rönnqvist


So at last I have found a solution. It took me time to understand that there is indeed no way to animate the fill of the shape, but we can trick CA engine by creating a filled circle by making the stroke (i.e. the border of the arc) extremely wide, so that it fills the whole circle!

extension CGFloat {
    func toRadians() -> CGFloat {
        return self * CGFloat(Double.pi) / 180.0
    }
}


import UIKit

class SliceView: UIView {

    let circleLayer = CAShapeLayer()
    var fromAngle:CGFloat = 30
    var toAngle:CGFloat = 150
    var color:UIColor = UIColor.magenta

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

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

    convenience init(frame:CGRect, fromAngle:CGFloat, toAngle:CGFloat, color:UIColor) {
        self.init(frame:frame)
        self.fromAngle = fromAngle
        self.toAngle = toAngle
        self.color = color
    }

    func setup() {
        circleLayer.strokeColor = color.cgColor
        circleLayer.fillColor = UIColor.clear.cgColor
        layer.addSublayer(circleLayer)
        layer.backgroundColor = UIColor.brown.cgColor
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let startAngle:CGFloat = (fromAngle-90).toRadians()
        let endAngle:CGFloat = (toAngle-90).toRadians()
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = min(bounds.width, bounds.height) / 4
        let path = UIBezierPath(arcCenter: CGPoint(x: 0,y :0), radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)

        circleLayer.position = center
        circleLayer.lineWidth = radius*2
        circleLayer.path = path.cgPath
    }

    public func animate() {
        let pathAnimation = CABasicAnimation(keyPath: "strokeEnd")
        pathAnimation.duration = 3.0;
        pathAnimation.fromValue = 0.0;
        pathAnimation.toValue = 1.0;
        circleLayer.add(pathAnimation, forKey: "strokeEndAnimation")
    }
}

So, now we can add it into our view controller and run the animation. In my case - I'm bridging it into Objecive-C but you can easily adapt it to swift.

I simply can't believe that in 2017 it was still not possible to find a ready solution for this simple task. It took me days to have that done. I really hope it will help others!

Here is how I'm using my class:

@implementation ViewController
{
    SliceView *sv_;
}

- (void)viewDidLoad {
    [super viewDidLoad];

    self.view.backgroundColor = UIColor.grayColor;
    CGFloat width = 240.0;
    CGFloat height = 160.0;

    CGRect r = CGRectMake(
                          self.view.frame.size.width/2 - width/2,
                          self.view.frame.size.height/2 - height/2,
                          width, height);
    sv_ = [[SliceView alloc] initWithFrame:r fromAngle:150 toAngle:30 color:[UIColor yellowColor] ];
    [self.view addSubview:sv_];
}

- (IBAction)pressedGo:(id)sender {
    [sv_ animate];
}
like image 41
ishahak Avatar answered Oct 23 '22 18:10

ishahak