0

I need a class derived from UIView that indicates progress by filling an increasingly large sector of a circle (as progress goes from 0 to 1 its angle should grow by 2 PI). It's like a circular version of UIProgressView.

The following code does not yet quite do the job: it produces increasingly many small sectors stacked in a flickering zebra pattern. Presumably the code is messing up its coordinates with UIView flipping its coordinate system, with CGContextAddArc´s (apparently) counter-intuitive definition of "clockwise", etc.

What's the reason for the misbehavior and how can it be fixed?

#define PERCENTAGE_TO_RADIANS(PERCENTAGE) ((PERCENTAGE) * 2 * M_PI - M_PI_2)

- (void)drawRect:(CGRect)rect
{
    NSAssert(!self.opaque,                     nil);
    NSAssert(!self.clearsContextBeforeDrawing, nil);

    CGSize  size   = self.bounds.size;
    CGPoint center = [self.superview convertPoint:self.center toView:self];

    NSAssert(size.width == size.height, nil);
    NSAssert((self.progress >= 0) && (self.progress <= 1), nil);

    NSAssert(PERCENTAGE_TO_RADIANS(0   ) == -M_PI_2   , nil); //   0% progress corresponds to north
    NSAssert(PERCENTAGE_TO_RADIANS(0.25) == 0         , nil); //  25% progress corresponds to east
    NSAssert(PERCENTAGE_TO_RADIANS(0.5 ) == M_PI_2    , nil); //  50% progress corresponds to south
    NSAssert(PERCENTAGE_TO_RADIANS(0.75) == M_PI      , nil); //  75% progress corresponds to west
    NSAssert(PERCENTAGE_TO_RADIANS(1   ) == 3 * M_PI_2, nil); // 100% progress corresponds to north

    CGFloat x          = center.x;
    CGFloat y          = center.y;
    CGFloat radius     = size.width / 2.0;
    CGFloat startAngle = self.lastAngle;
    CGFloat endAngle   = PERCENTAGE_TO_RADIANS(self.progress);
    int     clockwise  = 0;

    if (self.progress > 0) {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextSaveGState(context);

        CGContextSetFillColorWithColor(context, self.sectorTintColor.CGColor);
        CGContextSetLineWidth(context, 1);

        NSAssert(startAngle <= endAngle, nil);
        CGContextMoveToPoint(context, x, y);
        CGContextAddArc(context, x, y, radius, startAngle, endAngle, clockwise);
        CGContextClosePath(context);

        CGContextFillPath(context);

        CGContextRestoreGState(context);
    }

    self.lastAngle = endAngle;
}
Drux
  • 11,992
  • 13
  • 66
  • 116

1 Answers1

1

The most important thing you need to know is that positive Y is down, which makes the angles and the clockwise direction non-intuitive. You have two choices, either just go with it, or flip the Y-axis before drawing anything. In the sample code below, I took the latter approach.

The drawRect method is not accumulative. In other words, you are required to draw the entire sector on each call to drawRect. Hence there is no reason to keep track of the start angle, you should always start at angle PI/2 and draw the whole sector.

There is no need to save and restore the graphics state. The graphics context is created new each time drawRect is called.

The UIColor class has set, setFill, and setStroke methods that allow you to change fill and stroke colors without using CGColors.

With all that in mind, here's what the code looks like, assuming that self.progress is a number between 0.0 and 1.0

- (void)drawRect:(CGRect)rect
{
    if ( self.progress <= 0 || self.progress > 1.0 )
        return;

    CGFloat x = self.bounds.size.width  / 2.0;
    CGFloat y = self.bounds.size.height / 2.0;
    CGFloat r = (x <= y) ? x : y;

    CGFloat angle = M_PI_2 - self.progress * 2.0 * M_PI;

    CGContextRef context = UIGraphicsGetCurrentContext();

    // fix the y axis, so that positive y is up
    CGContextScaleCTM( context, 1.0, -1.0 );
    CGContextTranslateCTM( context, 0, -self.bounds.size.height );

    // draw the pie shape
    [self.sectorTintColor setFill];
    CGContextMoveToPoint( context, x, y );
    CGContextAddArc( context, x, y, r, M_PI_2, angle, YES );
    CGContextClosePath( context );

    CGContextFillPath( context );
}
user3386109
  • 34,287
  • 7
  • 49
  • 68
  • Excellent, thx. Could you please clarify one more point? I thought `drawRect:` is accumulative if the view's `clearsContextBeforeDrawing` is set to `NO`. But you are suggesting that if that is so the background view's context won't be cleared, but the view's own view (within `rect`) will always be cleared before `drawRect:` is called regardless, right? – Drux Jun 04 '14 at 21:57
  • [This](http://stackoverflow.com/questions/865636/uiview-how-to-do-non-destructive-drawing) was helpful re `clearsContextBeforeDrawing`: "You cannot prevent the contents from being erased by doing the following: `[self setClearsContextBeforeDrawing: NO];` This is merely a hint to the graphics engine that there is no point in having it pre-clear the view for you, since you will likely need to re-draw the whole area anyway. It may prevent your view from being automatically erased, but you cannot depend on it." – Drux Jun 17 '14 at 11:04
  • Ah yes, that explains it nicely. Now all we need is the `preservesContextFromPreviousDrawing` property :) – user3386109 Jun 17 '14 at 21:03