Jun 20, 2013

Custom Circular Progress View for iOS

Overview

This tutorial will show how to create simple circular progress view. Although iOS offers UIActivityIndicator, the problem is that it is indefinite, so you are not able to display percentage using it.

Here's how implemented circular progress view looks:



Of course, this is a very basic look, you can achieve more sophisticated result by adding custom graphics.

1. Setting up the project 

Open XCode and create new project using Single View Application template: 


Name the project, select "Universal" option from "Devices" menu, make sure that "Use Automatic Reference Counting" is checked, press "Next" one more time and save your project.

Now create new file (shortcut CMD+N) which inherits from UIView and name it CustomProgressView. 

Also include QuartzCore.framework to the project, it will be used for drawing purposes. 

2. Adding Functionality

Before staring to write actual code, let's think how the control works:

1. After progress view has been created, new progress value will be passed using setProgress: function (values from 0.0 to 1.0). 
2. If passed value is less than current progress value, no animation is performed
3. If passed value is greater than 1.0, program sets final value to 1.0 and performs the animation
4. If new value is passed while animation is still in progress, this value is stored and used to perform new animation after current animation is finished
5. After value reaches 1.0 and appropriate animation is finished, delegate is informed using didFinishAnimation: method.

Setting up CustomProgressView.h

  • <QuartzCore/QuartzCore.h> should be included for drawing purposes
  • CustomProgressViewDelegate should be created so that delegate could be informed when progress view had reached 100% 
  • current_value is going to store current progress value (from 0.0 to 1.0)
  • new_to_value variable will be used if currently there's animation in progress (in this case new animation will be performed after current animation is finished using new_to_value as final progress animation value)
  • ProgressLbl is used to display numeric value of progress (from 0% to 100%)
  • IsAnimationInProgress is boolean value indicating if there's animation running at the moment
CustomProgressView.h file should look like this:


#import <UIKit/UIKit.h>
#import <QuartzCore/QuartzCore.h>

@interface CustomProgressView : UIView
{
    float current_value;
    float new_to_value;
    
    UILabel *ProgressLbl;

    id delegate;
    
    BOOL IsAnimationInProgress;
}

@property id delegate;
@property float current_value;

- (id)init;
- (void)setProgress:(NSNumber*)value;

@end

@protocol CustomProgressViewDelegate
- (void)didFinishAnimation:(CustomProgressView*)progressView;
@end

Setting up CustomProgressView.m

1. Initialization

init method performs a few tasks:

  • Setting the frame - since CAShapeLayer will be used to display progress, autorotation mask can't be used to center objects. That's why view is created using the larger side of a screen for both width and height and centered manually (view is bigger than actual screen). When rotating, it doesn't change its size thus all objects remain centered.
  • Initializing global variables
  • Setting transparent background for the view
  • Creating label used for displaying progress view
Here's the final code:

- (id)init
{
    CGRect frame;
    if ([[UIApplication sharedApplication] statusBarOrientation] == UIDeviceOrientationLandscapeLeft || [[UIApplication sharedApplication] statusBarOrientation] == UIDeviceOrientationLandscapeRight)
        frame = CGRectMake(0.0, ([[UIScreen mainScreen] bounds].size.width-[[UIScreen mainScreen] bounds].size.height)/2, [[UIScreen mainScreen] bounds].size.height, [[UIScreen mainScreen] bounds].size.height);
    else
        frame = CGRectMake(([[UIScreen mainScreen] bounds].size.width-[[UIScreen mainScreen] bounds].size.height)/2, 0.0, [[UIScreen mainScreen] bounds].size.height, [[UIScreen mainScreen] bounds].size.height);

    self = [super initWithFrame:frame];
    if (self) {
        // Initialization code
        current_value = 0.0;
        new_to_value = 0.0;
        IsAnimationInProgress = NO;
        
        self.alpha = 0.95;
        self.backgroundColor = [UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:0.6];
        self.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
        
        ProgressLbl = [[UILabel alloc] initWithFrame:CGRectMake((self.frame.size.width-200)/2, self.frame.size.height/2-150, 200, 40.0)];
        ProgressLbl.font = [UIFont boldSystemFontOfSize:24.0];
        ProgressLbl.text = @"0%";
        ProgressLbl.backgroundColor = [UIColor clearColor];
        ProgressLbl.textColor = [UIColor whiteColor];
        ProgressLbl.textAlignment = NSTextAlignmentCenter ;
        ProgressLbl.alpha = self.alpha;
        [self addSubview:ProgressLbl];
    }
    return self;
}

