Thursday, October 30, 2014

Xamarin.Forms Circle Images

It is a common pattern in mobile applications to display an image in the shape of a circle.  Xamarin.Forms does not have any functionality for this right out of the box but the Xamarin.Forms team showed us how this can be done at Evolve 2014.  That implementation can be found here:

Xamarin.Forms Team Circle Image

This implementation by +James Montemagno is good for making an exact circle with a white border.  It uses masking and it's really fast.  One thing it doesn't do is handle what happens when the height and width of the control are different or what happens when you set the Aspect property.  I'm ignoring the catch(Exception ex) statements James, but just don't let +Jason Bock see them! (not that my code is perfect either, I try to do better each and every day.)

So here is an example of the renderers when using with controls that have a different height and width with the Aspect property set:



OK, we can see that the Android and Windows Phone implementations in this scenario are about the same.  They always draw a round image though the two implementations show a slightly different portion of the picture.  It probably with have displayed fine had the control's height and width been the same.  These are generally behaving as would be expected under the Aspect Fit setting. 

The iOS implementation is doing something very different.  It is behaving as though the Aspect property were set to Fill in all scenarios.  The upshot of the deal is that in most cases the programmer is going to set the RequestedHeight and RequestedWidth the same and these renderers are going to be great (and fast).  But how about those other cases, what would we expect to see then?





This is what you would expect to see.  Aspect Fill fills the entire control and what portion of the circle image is outside of that area is cropped off.  The top and bottom in this case.  The fill setting takes the circle and spreads it out to fill the entire control area resulting in an oval.  Aspect Fit makes a perfect circle in the center of the control.  Since the control area is wider than it is tall a letterbox situation is created with blank space on either side of the circle.

Note: My Windows Phone implementation is now yet complete.  I'm currently using a renderer in the style that +James Montemagno created.

The following implementation is now in the open source XLabs library for Xamarin.Forms.  It can be found here XForms Labs.  As of the time of this writing it has not yet made it to the nuget package.

The first thing I did was create a custom view that derived from the standard Image view as so:

public class CircleImage : Image
{
}

Ok, I know what you may be thinking... Where's the beef?  As it turns out at the present time I don't need to change anything intrinsic about the view itself, just how it is rendered.  To do this I subclass from the Image view so I can create a custom renderer for that without impacting base Image view.

To use this in Xaml it is as simple as:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:Xamarin.Forms.Labs.Controls;assembly=Xamarin.Forms.Labs"
             x:Class="Xamarin.Forms.Labs.Sample.Pages.Controls.CircleImagePage">
        <controls:CircleImage HeightRequest="75" WidthRequest="100" HorizontalOptions="Fill" Aspect="AspectFill">
            <controls:CircleImage.Source>
                <OnPlatform x:TypeArguments="ImageSource">
                    <OnPlatform.iOS>
                        <FileImageSource File="panic.jpg" />
                    </OnPlatform.iOS>
                    <OnPlatform.Android>
                        <FileImageSource File="panic.jpg" />
                    </OnPlatform.Android>
                    <OnPlatform.WinPhone>
                        <FileImageSource File="Images/panic.jpg" />
                    </OnPlatform.WinPhone>
                </OnPlatform>
            </controls:CircleImage.Source>
        </controls:CircleImage>
</ContentPage>

Other than calling it a CircleImage and referencing it the line "xmlns:controls="clr-namespace:Xamarin.Forms.Labs.Controls;assembly=Xamarin.Forms.Labs"" it is just like working with a standard Image view.

So let's take a look at what I did with iOS to make a custom renderer:

