Draw image in Xamarin Android app with your finger

Draw image in Xamarin Android app with your finger

Draw image in Xamarin Android app with your finger

Working with bitmaps is platform specific, hence to support pictures edit functionality, custom control should be created as well as a renderer on the needed platform.

Let’s implement this for Android platform.

Firstly we need to create control class in Xamarin cross-platform project and add some fundamental properties that will be used to draw on a bitmap.


public class EditablePicture : ContentView
   {
       public static readonly BindableProperty CurrentLineColorProperty = BindableProperty.Create(
           nameof(CurrentLineColor), typeof(Color), typeof(EditablePicture), Color.Default
       );
       public static readonly BindableProperty CurrentBrushSizeProperty = BindableProperty.Create(
            nameof(CurrentBrushSize), typeof(double), typeof(EditablePicture), 10d
       );
       public static readonly BindableProperty PicturePathProperty = BindableProperty.Create(
            nameof(PicturePath), typeof(string), typeof(EditablePicture)
        );

       public Color CurrentLineColor
       {
           get { return (Color)GetValue(CurrentLineColorProperty); }
           set { SetValue(CurrentLineColorProperty, value); }
       }

       public double CurrentBrushSize
       {
           get { return (double)GetValue(CurrentBrushSizeProperty); }
           set { SetValue(CurrentBrushSizeProperty, value); }
       }

       public double PicturePath
       {
           get { return (double)GetValue(PicturePathProperty); }
           set { SetValue(PicturePathProperty, value); }
       }   
   }

Now to use native Android APIs let’s create the custom control in Xamarin Android project.

public class BitmapEditView : View
    {
        private Path _drawPath;
        private Paint _drawPaint;
        private Paint _paint;
        private Canvas _drawCanvas;
        private Bitmap _drawBitmap;
        private float _workspaceWidth = 0;
        private float _workspaceHeight = 0;
        private Size _scaleSize;

        public BitmapEditView(Context context, Bitmap sourceBitmap) : base(context)
        {
            Initialize(sourceBitmap);
        }

        public Color DrawColor { get; set; }
        public float PenWidth { get; set; }

        private void Initialize(Bitmap sourceBitmap)
        {
            _drawBitmap = sourceBitmap;

            DrawColor = Color.Black;
            PenWidth = 10.0f;

            _drawPath = new Path();
            _paint = new Paint();
            _drawPaint = new Paint
            {
                Color = DrawColor,
                StrokeWidth = PenWidth,
                StrokeJoin = Paint.Join.Round,
                StrokeCap = Paint.Cap.Round
            };
            _drawPaint.SetStyle(Paint.Style.Stroke);
        }

    protected override void OnDraw(Canvas canvas)
        {
            base.OnDraw(canvas);

            canvas.DrawBitmap(_drawBitmap, null, new RectF(0, 0, _workspaceWidth, _workspaceHeight), _paint);
            canvas.DrawPath(_drawPath, _drawPaint);
        }
    }

The OnDraw method is responsible for drawing of the current bitmap and custom lines made by a user. To make it work, we also need to set control width and height and calculate drawing paths.

protected override void OnSizeChanged(int w, int h, int oldw, int oldh)
{
    base.OnSizeChanged(w, h, oldw, oldh);

    _workspaceWidth = w;
    _workspaceHeight = h;
}

public override bool OnTouchEvent(MotionEvent e)
{
    var touchX = e.GetX();
    var touchY = e.GetY();

    switch (e.Action)
    {
        case MotionEventActions.Down:
            _drawPath.MoveTo(touchX, touchY);
            break;
        case MotionEventActions.Move:
            _drawPath.LineTo(touchX, touchY);
            break;
        case MotionEventActions.Up:
            _drawPath.Reset();
            break;
        default:
            return false;
    }

    Invalidate();
    return true;
}

At this point, we will have our bitmap and lines to be drawn, but changes won’t be preserved. To correct this behavior we need to extend methods above with some additional functionality. To keep drawing lines, we need to create the canvas and link it to our bitmap.

protected override void OnSizeChanged(int w, int h, int oldw, int oldh)
{
    ...
    InitializeDrawCanvas(w, h);
}
public override bool OnTouchEvent(MotionEvent e)
{
...
    case MotionEventActions.Up:
        _drawCanvas.DrawPath(_drawPath, _drawPaint);
     	_drawPath.Reset();
		break;
		...
}