2. Setting new label 's value:

-(void)UpdateLabelsWithValue:(NSString*)value
{
    ProgressLbl.text = value;
}

3. Updating the label 's value

We don't want the control to jump from 0 to 50%, instead update function should be called to gradually change label's value during the animation time, passed as the parameter:


-(void)setProgressValue:(float)to_value withAnimationTime:(float)animation_time
{
    float timer = 0;
    
    float step = 0.1;
    
    float value_step = (to_value-self.current_value)*step/animation_time;
    int final_value = self.current_value*100;
    
    while (timer<animation_time-step) {
        final_value += floor(value_step*100);
        [self performSelector:@selector(UpdateLabelsWithValue:) withObject:[NSString stringWithFormat:@"%i%%", final_value] afterDelay:timer];
        timer += step;
    }
    
    [self performSelector:@selector(UpdateLabelsWithValue:) withObject:[NSString stringWithFormat:@"%.0f%%", to_value*100] afterDelay:animation_time];
}

4. Running new progress animation in case there was progress value change during current animation:

-(void)SetAnimationDone
{
    IsAnimationInProgress = NO;
    if (new_to_value>self.current_value)
        [self setProgress:[NSNumber numberWithFloat:new_to_value]];
}


5. Performing animation itself:

Note, that animation time is proportional to value change (for example, time for 2% change would be 10 times less than for 20% change). This function also calls setProgressValue: function to gradually change progress label value.

After animation is finished, SetAnimationDone function checks if there was value change during animation.

When final progress value is 1.0 and animation is finished, delegate is informed using didFinishAnimation: function. 

Actual circle drawing code is used from here with slight modifications. 

- (void)setProgress:(NSNumber*)value{
    
    float to_value = [value floatValue];
    
    if (to_value<=self.current_value)
        return;
    else if (to_value>1.0)
        to_value = 1.0;
    
    if (IsAnimationInProgress)
    {
        new_to_value = to_value;
        return;
    }
    
    IsAnimationInProgress = YES;
    
    float animation_time = to_value-self.current_value;
    
    [self performSelector:@selector(SetAnimationDone) withObject:Nil afterDelay:animation_time];
    
    if (to_value == 1.0 && delegate && [delegate respondsToSelector:@selector(didFinishAnimation:)])
        [delegate performSelector:@selector(didFinishAnimation:) withObject:self afterDelay:animation_time];
    
    [self setProgressValue:to_value withAnimationTime:animation_time];
    
    float start_angle = 2*M_PI*self.current_value-M_PI_2;
    float end_angle = 2*M_PI*to_value-M_PI_2;
    
    float radius = 75.0;
    
    CAShapeLayer *circle = [CAShapeLayer layer];

    // Make a circular shape
    
    circle.path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(self.frame.size.width/2,self.frame.size.height/2)
                                                 radius:radius startAngle:start_angle endAngle:end_angle clockwise:YES].CGPath;
    
    // Configure the apperence of the circle
    circle.fillColor = [UIColor clearColor].CGColor;
    circle.strokeColor = [UIColor whiteColor].CGColor;
    circle.lineWidth = 3;
    
    // Add to parent layer
    [self.layer addSublayer:circle];
    
    // Configure animation
    CABasicAnimation *drawAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
    
    drawAnimation.duration            = animation_time;
    drawAnimation.repeatCount         = 0.0// Animate only once..
    drawAnimation.removedOnCompletion = NO;   // Remain stroked after the animation..
    
    // Animate from no part of the stroke being drawn to the entire stroke being drawn
    drawAnimation.fromValue = [NSNumber numberWithFloat:0.0];
    drawAnimation.toValue   = [NSNumber numberWithFloat:1.0];
    
    // Experiment with timing to get the appearence to look the way you want
    drawAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
    
    // Add the animation to the circle
    [circle addAnimation:drawAnimation forKey:@"drawCircleAnimation"];
    self.current_value = to_value;
}

