Search code examples
xamarinxamarin.formsskiasharp

Example of how to create an image with clickable regions


This is not a question, but an article on how to do this.
All the details and focus will be in the answer to this question.

What is it about:
I was looking for a way to achieve is this

enter image description here

This is what I call a image with clickable regions, it seemed so simple but it took me some doing

What I found about this topic:
I found many questions about this on stack and other sites, but nothing about how it is actually done. Only bits and pieces scattered around.
I noticed SkiaSharp being mentioned a few times, so I searched on how to do this using that library, but again no actuall answer.
So I looked at their documentation and examples, what I needed was in there but it's hard to find if you don't know what methods and terminology to search for.
Also I had never worked with vector images before this, that was completely new for me, so I did not knew that was what I had to search on.

What I have came up with
So I took some time to figure it out, had to learn about vector images, about the skiasharp package, and lots of other stuff.

I managed to get it working, and I decided to write down here all the steps that are needed, so other people don't have to do that same search. It is all in my answer on this question.

The example in my answer is done with xamarin forms using c# and the SkiaSharp package

You can see a step by step approach in detail on how to do this, hopefully other people that are searching for this can use this as a starting point.


Solution

  • I had to do a lot of searching, researching and experimenting on how to do this.
    Maybe there are better ways of doing this, I don't know, but this method sure works and I want to share my findings here so other people can benefit from my struggles.

    What I want to achieve is this

    enter image description here

    Looks not so bad hey, let's see how this is done

    So, what do you need for this ?

    You have to get the SkiaSharp package from nuget, this can be easily installed using the nuget manager, search for SkiaSharp.Views.Forms

    Next you need an image that you can use as your base image, in this example the image of the car you can see in the gif above.

    The XAML file

    The xaml file is actually very simple, in the example above I need a label on top, a Skia Canvas in the middle, and 2 buttons with a label between them at the bottom

    <StackLayout VerticalOptions="Start" HorizontalOptions="FillAndExpand" Orientation="Horizontal" Margin="1, 1">
        <Label Text="SELECT DAMAGE REGION" 
               VerticalOptions="StartAndExpand" HorizontalOptions="FillAndExpand" >
        </Label>
    </StackLayout>
    
    <skia:SKCanvasView 
        x:Name="sKCanvasViewCar"
        HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" 
        EnableTouchEvents="True" >
    </skia:SKCanvasView>
    
    <StackLayout HorizontalOptions="FillAndExpand" Orientation="Horizontal" VerticalOptions="End" Padding="5, 5">
        <Button x:Name="ButtonSelectFromCarBack" 
                WidthRequest="150" 
                HorizontalOptions="Start" 
                Text="Back" />
        <Label x:Name="labelRegionCar" 
               VerticalOptions="Center" 
               HorizontalOptions="CenterAndExpand" HorizontalTextAlignment="Center" />
        <Button x:Name="ButtonSelectFromCarSelect" IsEnabled="false" 
                WidthRequest="150" 
                HorizontalOptions="End" 
                Text="Next" />
    </StackLayout>
    

    To avoid the error The type 'skia:SKCanvasView' was not found have this in the definition of the form xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"

    The code behind

    In the code behind we need to do 3 things

    1. draw the base image on the screen
    2. determine that someone clicked on the image, and where on the image he clicked
    3. draw the clicked region with a different color (red in this example) over the base image

    1. draw the base image on the screen

    My base image is a file called draw_regions_car.png that I have in my project as embedded resource
    To draw it on the Skia Canvas I need put it on a SKBitmap that I can use later in the event PaintSurface to draw it on the screen.

    In my example this looks like this

    namespace yourProject.Pages
    {
        [XamlCompilation(XamlCompilationOptions.Compile)]
        public partial class PageDamageSelectFromCarImage : ContentPage
        {
            private SKBitmap _bitmap;
            private SKMatrix _matrix = SKMatrix.CreateIdentity();
            private float _scale = 1f;
            private float _x;
            private float _y;
    
            bool _firstTime = true;
            string _region = "";
    
            private SKPath _path_10;
            private SKPath _path_11;
    
    
            public PageDamageSelectFromCarImage()
            {
                InitializeComponent();
    
                sKCanvasViewCar.PaintSurface += OnCanvasViewPaintSurface;
                sKCanvasViewCar.Touch += SKCanvasView_Touch;
    
                // put the car_region image from the resources into a skia bitmap, so we can draw it later in the Surface event
                string resourceID = "yourProject.Resources.draw_regions_car.png";
                var assembly = Assembly.GetExecutingAssembly();
                using (Stream stream = assembly.GetManifestResourceStream(resourceID))
                {
                    _bitmap = SKBitmap.Decode(stream);
                }
            }
    }
    

    and this is how the surface event looks like for now

    private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;
    
        canvas.Clear();
    
        if (_firstTime)
        {
            _firstTime = false;
            _scale = (float)info.Width / _bitmap.Width;
        }
    
        canvas.Scale(_scale);
        canvas.Translate(new SKPoint(_x, _y));
        _matrix = canvas.TotalMatrix;
    
        canvas.DrawBitmap(_bitmap, 0, 0);
    
        using (SKPaint paint = new SKPaint())
        {
            // here we will draw the selected regions over the base image
        }
    }
    

    If you would run the app now, it should display the base image on the screen.
    From this we can get to the next steps


    2. determine that someone clicked on the image, and where on the image he clicked

    To do this, we need to use the Touch event of the SKCanvasView (see the ctor in this article)
    In our example we called it SKCanvasView_Touch
    There I determine what kind of touch has happened, and if it was a click then I call a private method called OnPressed

    private void SKCanvasView_Touch(object sender, SKTouchEventArgs e)
    {
        switch (e.ActionType)
        {
            case SKTouchAction.Pressed:
                OnPressed(sender, e.Location);
                break;
        }
    }
    

    So in the OnPressed method we can handle all the clicks that are done on the image.
    For example, let's see how I can know if the user clicked on the front_door_left.
    To do that, I need a path of this door.
    What is this path??
    Well, it is a vector drawing. A vector drawing (find in .svg files) is a series of commands that when executed results in the image.
    for our front-door-left these commands could look like this

    "M77.5 289.7c.3 3.2 1.9 37.3 3.6 75.8 1.7 38.5 3.2 70.1 3.4 70.3.1.1 5.6.7 12.1 1.1 6.6.5 28 2.1 47.7 3.7 19.6 1.5 36 2.5 36.3 2.2.3-.3-.6-22.6-2-49.4-1.5-26.9-2.6-49.7-2.6-50.7 0-1.4-.9-1.7-4.8-1.7-7.1 0-8.2-1.6-8.2-12.3v-8.5l-3.7-1.3c-5.3-1.6-6.3-2.7-6.3-6.3 0-6 .9-6.3 10.7-2.8 4.9 1.8 9.2 3.2 9.6 3.2.9 0 .9-17.2-.1-18.5-.4-.6-3.6-3.2-7.1-5.8l-6.5-4.7H77l.5 5.7zm90.8 124.2c.4 6.3.6 12 .3 12.7-.3.8-1.9 1.4-3.6 1.4h-3v-26.1l2.8.3 2.7.3.8 11.4z"
    

    What they mean is not important for this example, what we need to do is create a variable of type SKPath that holds this value, and with that variable we can instruct Skia to do its magic for us.

    SKPath _path_10;
    
    _path_10 = SKPath.ParseSvgPathData("M77.5 289.7c.3 3.2 1.9 37.3 3.6 75.8 1.7 38.5 3.2 70.1 3.4 70.3.1.1 5.6.7 12.1 1.1 6.6.5 28 2.1 47.7 3.7 19.6 1.5 36 2.5 36.3 2.2.3-.3-.6-22.6-2-49.4-1.5-26.9-2.6-49.7-2.6-50.7 0-1.4-.9-1.7-4.8-1.7-7.1 0-8.2-1.6-8.2-12.3v-8.5l-3.7-1.3c-5.3-1.6-6.3-2.7-6.3-6.3 0-6 .9-6.3 10.7-2.8 4.9 1.8 9.2 3.2 9.6 3.2.9 0 .9-17.2-.1-18.5-.4-.6-3.6-3.2-7.1-5.8l-6.5-4.7H77l.5 5.7zm90.8 124.2c.4 6.3.6 12 .3 12.7-.3.8-1.9 1.4-3.6 1.4h-3v-26.1l2.8.3 2.7.3.8 11.4z");
    

    I will explain later how you can extract this Path for every region from your base image, for now let's focus on how to use this path.

    In the OnPressed method I can use this path to find out if the user clicked on this door or not, and if he did, then I will put code '10' in the private variabel _region
    After that, I call InvalidateSurface to fire the Surface OnCanvasViewPaintSurface event again, where we do all our drawing

    // I define this (and all other paths) on top of the class, so they are in the global scope for this class  
    SKPath _path_10;
    
    
    private async void OnPressed(object sender, SKPoint point)
    {
        SKPoint location = point;
    
        _region = "";
    
        if (_path_10.Contains(location.X, location.Y))
        {
            _region = "10";
        }
    
        sKCanvasViewCar.InvalidateSurface();
    }
    
    

    3. draw the clicked region with a different color (red in this example) over the base image

    Let's look at the event OnCanvasViewPaintSurface again with the extra code

    private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;
    
        canvas.Clear();
    
        if (_firstTime)
        {
            _firstTime = false;
            _scale = (float)info.Width / _bitmap.Width;
        }
    
        canvas.Scale(_scale);
        canvas.Translate(new SKPoint(_x, _y));
        _matrix = canvas.TotalMatrix;
    
        canvas.DrawBitmap(_bitmap, 0, 0);
    
        _path_10 = SKPath.ParseSvgPathData("m95 187.7-3.2 5.8-3.4 4.2-3.4 4.1-4.7 3.5-4.8 3.5-7 2.5-7 2.5-.3 33.6L61 281l2.2 1 2.3.9 20.5 3.6 20.5 3.6 55.5-.3 55.5-.3v-67l-1.9-4-2-4-16-16.3-16.1-16.2H98.2l-3.2 5.7zm85.3 2.7 3.3 1.3 13.9 14.7 14 14.7 1.5 2.7 1.5 2.7.3 21.1.3 21.1-1.5 3.7-1.5 3.7-3.5 1.4-3.5 1.5h-46.2l-2.9-1.5-2.9-1.5-.9-1.8-.9-1.7-.7-7.5-.7-7.5-3.5-28.2-3.5-28.3 1.5-2.7 1.4-2.8 2.4-2.2 2.3-2.2 2.5-1 2.5-1h10.8l10.8-.1 3.2 1.4zm-35.5 71.8 1.2 1.2V277l-2.5.6-2.5.6-2.5-.6-2.5-.6v-14.8l1.3-.5 1.2-.5 2.6-.1 2.5-.1 1.2 1.2z");
    
        _path_10.Transform(_matrix);
    
        using (SKPaint paint = new SKPaint())
        {
            if (_region == "10")
            {
                DrawRegion(canvas, paint, _path_10);
            }
        }
    }
    
    private void DrawRegion(SKCanvas canvas, SKPaint paint, SKPath path, bool strokeOnly = false, int strokeWidth = 1)
    {
        path.Transform(_matrix.Invert());
    
        if (strokeOnly == false)
        {
            paint.Style = SKPaintStyle.StrokeAndFill;
        }
        paint.Color = (Xamarin.Forms.Color.Red).ToSKColor();
        paint.StrokeWidth = strokeWidth;
        canvas.DrawPath(path, paint);
    }
        
    

    This you need to repeat for every region in the base image you want to be clickable

    private async void OnPressed(object sender, SKPoint point)
    {
        SKPoint location = point;
    
        _region = "";
    
        if (_path_10.Contains(location.X, location.Y))
        {
            _region = "10";
        }
        else if (_path_11.Contains(location.X, location.Y))
        {
            _region = "11";
        }
        // and so on...
    
        sKCanvasViewCar.InvalidateSurface();
    }
    
    
    private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        ...
    
        _path_10 = SKPath.ParseSvgPathData("m95 187.7-3.2 5.8-3.4 4.2-3.4 4.1-4.7 3.5-4.8 3.5-7 2.5-7 2.5-.3 33.6L61 281l2.2 1 2.3.9 20.5 3.6 20.5 3.6 55.5-.3 55.5-.3v-67l-1.9-4-2-4-16-16.3-16.1-16.2H98.2l-3.2 5.7zm85.3 2.7 3.3 1.3 13.9 14.7 14 14.7 1.5 2.7 1.5 2.7.3 21.1.3 21.1-1.5 3.7-1.5 3.7-3.5 1.4-3.5 1.5h-46.2l-2.9-1.5-2.9-1.5-.9-1.8-.9-1.7-.7-7.5-.7-7.5-3.5-28.2-3.5-28.3 1.5-2.7 1.4-2.8 2.4-2.2 2.3-2.2 2.5-1 2.5-1h10.8l10.8-.1 3.2 1.4zm-35.5 71.8 1.2 1.2V277l-2.5.6-2.5.6-2.5-.6-2.5-.6v-14.8l1.3-.5 1.2-.5 2.6-.1 2.5-.1 1.2 1.2z");
        _path_11 = SKPath.ParseSvgPathData("m52.2 356.7.3 59.8h165v-119l-82.8-.3L52 297l.2 59.7zm159.4-54.8 3.1 1.9.7 3.2.6 3.2v92.3l-1 3.6-1 3.7-2.5 1.6-2.4 1.6h-45.9l-4.1-4-4.1-4v-96.5l1.9-2.9 2-3 2.3-1.2 2.3-1.3 22.5-.1h22.5l3.1 1.9zM145.5 314v7.5l-4.7.3-4.8.3v-14.8l.7-.7.7-.7 4.1.3 4 .3v7.5z");
    
        _path_10.Transform(_matrix);
        _path_11.Transform(_matrix);
    
        using (SKPaint paint = new SKPaint())
        {
            if (_region == "10")
            {
                DrawRegion(canvas, paint, _path_10);
            }
    
            if (_region == "11")
            {
                DrawRegion(canvas, paint, _path_11);
            }
    
            // and so on...
        }
    
    

    How to extract a path from my base image ?

    My base image draw_regions_car.png is a simple png image, which is not a vector image and thus has no paths.
    So here is how I extract the path for the front-door-left from this image.
    First I open it in an application that is able to do complex selection, I use the free program paint.net for this.

    In there I select Tools/Magic Wand and drop it on the door, so it becomes selected, then I click on ctrl-I to revert the selection and then click on delete.

    enter image description here

    Now save this file, I saved it as 10.png
    Next step is to convert this to svg and for that I use the website https://svgco.de/

    enter image description here

    Now you have a .svg file you can open with any text editor, the content looks like this

    <svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 688 833">
    <path fill="#007CFF" d="M77.5 289.7c.3 3.2 1.9 37.3 3.6 75.8 1.7 38.5 3.2 70.1 3.4 70.3.1.1 5.6.7 12.1 1.1 6.6.5 28 2.1 47.7 3.7 19.6 1.5 36 2.5 36.3 2.2.3-.3-.6-22.6-2-49.4-1.5-26.9-2.6-49.7-2.6-50.7 0-1.4-.9-1.7-4.8-1.7-7.1 0-8.2-1.6-8.2-12.3v-8.5l-3.7-1.3c-5.3-1.6-6.3-2.7-6.3-6.3 0-6 .9-6.3 10.7-2.8 4.9 1.8 9.2 3.2 9.6 3.2.9 0 .9-17.2-.1-18.5-.4-.6-3.6-3.2-7.1-5.8l-6.5-4.7H77l.5 5.7zm90.8 124.2c.4 6.3.6 12 .3 12.7-.3.8-1.9 1.4-3.6 1.4h-3v-26.1l2.8.3 2.7.3.8 11.4z"/>
    <path fill="#0000CB" d="M77.2 291.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm97 20.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm-96 1.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm1 22c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm97 15.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm-96 6.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm97 12.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm-96 9.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm97 10.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm-96 11.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm97 7.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm-10.9 12.5c0 2.2.2 3 .4 1.7.2-1.2.2-3 0-4-.3-.9-.5.1-.4 2.3zm-85.1 2c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm79 1.5c0 1.4.2 1.9.5 1.2.2-.6.2-1.8 0-2.5-.3-.6-.5-.1-.5 1.3zm18 3.5c0 1.6.2 2.2.5 1.2.2-.9.2-2.3 0-3-.3-.6-.5.1-.5 1.8zm-92.4 8.2c.7.3 1.6.2 1.9-.1.4-.3-.2-.6-1.3-.5-1.1 0-1.4.3-.6.6zm26 2c.7.3 1.6.2 1.9-.1.4-.3-.2-.6-1.3-.5-1.1 0-1.4.3-.6.6zm40 3c.7.3 1.6.2 1.9-.1.4-.3-.2-.6-1.3-.5-1.1 0-1.4.3-.6.6zm13 1c.7.3 1.6.2 1.9-.1.4-.3-.2-.6-1.3-.5-1.1 0-1.4.3-.6.6z"/>
    </svg>
    

    And there you will find the path that you need in your code

    Conclusion

    Maybe there are better ways to do this, but at least this methods works.
    It is not even so complicated once you realize how this works, it just takes some time to set it all up.
    For all you people desperate looking on the web on how to do this, I hope you can all use this as a starting point.