0

I'm porting some animation code that looks a bit like this:

- (void)drawRect:(CGRect)rect
{
    self.angle += 0.1;
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetRGBStrokeColor(context, 1.0, 0, 0, 1);
    CGContextSetLineWidth(context, 2);
    CGContextSetLineCap(context, kCGLineCapButt);
    CGContextAddArc(context,
                 self.frame.size.height/2, self.frame.size.height/2, //center
                 self.frame.size.height/2 - 2, //radius
                 0.0 + self.angle, M_PI_4 + self.angle, //arc start/finish
                 NO);
    CGContextStrokePath(context);
}

The problem is that drawRect is only ever called once, when the view is first drawn, so the position of the arc is never updated.

How can I achieve the effect I want (the arc slowly and continuously moving around the centre point)? Most of the animation examples I can find are to perform a one-time animation (such as a fade-In), but not something that is continuous.

I've also tried something along the lines of:

[arcView animateWithDuration:10.0f
         delay:1.0f
         options: UIViewAnimationOptionRepeat | UIViewAnimationOptionBeginFromCurrentState
         animations: ^(void){
             _arcView.transform = CGAffineTransformMakeRotation(self.angle++);
         }
         completion:NULL];

When showing the view, but this doesn't seem to be doing anything either.

A little more about what I'm aiming for: I have a View that I want to be able to set certain states on, e.g. arcView.state = STATE_READY, and for that to change the way it animates. This is being ported from an Android project where it's as simple as adding logic to the draw method on the View, and something reasonably analogous would be preferred.

fredley
  • 32,953
  • 42
  • 145
  • 236

3 Answers3

2

