Circular Progress View using PaintCode & Xamarin

Circular Progress

I wanted a fancy Circular Progress View for a project that I'm working on. First stop CocoaControls to see what's there. Well, there 15+ such controls including DACircularProgress.

With 54 ratings at a 4 rating, this seems like a good choice. I wanted something flat and clean, some of the other options were over the top.

Porting to Xamarin

In the Xamarin world you have a couple of options when you find native Objective-C code. You can create a library and wrap it, or you can convert the code to C#. 

Wrapping Objective-C code is not difficult. 

  • Create a static lib
  • Copy in the needed source
  • Build using the terminal for the device & simulator 
  • Rename those build files using say x86 and ARM
  • Use lipo to combine into one file
  • Create a new Xamarin binding iOS project
  • Create the wrapper classes for what you want to expose to .net

It's really not that bad and an essential skill for a Xamarin iOS developer.

That being said I evaluated between rewriting the code and creating a static lib based on complexity and size.

In this case, the code is around 300 lines and is mostly drawing code, so not a lot of logic involved. 

PaintCode

So I started to port this code when I started wondering how PaintCode could work in this situation.

What is PaintCode?

PaintCode is one of the most useful development tools ever created. It's designed with the designer in mind, but mere mortals like developers can also use it.

Primarily graphic assets can be designed in a first class vector drawing tool and exported as either Objective-C or MonoTouch C# code. 

Graphics can be sized at runtime based on application logic and since it's code anything can change like colors, fonts ext....

Images generated are optimized for the running device. Retina & Non-Retina code is the same. You could create images today that will be optimized for the iPhone 6, as well. 

So in short PaintCode is awesome.

The Circle

The Progress Circle

The Progress Circle

To get our progress circle effect, first we draw one circle that is the color that we want to represent progress. This circle is behind all of the other circles.

The bottom most circle

The bottom most circle

Next up is a circle that covers up the progress circle. Based on what the progress is

Next circle hides a portion of the bottom circle

Next circle hides a portion of the bottom circle

We change the angle exposing the desired portion below. This is what we'll do in code. 

Changing the angle

Changing the angle

Lastly we need to put one more circle above, now just showing the outer part of the circle.

Writing the code

CircularProgressView &  CircularProgressLayer

We need to animate the progress. One way you should never animate is to repeatedly call SetNeedsDisplay to force a view to redraw. 

If you animate you need to make sure to use CoreAnimation. CoreAnimation does not operate on UIViews, but on CALayers. 

To truly understand CALayers and CoreAnimation, I would recommend first looking at WWDC videos on the topic, going back the last three years. 

iOS and OSX frameworks are typically structured to have an API on the C level that does everything, and to then have an abstraction above that. Most of the time the abstraction is what you'll need, but like any abstraction, it will probably fail you at some point and you'll need to dive deeper. That and to truly understand what's going on, it's better to at least become familiar with the core framework. 

So I said all of that because we need to create a new layer and understand CoreAnimation a little better. 

CircularProgressLayer

In order for the animation to work our layer needs to take in another instance of the layer. CoreAnimation will then animate the difference between the to states. 

Note the Export attribute. The export attribute exposes a method to Objective-C and maps to the specified message.

The runtime will then call for a constructor that takes a CALayer called initWithLayer.

Objective-C constructs objects by using this convention. A two step process to first allocate the object and then call a method intended to instantiate with certain parameters.

 

   1:          [Export("initWithLayer:")]
   2:          public CircularProgressLayer(CALayer other) : base(other)
   3:          {
   4:              var otherProgressLayer = other as CircularProgressLayer;
   5:   
   6:              if (otherProgressLayer != null)
   7:              {
   8:                  InnerColor = otherProgressLayer.InnerColor;
   9:                  InsideColor = otherProgressLayer.InsideColor;
  10:                  OuterColor = otherProgressLayer.OuterColor;
  11:              }
  12:          }

Properties

The const ProgressKey is our animatable property. The syntax of animations are specified by specifying the From and To value for a property to traverse through in the animation. 

The three color properties are used to customize the appearance of the ProgressView and will be accessed by the drawing code.

   1:          public const string ProgressKey = "progress";
   2:   
   3:          #region Members
   4:   
   5:          /// <summary>
   6:          /// Color inside of the circle.
   7:          /// </summary>
   8:          /// <value>The color of the inner.</value>
   9:          public UIColor InnerColor { get; set; }
  10:   
  11:          /// <summary>
  12:          /// Color of the progress
  13:          /// </summary>
  14:          /// <value>The color of the inside.</value>
  15:          public UIColor InsideColor { get; set; }
  16:   
  17:          /// <summary>
  18:          /// Color outside of the progress
  19:          /// </summary>
  20:          /// <value>The color of the outer.</value>
  21:          public UIColor OuterColor { get; set; }
  22:   
  23:          #endregion

Next up is NeedsDisplayForKey which notifies CoreAnimation that if a key changes then animation needs to occur. 

Progress is once again our animatable value

DrawInContext does the drawing of the circle. 