Setting up ViewController.h

We just need to include CustomProgressView.h and set view controller to be its delegate:

#import <UIKit/UIKit.h>
#import "CustomProgressView.h"
@interface mkViewController : UIViewController <CustomProgressViewDelegate>
{
    CustomProgressView *customProgressView;
}
@end

Setting up ViewController.m

Let's create a button that should be clicked to show progress view:

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    UIButton *ProgressBtn = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    ProgressBtn.frame = CGRectMake((int)((self.view.frame.size.width-200.0)/2.0), 120.0, 200.0, 50.0);
    [ProgressBtn setTitle:@"Show Progress View" forState:UIControlStateNormal];
    [ProgressBtn addTarget:self action:@selector(onProgressBtnPressed) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:ProgressBtn];
    ProgressBtn.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin;
}

When button is clicked, onProgressBtnPressed will change progress value a few times with delay for demonstrating purposes:


- (void)onProgressBtnPressed
{
    customProgressView = [[CustomProgressView alloc] init];
    customProgressView.delegate = self;
    [self.view addSubview:customProgressView];
    
    [self performSelector:@selector(setProgress:) withObject:[NSNumber numberWithFloat:0.3] afterDelay:0.0];
    [self performSelector:@selector(setProgress:) withObject:[NSNumber numberWithFloat:0.75] afterDelay:2.0];
    [self performSelector:@selector(setProgress:) withObject:[NSNumber numberWithFloat:1.0] afterDelay:4.0];
}

Please note that redrawing should be performed in the main thread:


-(void)setProgress:(NSNumber*)value
{
    [customProgressView performSelectorOnMainThread:@selector(setProgress:) withObject:value waitUntilDone:NO];
}

That's it, you can download source code here.

5 comments:

  1. Great class! Nice and easy to customize! :)

    ReplyDelete
  2. Hi What if We want to move the progress in counter clock wise direction?

    ReplyDelete
    Replies
    1. Hi,
      You just need to make a few small modifications in setProgress method:
      1. Calculate start and end angle like this:
      float start_angle = -2*M_PI*self.current_value-M_PI_2;
      float end_angle = -2*M_PI*to_value-M_PI_2;
      2. Set clockwise parameter to NO when creating path:
      circle.path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(self.frame.size.width/2,self.frame.size.height/2) radius:radius startAngle:start_angle endAngle:end_angle clockwise:NO].CGPath;

      Hope this helps

      Delete
  3. Hello everyone,

    i have two questions reg. this script:

    1.) The circle has a light transparency and i can´t find the way to solve it. I have to following values:
    - (id)init ... self.alpha = 1.0;
    - (void)setProgress:(NSNumber*)value{...... circle.strokeColor = [UIColor colorWithRed:255.0 green:255.0 blue:255.0 alpha:1.0].CGColor;

    I want to have a bright white -> Can someone help me?

    2.) The background of the full circle.strokeColor should be black as soon as the view is loaded. How can i make it happend? The middle should be transparent.

    Would be great to get help! Thanks in advance.

    ReplyDelete
  4. Android SDK allows you to create stunning mobile application loaded with more features and enhanced priority. With basis on Java coding language, you can create stunning mobile application with ease.

    Cado Magenge
    " iphone application development melbourne
    web design and development company
    app development company
    mobile apps development companies melbourne "

    ReplyDelete