private void InitializeDrawCanvas(float width, float height)
{
    if (_drawBitmap != null)
    {
        var imageSize = new Size(_drawBitmap.Width, _drawBitmap.Height);
        var targetSize = new Size(width, height);

        _scaleSize = CalculateScale(targetSize, imageSize);
    }
    else
    {
        _drawBitmap = Bitmap.CreateBitmap((int)width, (int)height, Bitmap.Config.Argb8888);
        _scaleSize =  = new Size(1f, 1f);
    }
            
    _drawCanvas = new Canvas(_drawBitmap);
    _drawCanvas.Scale((float)_scaleSize.Width, (float) _scaleSize.Height);
}

As control size is usually different from the bitmap size we need to handle size scaling.

public Size CalculateScale(Size imageSize, Size boxSize)
{
   var widthScale = imageSize.Width / boxSize.Width;
   var heightScale = imageSize.Height / boxSize.Height;
   var scale = new Size(widthScale, heightScale);

   return scale;
}

It looks like needed functionality is ready, but one thing is still missing. Our control size ratio in most cases will be different from the picture aspect ratio and the picture will be stretched to fit the control bounds.

The easiest way to keep the picture aspect ratio is to arrange our control into needed calculated bounds.

public override void Layout(int l, int t, int r, int b)
{
    var imageSize = new Size(_drawBitmap.Width, _drawBitmap.Height);
    var targetSize = new Size(MeasuredWidth, MeasuredHeight);
    Rectangle rectangle = GetRatioScaledRectangle(imageSize, targetSize);

    var left = (MeasuredWidth - rectangle.Width) / 2;
    var top = (MeasuredHeight - rectangle.Height) / 2;
    base.Layout((int)left, (int)top, (int)(rectangle.Width + left), (int)(rectangle.Height + top));
}

public static Rectangle GetRatioScaledRectangle(Size imageSize, Size boxSize)
{
   var boxAspectRatio = boxSize.Width / boxSize.Height;
   var imageAspectRatio = imageSize.Width / imageSize.Height;
   Rectangle rect;

   if (boxAspectRatio > imageAspectRatio)
   {
       rect = new Rectangle(0, 0, imageSize.Width * boxSize.Height / imageSize.Height, boxSize.Height);
   }
   else
   {
       rect = new Rectangle(0, 0, boxSize.Width, imageSize.Height * boxSize.Width / imageSize.Width);
   }

   return rect;
}

As the final step let’s add the method for saving edited picture.

public async Task Save(EditablePicture element)
{
    try
    {
        using (var stream = new System.IO.FileStream(element.PicturePath, System.IO.FileMode.OpenOrCreate))
        {
            await _drawBitmap.CompressAsync(Bitmap.CompressFormat.Jpeg, 100, stream);
        }
        _drawBitmap.Recycle();

    }
    catch (System.Exception ex)
    { }
} 

Now we have cross-platform control and native android view. To put them together we need to create xamarin custom control renderer.

[assembly: ExportRenderer(typeof(EditablePicture), typeof(EditablePictureRenderer))]
public class EditablePictureRenderer : ViewRenderer<EditablePicture, BitmapEditView>
{
    private Bitmap _currentBitmap;

    protected override async void OnElementChanged(ElementChangedEventArgs<EditablePicture> e)
    {
       base.OnElementChanged(e);
       try
       {
           if (e.OldElement != null) return;

           _currentBitmap = await GetBitmapFromPath(e.NewElement.PicturePath);
           var editView = new BitmapEditView(Context, _currentBitmap);
           SetNativeControl(editView);
       }
       catch(Exception) { }
   } 

   private async Task<Bitmap> GetBitmapFromPath(string path)
   {
       RecyclePreviousBitmap();
          
       BitmapFactory.Options bmOptions = new BitmapFactory.Options() { InMutable = true, InPurgeable = true };
       _currentBitmap = await BitmapFactory.DecodeFileAsync(path, bmOptions);
       return _currentBitmap;
   }

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

       if (e.PropertyName == EditablePicture.CurrentLineColorProperty.PropertyName)
       {
           UpdateControl();
       }

       if (e.PropertyName == EditablePicture.CurrentBrushSizeProperty.PropertyName)
       {
           UpdateControl();
       }           
   }

   private void UpdateControl()
   {
       Control.DrawColor = Element.CurrentLineColor.ToAndroid();
       Control.PenWidth = (float)Element.CurrentBrushSize;
   }

   private void RecyclePreviousBitmap()
   {
       if (_currentBitmap != null && !_currentBitmap.IsRecycled)
       {
           _currentBitmap.Recycle();
       }
   }

   protected override void OnDetachedFromWindow()
   {
       base.OnDetachedFromWindow();

       RecyclePreviousBitmap();
   }
}

Always keep in mind that android stores bitmap in the heap, size of which is very limited. To avoid memory leaks and out of memory exceptions never forget to recycle not used bitmaps.

Happy Coding!

Author

Mykhaylo Katruk

comments powered by Disqus