PaintCode doesn't support Context Drawing. Their support states that it isn't necessary because of the methods seen here. Push & Pop Context. 

   1:          [Export("needsDisplayForKey:")]
   2:          static bool NeedsDisplayForKey(NSString key)
   3:          {
   4:              return key == ProgressKey || CALayer.NeedsDisplayForKey(key);
   5:          }
   6:   
   7:          [Export(ProgressKey)]
   8:          public float Progress { get; set; }
   9:   
  10:          public override void DrawInContext(CGContext ctx)
  11:          {
  12:              UIGraphics.PushContext(ctx);
  13:   
  14:              Draw(Bounds);
  15:   
  16:              UIGraphics.PopContext();
  17:          }

CircularProgressView

The LayerClass method tells the runtime what class to use as the layer.

CircularProgressLayer property is a convince property making accessing the layer more type safe. 

MoveToWindow assures that the layer is at the correct scale.

   1:          #region Layer
   2:   
   3:          [Export("layerClass")]
   4:          public static Class LayerClass()
   5:          {
   6:              return new Class(typeof(CircularProgressLayer));
   7:          }
   8:   
   9:          CircularProgressLayer CircularProgressLayer
  10:          {
  11:              get
  12:              {
  13:                  return (CircularProgressLayer)Layer;
  14:              }
  15:          }
  16:   
  17:          public override void MovedToWindow()
  18:          {
  19:              var windowContentScale = Window.Screen.Scale;
  20:              CircularProgressLayer.ContentsScale = windowContentScale;
  21:              CircularProgressLayer.SetNeedsDisplay();
  22:          }
  23:   
  24:          #endregion

The color properties wrap the layer color properties, as well as progress

   1:          /// <summary>
   2:          /// Color inside of the circle.
   3:          /// </summary>
   4:          /// <value>The color of the inner.</value>
   5:          public UIColor InnerColor
   6:          {
   7:              get{ return CircularProgressLayer.InnerColor; }
   8:              set { CircularProgressLayer.InnerColor = value; }
   9:          }
  10:   
  11:          /// <summary>
  12:          /// Color of the progress
  13:          /// </summary>
  14:          /// <value>The color of the inside.</value>
  15:          public UIColor InsideColor
  16:          {
  17:              get{ return CircularProgressLayer.InsideColor; }
  18:              set { CircularProgressLayer.InsideColor = value; }
  19:          }
  20:   
  21:          /// <summary>
  22:          /// Color outside of the progress
  23:          /// </summary>
  24:          /// <value>The color of the outer.</value>
  25:          public UIColor OuterColor
  26:          {
  27:              get{ return CircularProgressLayer.OuterColor; }
  28:              set { CircularProgressLayer.OuterColor = value; }
  29:          }
  30:   
  31:          [Export(CircularProgressLayer.ProgressKey)]
  32:          public float Progress
  33:          {
  34:              get
  35:              {
  36:                  return CircularProgressLayer.Progress;
  37:              }
  38:              set
  39:              {
  40:                  SetProgress(Progress, value, Progress > value);
  41:              }
  42:          }

Setting the progress

To set the progress we

  • Remove an existing animation
  • Calculate the angle 
  • Create a new Basic animation
  • Set values
  • Run the animation

We also have the option not to animate when we just redraw. 

   1:         void SetProgress(float fromProgress, float toProgress, bool animate)
   2:          {
   3:              Layer.RemoveAnimation(indeterminateAnimation);
   4:              Layer.RemoveAnimation(CircularProgressLayer.ProgressKey);
   5:   
   6:              var progressPct = Progress / StartAngle;
   7:              var pinnedProgress = Math.Min(Math.Max(progressPct, 0.0f), 1.0f);
   8:   
   9:              if(toProgress > fromProgress) //Don't want to animate between 360 to 0
  10:              {
  11:                  if (animate)
  12:                  {
  13:                      var animation = CABasicAnimation.FromKeyPath(CircularProgressLayer.ProgressKey);
  14:                      animation.Duration = Math.Abs(progressPct - pinnedProgress);
  15:                      animation.TimingFunction = CAMediaTimingFunction.FromName(CAMediaTimingFunction.EaseInEaseOut);
  16:                      animation.From = NSNumber.FromFloat(fromProgress);
  17:                      animation.To = NSNumber.FromFloat(toProgress);
  18:                      CircularProgressLayer.AddAnimation(animation, CircularProgressLayer.ProgressKey);
  19:                  }
  20:                  else
  21:                  {
  22:                      CircularProgressLayer.SetNeedsDisplay();
  23:                  }
  24:              }
  25:   
  26:              CircularProgressLayer.Progress = toProgress;
  27:          }

Finally Animating 

To animate around the circle there is start and stop method. 

We create a repeating timer to update the progress continually

   1:           /// <summary>
   2:          /// Start the intermediate progress
   3:          /// </summary>
   4:          public void Start()
   5:          {
   6:              if (_timer != null)
   7:                  return;
   8:   
   9:              _timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromSeconds(0.03), UpdateProgress);
  10:          }
  11:   
  12:          /// <summary>
  13:          /// Stop the intermediate progress
  14:          /// </summary>
  15:          public void Stop()
  16:          {
  17:              if (_timer == null)
  18:                  return;
  19:   
  20:              _timer.Invalidate();
  21:              _timer.Dispose();
  22:              _timer = null;
  23:          }
  24:   
  25:           void UpdateProgress()
  26:          {
  27:              if ((Progress + 1) > 360)
  28:                  Progress = 0;
  29:              else
  30:                  Progress++;
  31:          }

You can find the code for this post, including the PaintCode file here