A couple of observations:

  1. First, drawRect should probably not be incrementing the angle. The purpose of drawRect is to draw a single frame and it should not be changing the state of the view.

  2. If you wanted the UIView subclass to repeated update the angle and redraw itself, you would set up a timer, or better, a CADisplayLink, that will be called periodically, updating the angle and then calling [self setNeedsDisplay] to update the view:

    #import "MyView.h"
    
    @interface MyView ()
    
    @property (nonatomic, strong) CADisplayLink *displayLink;
    @property (nonatomic) CFTimeInterval startTime;
    
    @property (nonatomic) CGFloat revolutionsPerSecond;
    @property (nonatomic) CGFloat angle;
    
    @end
    
    @implementation MyView
    
    - (instancetype)initWithCoder:(NSCoder *)coder
    {
        self = [super initWithCoder:coder];
        if (self) {
            self.angle = 0.0;
            self.revolutionsPerSecond = 0.25; // i.e. go around once per every 4 seconds
            [self startDisplayLink];
        }
        return self;
    }
    
    - (void)startDisplayLink
    {
        self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
        self.startTime = CACurrentMediaTime();
        [self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    }
    
    - (void)stopDisplayLink
    {
        [self.displayLink invalidate];
        self.displayLink = nil;
    }
    
    - (void)handleDisplayLink:(CADisplayLink *)displayLink
    {
        CFTimeInterval elapsed = CACurrentMediaTime() - self.startTime;
    
        double revolutions;
        double percent = modf(elapsed * self.revolutionsPerSecond, &revolutions);
        self.angle = M_PI * 2.0 * percent;
        [self setNeedsDisplay];
    }
    
    - (void)drawRect:(CGRect)rect
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextSetRGBStrokeColor(context, 1.0, 0, 0, 1);
        CGContextSetLineWidth(context, 2);
        CGContextSetLineCap(context, kCGLineCapButt);
        CGContextAddArc(context,
                        self.frame.size.width/2, self.frame.size.height/2, //center
                        MIN(self.frame.size.width, self.frame.size.height) / 2.0 - 2.0, //radius
                        0.0 + self.angle, M_PI_4 + self.angle, //arc start/finish
                        NO);
        CGContextStrokePath(context);
    }
    
    @end
    
  3. An easier approach is to update the transform property of the view, to rotate it. In iOS 7 and later, you add a view with the arc and then rotate it with something like:

    [UIView animateKeyframesWithDuration:4.0 delay:0.0 options:UIViewKeyframeAnimationOptionRepeat | UIViewAnimationOptionCurveLinear animations:^{
        [UIView addKeyframeWithRelativeStartTime:0.00 relativeDuration:0.25 animations:^{
            self.view.transform = CGAffineTransformMakeRotation(M_PI_2);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.25 relativeDuration:0.25 animations:^{
            self.view.transform = CGAffineTransformMakeRotation(M_PI);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.50 relativeDuration:0.25 animations:^{
            self.view.transform = CGAffineTransformMakeRotation(M_PI_2 * 3.0);
        }];
        [UIView addKeyframeWithRelativeStartTime:0.75 relativeDuration:0.25 animations:^{
            self.view.transform = CGAffineTransformMakeRotation(M_PI * 2.0);
        }];
    } completion:nil];
    

    Note, I've broken the animation up into four steps, because if you rotate from 0 to M_PI * 2.0, it won't animate because it knows the ending point is the same as the starting point. So by breaking it up into four steps like that it does each of the four animations in succession. If doing in in earlier versions of iOS, you do something similar with animateWithDuration, but to have it repeat, you need the completion block to invoke another rotation. Something like: https://stackoverflow.com/a/19408039/1271826

  4. Finally, if you want to animate, for example, just the end of the arc (i.e. to animate the drawing of the arc), you can use CABasicAnimation with a CAShapeLayer:

    UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(self.view.bounds.size.width / 2.0, self.view.bounds.size.height / 2.0) radius:MIN(self.view.bounds.size.width, self.view.bounds.size.height) / 2.0 - 2.0 startAngle:0 endAngle:M_PI_4 / 2.0 clockwise:YES];
    
    CAShapeLayer *layer = [CAShapeLayer layer];
    layer.frame = self.view.bounds;
    layer.path = path.CGPath;
    layer.lineWidth = 2.0;
    layer.strokeColor = [UIColor redColor].CGColor;
    layer.fillColor = [UIColor clearColor].CGColor;
    
    [self.view.layer addSublayer:layer];
    
    CABasicAnimation *animateStrokeEnd = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
    animateStrokeEnd.duration  = 10.0;
    animateStrokeEnd.fromValue = @0.0;
    animateStrokeEnd.toValue   = @1.0;
    animateStrokeEnd.repeatCount = HUGE_VALF;
    [layer addAnimation:animateStrokeEnd forKey:@"strokeEndAnimation"];
    

    I know that's probably not what you're looking for, but I mention it just so you can add it to your animation "tool belt".

Community
  • 1
  • 1
Rob
  • 415,655
  • 72
  • 787
  • 1,044
1

Most of the animation examples I can find are to perform a one-time animation (such as a fade-In), but not something that is continuous.

If you use a method like UIView's +animateWithDuration:delay:options:animations:completion: to do your animation, you can specify UIViewAnimationOptionRepeat in the options parameter to make the animation repeat indefinitely.

Fundamentally, using -drawRect: for most animations is the wrong way to go. As you discovered, -drawRect: is only called when the graphics system really needs to redraw the view, and that's a relatively expensive process. As much as possible, you should use Core Animation to do your animations, especially for stuff like this where the view itself doesn't need to be redrawn, but it's pixels need to be transformed somehow.

Note that +animateWithDuration:delay:options:animations:completion: is a class method, so you should send it to UIView itself (or some subclass of UIView), not to an instance of UIView. There's an example here. Also, that particular method might or might not be the best choice for what you're doing -- I just called it out because it's an easy example of using animation with views and it lets you specify the repeating option.

I'm not sure what's wrong with your animation (other than maybe the wrong receiver as described above), but I'd avoid using the ++ operator to modify the angle. The angle is specified in radians, so incrementing by 1 probably isn't what you want. Instead, try adding π/2 to the current rotation, like this:

_arcView.transform = CAAffineTransformRotate(_arcView.transform, M_PI_2);
Caleb
  • 124,013
  • 19
  • 183
  • 272
  • No luck with `animateWithDuration`, I must be doing something wrong but I have no idea what... – fredley Aug 26 '14 at 14:13
  • I've tried using `CABasicAnimation`, but I still can't get the arc to rotate when I call `[arcView animate]`. [Any idea what I'm doing wrong?](http://pastebin.com/W0NiRZd7). Nothing I do seems to have any effect. – fredley Aug 26 '14 at 14:53
-1

So this is what I've ended up with. It took a long time to work out the I needed "translation.rotation" instead of just "rotation"...

@implementation arcView

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    self.state = 0;
    if (self) {
        self.layer.contents = (__bridge id)([[self generateArc:[UIColor redColor].CGColor]CGImage]);
    }
    return self;
}
-(UIImage*)generateArc:(CGColorRef)color{
    UIGraphicsBeginImageContext(self.frame.size);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetStrokeColorWithColor(context, color);
    CGContextSetLineWidth(context, 2);
    CGContextSetLineCap(context, kCGLineCapButt);
    CGContextAddArc(context,
                    self.frame.size.height/2, self.frame.size.height/2,
                    self.frame.size.height/2 - 2,0.0,M_PI_4,NO);
    CGContextStrokePath(context);
    UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return result;
}
-(void)spin{
    [self.layer removeAnimationForKey:@"rotation"];
    CABasicAnimation *rotate = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
    NSNumber *current =[[self.layer presentationLayer] valueForKeyPath:@"transform.rotation"];
    rotate.fromValue = current;
    rotate.toValue = [NSNumber numberWithFloat:current.floatValue + M_PI * (self.state*2 - 1)];
    rotate.duration = 3.0;
    rotate.repeatCount = INT_MAX;
    rotate.fillMode = kCAFillModeForwards;
    rotate.removedOnCompletion = NO;
    [self.layer addAnimation:rotate forKey:@"rotation"];
}
fredley
  • 32,953
  • 42
  • 145
  • 236