[assembly: ExportRenderer(typeof(CircleImage), typeof(CircleImageRenderer))]
namespace Xamarin.Forms.Labs.iOS.Controls.CircleImage
{
    public class CircleImageRenderer : ImageRenderer

The header is exactly what you would expect.  The new renderer for the CircleImage is registered as part of the  namespace and the custom renderer derives from the ImageRenderer.

One of the things that I need to think about is how will I accomplish the Aspect property working.  This requires considering a few things, do I need to manipulate the image, if I do how do I know the image is loaded, ensure there are no memory leaks and finally use masking as the Xamarin.Forms implementation when possible because it performs better.

So right of the bat I want to do masking for Fill because that's what works by default.  First we'll look at the OnElementChanged method:

protected override void OnElementChanged(ElementChangedEventArgs<Image> e)
{
    base.OnElementChanged(e);

    if (Control == null || e.OldElement != null || Element == null || this.Element.Aspect != Aspect.Fill )
        return;

    var min = Math.Min(Element.Width, Element.Height);
    Control.Layer.CornerRadius = (float)(min / 2.0);
    Control.Layer.MasksToBounds = false;
    Control.ClipsToBounds = true;
}

This isn't normally where I would look for manipulating images as this is where the base class's ImageRenderer will start loading them asynchronously from the source.  I can however set a mask, which is what I do if the Aspect is Fill.

Where the real interesting code comes in is during OnElementPropertyChanged.  For my masking I want to know that the control is drawing and that means that the height and width have changed.  For the bitmap manipulation I need to know when the image is loaded from the source.  Luckily the Xamarin.Forms team sets an IsLoading property to true when the image starts loading and turns it to false when complete.  That means when the IsLoading property changes to false I know when the load operation is complete.

NOTE: On Windows Phone changes to the IsLoading property are not raised to the renderer's OnElementPropertyChanged method as they are in iOS and Android.  I suspect this is an oversight but for now other methods need to be used.

protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
    base.OnElementPropertyChanged(sender, e);

    if (Control == null) return;

    if (this.Element.Aspect == Aspect.Fill)
    {
        if (e.PropertyName == VisualElement.HeightProperty.PropertyName ||
            e.PropertyName == VisualElement.WidthProperty.PropertyName)
        {
            DrawFill();               
        }
    }
    else
    {
        if (e.PropertyName == Image.IsLoadingProperty.PropertyName
            && !this.Element.IsLoading && this.Control.Image != null)
        {
            DrawOther();
        }
    }
}

The code here is pretty simple.  Base on the right phase in the lifecycle and what Aspect we are using we either draw for our masking (DrawFill) or re-size the image (DrawOther).  For now I'll ignore the masking and focus on the DrawOther method.

private void DrawOther()
{
    int height = 0;
    int width = 0;
    int top = 0;
    int left = 0;

    switch (this.Element.Aspect)
    {
        case Aspect.AspectFill:
            height = (int)this.Control.Image.Size.Height;
            width = (int)this.Control.Image.Size.Width;
            height = this.MakeSquare(height, ref width);
            left = (((int)this.Control.Image.Size.Width - width) / 2);
            top = (((int)this.Control.Image.Size.Height - height) / 2);
            break;
        case Aspect.AspectFit:
            height = (int)this.Control.Image.Size.Height;
            width = (int)this.Control.Image.Size.Width;
            height = this.MakeSquare(height, ref width);
            left = (((int)this.Control.Image.Size.Width - width) / 2);
            top = (((int)this.Control.Image.Size.Height - height) / 2);
            break;
        default:
            throw new NotImplementedException();
    }

    UIImage image = this.Control.Image;
    var clipRect = new RectangleF(0, 0, width, height);
    var scaled = image.Scale(new SizeF(width, height));
    UIGraphics.BeginImageContextWithOptions(new SizeF(width, height), false, 0f);
    UIBezierPath.FromRoundedRect(clipRect, Math.Max(width, height) / 2).AddClip();

    scaled.Draw(new RectangleF(0, 0, scaled.Size.Width, scaled.Size.Height));
    UIImage final = UIGraphics.GetImageFromCurrentImageContext();
    UIGraphics.EndImageContext();
    this.Control.Image = final;
}

A couple of things to notice.  I look at the aspect to figure out the size of the image I need.  If it is Aspect Fill I expect that the image may be higher or wider than the control's requested width or height.  For Aspect Fit I expect that neither the height nor width of the image will be larger than the requested amounts but that at least one will be equal.  In both cases I expect the image to be square.

With the new width and height I now scale the image to the right size (image.Scale(new SizeF(width, height));).  I can then use the FromRoundedRect function to crop the image and make it round with the rest transparent.  When I am done I make the new image the image for the control's image property and that's what will be used.

Android was a little more complex but in many ways the same.  The first thing was that the masking works correctly with the Aspect Fit scenario instead of Fill like on iOS.  The second is that we want to apply the mask on the DrawChild method override and not OnElementPropertyChanged.

protected override bool DrawChild(Canvas canvas, global::Android.Views.View child, long drawingTime)
{
    if (this.Element.Aspect == Aspect.AspectFit)
    {
        var radius = Math.Min(Width, Height)/2;
        var strokeWidth = 10;
        radius -= strokeWidth/2;

        var path = new Path();
        path.AddCircle(Width/2, Height/2, radius, Path.Direction.Ccw);
        canvas.Save();
        canvas.ClipPath(path);

        var result = base.DrawChild(canvas, child, drawingTime);

        path.Dispose();

        return result;

    }

    return base.DrawChild(canvas, child, drawingTime);
}

I check to see if this is actually an Aspect Fit situation and if not just don't so anything.  If it is I want to clip the existing image.  The AddCircle command works nicely for this.  This command always takes a center X and Y and a radius to draw a circle.  We calculate how large we want the circle to be by looking at the width and height of the control as they have been calculated by this point.  Once this is done successfully we return true and have a nice round image view that fits nicely in the control's bounds.

For the other two we look at OnElementPropertyChanged like we did on iOS and if we are done loading convert the drawable into a bitmap.

protected async override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
    base.OnElementPropertyChanged(sender, e);
    if (e.PropertyName == Image.IsLoadingProperty.PropertyName && !this.Element.IsLoading
        && this.Control.Drawable != null)
    {
        //Should only be true right after an image is loaded
        if (this.Element.Aspect != Aspect.AspectFit)
        {
            using (var sourceBitmap = Bitmap.CreateBitmap(this.Control.Drawable.IntrinsicWidth, this.Control.Drawable.IntrinsicHeight, Bitmap.Config.Argb8888))
            {
                var canvas = new Canvas(sourceBitmap);
                this.Control.Drawable.SetBounds(0, 0, canvas.Width, canvas.Height);
                this.Control.Drawable.Draw(canvas);
                this.ReshapeImage(sourceBitmap);
            }
                    
        }
    }
}

Once we have a new source bitmap I call ReshapeImage to manipulate it:

private void ReshapeImage(Bitmap sourceBitmap)
{
    if (sourceBitmap != null)
    {
        var sourceRect = GetScaledRect(sourceBitmap.Height, sourceBitmap.Width);
        var rect = this.GetTargetRect(sourceBitmap.Height, sourceBitmap.Width);
        using (var output = Bitmap.CreateBitmap(rect.Width(), rect.Height(), Bitmap.Config.Argb8888))
        {
            var canvas = new Canvas(output);

            var paint = new Paint();
            var rectF = new RectF(rect);
            var roundRx = rect.Width() / 2;
            var roundRy = rect.Height() / 2;

            paint.AntiAlias = true;
            canvas.DrawARGB(0, 0, 0, 0);
            paint.Color = Android.Graphics.Color.ParseColor("#ff424242");
            canvas.DrawRoundRect(rectF, roundRx, roundRy, paint);
            paint.SetXfermode(new PorterDuffXfermode(PorterDuff.Mode.SrcIn));
            canvas.DrawBitmap(sourceBitmap, sourceRect, rect, paint);

            this.Control.SetImageBitmap(output);
            // Forces the internal method of InvalidateMeasure to be called.
            this.Element.WidthRequest = this.Element.WidthRequest;
        }
    }
}

Android works primarily in rectangles for the area to draw in.  I get two rectangles, a scaled rectangle that is used for the area to resize the image and a target rectangle for where it will display on the control.  A new target bitmap is created that will be used by the control.  I won't go too deeply into the image manipulation commands but one thing to notice is the DrawRoundRect has an x radius and a y radius.  That allows me to draw a nice oval in fill mode as I was not able to do in iOS.

The other thing that may look strange is setting the Element's WidthRequest property to itself.  I did this because I wanted to call the base class's InvalidateMeasure method to let the control take appropriate action with the new image.  Since this is a protected method I can't call it directly.  However, when the WidthRequest is set, InvalidateMeasure is then called.  It was just a way to force an InvalidateMeasure call.

I hope you find this useful.  The full code case be found in the XForms labs project and will shortly be coming to our nuget packages.  What's next?  Finishing the Windows Phone renderer and then adding a border, allowing the user to set the color and width.

I'll be speaking in a few weeks at Modern Apps Live in Orlando (strangely enough not on Xamarin).  I hope to see you there.  Modern Apps Live 2014 Orlando

3 comments:

  1. I'm glad you find it useful.

    With Android be careful with the amount and size of images you load into memory. The default ImageSource types do not attempt to resize on loading which is a common way to managing this. I plan on writing a blog post in a few weeks with a way to manage this.

    ReplyDelete
  2. Nice work Kevin!

    However, I'm not using Xamarin Forms. How would I go about using this in Xamarin.Android and Xamarin.iOS projects? Do you have any samples?

    Thanks in advance!

    ReplyDelete