Here's a conundrum. The recommended way of dealing with images, on both iOS and Android, is to have multiple copies of each of your images at a set range of sizes so that the underlying SDK can use the correctly sized image for the pixel density of the device. This prevents low-res images being used on high-end devices and doesn't wastefully use high-res images on low-end devices. From a performance perspective, this is the best option available; however, in practice, this means you need 6 copies of each image for your app.
The styling model in Xamarin.Forms is similar to WPF - you have resource dictionaries where you declare colors and styles for your UI elements. The usage can either be static, such that the value never changes, or dynamic, the value can change at runtime. This enables features like switching color themes at runtime or scaling text sizes in line with the device's accessibility settings.
So what happens if your app has lots of image assets, but also wants to support dynamic color theming? What options do you have for dealing with images if the image uses colors from your theme?
  1. You could make all your images color-theme agnostic, but having to compromise on your app's UI is far from ideal.
  2. Throw it over the wall to a designer, get them to create all image assets in every required color, and then use some naming convention to select the correct images at runtime? What if you want to remain on good terms with the designer, or you're not fully set on the color themes themselves, or want to enable users to pick their own colors?

  3. You could use a custom font file for iconography, and then each device renders an image in the correct color on the fly and uses the resulting image file. This is a decent option and there's plenty of guides on doing this out there, but it can get complicated with regards to getting the right resolutions for the device's pixel density. Composite images like a calendar with a day number become a royal pain because you need to render multiple images and overlay them.

  4. Use one set of image assets, and recolor the images on the fly, while letting the underlying SDK pick the correct image size for the device. Viable if your images are a single color on a transparent background.

  5. Give up and reconsider your life choices.

In this article, I'm going to walk through an implementation for option #4. Source code is available here.
Starting off
So, image elements in Xamarin Forms take a FileImageSource, and then the platform specific renderer loads the image asset and applies it to the native UI. We're going to create an Image subclass that intercepts and converts the image color when the source is assigned. With this in mind, let's define an abstraction for changing the color of an image. We're also going to need to get the image asset data in a similar fashion to the Xamarin.Forms renderers, so let's define those as well. We'll also need to work with the filesystem, but, for simplicity's sake, I'll leave that as an exercise for the reader.
interface IColorTransformService
{
    FileImageSource TransformFileImageSource(FileImageSource source, Color outputColor);
}

public interface IPlatformResourceImageResolver
{
    Stream GetImageData(FileImageSource source);
}
Getting the image data
On Android, to get the image data we need to find the ID of the image asset, then calling GetDrawable will select the most appropriately sized image for the device's screen. We'll then write the bitmap data to a stream and hand that back to our cross-platform transform service.
class DroidImageResolver : IPlatformResourceImageResolver
{
    private readonly Context _context;

    public DroidImageResolver(Context context)
    {   
        _context = context;
    }

    public Stream GetImageData(FileImageSource source)
    {
        string file = source?.File;
        if (string.IsNullOrWhiteSpace(file)) throw new ArgumentException($"Expected a file image source, but no file was specified");

        var imageId = _context.Resources.GetIdentifier(file, "drawable", _context.PackageName);

        using (var drawable = _context.GetDrawable(imageId))
        {
            Bitmap bitmap = ((BitmapDrawable) drawable).Bitmap;

            Stream memoryStream = new MemoryStream();
            bitmap.Compress(Bitmap.CompressFormat.Png, quality: 100, stream: memoryStream);
            memoryStream.Seek(0, SeekOrigin.Begin);

            return memoryStream;
        }
    }
}
And on iOS it's a little simpler, we create a UIImage, get the NSData, and call AsStream to get an unmanaged memory stream.
class iOSImageResolver : IPlatformResourceImageResolver
{
    public Stream GetImageData(FileImageSource source)
    {
        var filesource = source;
        var file = filesource?.File;
        if (!string.IsNullOrEmpty(file))
        {
            var image = File.Exists(file) ? new UIImage(file) : UIImage.FromBundle(file);
            return image.AsPNG().AsStream();
        }

        throw new ArgumentException("Image file did not exist");
    }
}
Wire these up into your IoC container/Service locator of choice and let's move on.
Converting the image colors
For manipulating the image data, we're going to use SkiaSharp. SkiaSharp is a cross-platform 2D graphics API for .Net platforms based on Google's Skia Graphics Library. As well as giving us access to various drawing primitives, we can also define transformations and color remap tables to use while rendering.
Let's start by creating a bitmap from the data provided by each platform, and a second bitmap for us to draw our recolored image to.
using (Stream sourceImageStream = _platformImageResolver.GetImageData(fileImageSource))
using (SKBitmap sourceBitmap = SKBitmap.Decode(sourceImageStream))
{
    SKImageInfo info = new SKImageInfo(
        sourceBitmap.Width,
        sourceBitmap.Height,
        sourceBitmap.ColorType,
        sourceBitmap.AlphaType,
        sourceBitmap.ColorSpace);

    using (SKBitmap outputBitmap = new SKBitmap(info))
    using (SKCanvas canvas = new SKCanvas(outputBitmap))
    {
        // Draw to the canvas
    }
}
To copy one bitmap to the other, we simply need to call canvas.DrawBitmap(sourceBitmap). This tells Skia what to draw. To specify how it should be drawn, we need to provide a SKPaint brush which gives us access to things such as blending, antialiasing, filtering, fonts and so on. The property we're interested in is the ColorFilter. Color filters can be specified either by a transformation matrix or by color remap tables. I'll not go into details in this post of how transformation matrices work, but here's a good explanation if you'd rather go that route.
Color remap tables work by specifying a series of arrays. As each pixel is drawn to the canvas, the R, G, B and Alpha byte values are used as indexes to look up the new color value to use while drawing. You can omit remap tables for each component by passing null, and then that color component remains unchanged.

So in our scenario, expecting a single color image on a transparent background, we're going to have every possible R, G and B value transform into the output color, and leave the alpha channel unchanged.
using (Stream sourceImageStream = _platformImageResolver.GetImageData(fileImageSource))
using (SKBitmap sourceBitmap = SKBitmap.Decode(sourceImageStream))
{
    SKImageInfo info = new SKImageInfo(
        sourceBitmap.Width,
        sourceBitmap.Height,
        sourceBitmap.ColorType,
        sourceBitmap.AlphaType,
        sourceBitmap.ColorSpace);

    using (SKBitmap outputBitmap = new SKBitmap(info))
    using (SKCanvas canvas = new SKCanvas(outputBitmap))
    using (SKPaint transformationBrush = new SKPaint())
    {
        canvas.DrawColor(SKColors.Transparent);

        SKColor targetColor = outputColor.ToSKColor();

        var tableRed = new byte[256];
        var tableGreen = new byte[256];
        var tableBlue = new byte[256];

        // We expect the icon to be a single color on a transparent background
        for (int i = 0; i < 256; i++)
        {
            tableRed[i] = targetColor.Red;
            tableGreen[i] = targetColor.Green;
            tableBlue[i] = targetColor.Blue;
        }

        transformationBrush.ColorFilter =
            SKColorFilter.CreateTable(null, tableRed, tableGreen, tableBlue);

        canvas.DrawBitmap(
            sourceBitmap,
            info.Rect /* Draw the whole image, not a subset */,
            transformationBrush);

        using (var image = SKImage.FromBitmap(outputBitmap))
        using (var data = image.Encode(SKEncodedImageFormat.Png, 100))
        using (var stream = new FileStream(outputFilePath, FileMode.Create, FileAccess.Write))
            data.SaveTo(stream);
    }
}
In its simplest sense, that's all that's needed to recolor the image and save it to a file. That file path can then be used in a FileImageSource and provided as the source to the Xamarin.Forms.Image class. There are a few more caveats here however. When iOS loads an image from the application bundle or a path on disk, it uses a naming convention @2X or @3X to specify the scale of the image for the device's pixel density. You can get this using UIScreen.MainScreen.Scale. If your output file path doesn't use this naming convention, your images will appear at the wrong size. A similar issue will happen on Android, but this is easily worked around by specifying the HeightRequest and WidthRequest properties of the image control, and the image source will be sized appropriately. It would also be wise to create a simple in-memory cache to remember file paths so that once an image has been recolored we can reuse that image rather than drawing the same thing multiple times.
class ColorTransformService : IColorTransformService
{
    private readonly IFileSystem _filesystem;
    private readonly IPlatformResourceImageResolver _resolver;
    private readonly TransformedImageCache _cache = new TransformedImageCache();

    public ColorTransformService(IFileSystem filesystem, IPlatformResourceImageResolver resolver)
    {
        _filesystem = filesystem ?? throw new ArgumentNullException(nameof(filesystem));
        _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
    }

    public FileImageSource TransformFileImageSource(FileImageSource source, Color outputColor)
    {
        string transformedFile = null;

        if (_cache.TryGetValue(source.File, outputColor, out transformedFile))
        {
            return (FileImageSource)ImageSource.FromFile(transformedFile);
        }

        transformedFile = _filesystem.GetTempImageFilePathForCurrentPixelDensity();

        using (Stream sourceImageStream = _resolver.GetImageData(source))
        using (SKBitmap sourceBitmap = SKBitmap.Decode(sourceImageStream))
        {
            SKImageInfo info = new SKImageInfo(
                sourceBitmap.Width,
                sourceBitmap.Height,
                sourceBitmap.ColorType,
                sourceBitmap.AlphaType,
                sourceBitmap.ColorSpace);

            using (SKBitmap outputBitmap = new SKBitmap(info))
            using (SKCanvas canvas = new SKCanvas(outputBitmap))
            using (SKPaint transformationBrush = new SKPaint())
            {
                canvas.DrawColor(SKColors.Transparent);

                var targetColor = outputColor.ToSKColor();

                var tableRed = new byte[256];
                var tableGreen = new byte[256];
                var tableBlue = new byte[256];

                for (int i = 0; i < 256; i++)
                {
                    tableRed[i] = targetColor.Red;
                    tableGreen[i] = targetColor.Green;
                    tableBlue[i] = targetColor.Blue;
                }

                // Alpha channel remains unchanged
                transformationBrush.ColorFilter =
                    SKColorFilter.CreateTable(null, tableRed, tableGreen, tableBlue);

                canvas.DrawBitmap(sourceBitmap, info.Rect, transformationBrush);

                using (var image = SKImage.FromBitmap(outputBitmap))
                using (SKData data = image.Encode(SKEncodedImageFormat.Png, 100))
                using (var stream = new FileStream(transformedFile, FileMode.Create, FileAccess.Write))
                    data.SaveTo(stream);
            }
        }

        _cache.Add(source.File, outputColor, transformedFile);

        return (FileImageSource) ImageSource.FromFile(transformedFile);
    }

    private class TransformedImageCache
    {
        private readonly Dictionary<CacheKey, string> _cachedImagesFiles = new Dictionary<CacheKey, string>();

        private class CacheKey : IEquatable
        {
            public CacheKey(string sourceImageName, Color outputColor)
            {
                SourceImageName = sourceImageName;
                OutputColor = outputColor;
            }

            public string SourceImageName { get; private set; }
            public Color OutputColor { get; private set; }

            public bool Equals(CacheKey other)
            {
                if (ReferenceEquals(null, other)) return false;
                if (ReferenceEquals(this, other)) return true;
                return string.Equals(SourceImageName, other.SourceImageName) && OutputColor.Equals(other.OutputColor);
            }

            public override bool Equals(object obj)
            {
                if (ReferenceEquals(null, obj)) return false;
                if (ReferenceEquals(this, obj)) return true;
                if (obj.GetType() != this.GetType()) return false;
                return Equals((CacheKey) obj);
            }

            public override int GetHashCode()
            {
                unchecked
                {
                    return ((SourceImageName != null ? SourceImageName.GetHashCode() : 0) * 397) ^ OutputColor.GetHashCode();
                }
            }
        }

        public bool TryGetValue(string sourceFile, Color outputColor, out string transformedFile)
        {
            var key = new CacheKey(sourceFile, outputColor);

            return _cachedImagesFiles.TryGetValue(key, out transformedFile);
        }

        public void Add(string sourceFile, Color outputColor, string transformedFile)
        {
            var key = new CacheKey(sourceFile, outputColor);

            _cachedImagesFiles[key] = transformedFile;
        }
    }
}
Putting it to use
We've got all the heavy lifting in place, now we just need a control to display the images. I'm going to create a subclass of Image so that we can keep the source image file, and the transformed image file separate. We won't need any custom renderers, the default Xamarin image renderers will do just fine. To use this, in your Xaml specify a ColorTransformImage rather than Image, and data bind to the SourceImage and TargetTintColor properties.
class ColorTransformImage : Image
{
    private IColorTransformService _transformer;

    private IColorTransformService Transformer
    {
        get
        {
            return _transformer ?? (_transformer = YourServiceLocator.Resolve());
        }
    }

    public static readonly BindableProperty SourceImageProperty = BindableProperty.Create(nameof(SourceImage), typeof(FileImageSource), typeof(ColorTransformImage), null, propertyChanged: OnSourceImagePropertyChanged);

    public static readonly BindableProperty SourceImageColorProperty = BindableProperty.Create(nameof(SourceImageColor), typeof(Color), typeof(ColorTransformImage), Color.Default, propertyChanged: OnSourceImageColorPropertyChanged);

    public static readonly BindableProperty TargetTintColorProperty = BindableProperty.Create(nameof(TargetTintColor), typeof(Color), typeof(ColorTransformImage), Color.Default, propertyChanged: OnTargetTintColorPropertyChanged);

    public ImageSource SourceImage
    {
        get => (ImageSource)GetValue(SourceImageProperty);
        set => SetValue(SourceImageProperty, value);
    }

    public Color SourceImageColor
    {
        get => (Color)GetValue(SourceImageColorProperty);
        set => SetValue(SourceImageColorProperty, value);
    }

    public Color TargetTintColor
    {
        get => (Color)GetValue(TargetTintColorProperty);
        set => SetValue(TargetTintColorProperty, value);
    }

    private static void OnSourceImagePropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
    {
        ColorTransformImage button = (ColorTransformImage)bindable;
        if (CanTransformSourceImage(button))
            button.TransformSourceImage();
    }

    private static void OnSourceImageColorPropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
    {
        ColorTransformImage button = (ColorTransformImage)bindable;
        if (CanTransformSourceImage(button))
            button.TransformSourceImage();
    }

    private static void OnTargetTintColorPropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
    {
        ColorTransformImage button = (ColorTransformImage)bindable;
        if (CanTransformSourceImage(button))
            button.TransformSourceImage();
    }

    private static bool CanTransformSourceImage(ColorTransformImage button)
    {
        return button.SourceImage != null;
    }

    private void TransformSourceImage()
    {
        var imageSource = (FileImageSource)SourceImage;

        if (SourceImageColor == TargetTintColor || !IsValidForTransformation(TargetTintColor))
        {
            Source = imageSource;
            return;
        }

        this.Source = Transformer.TransformFileImageSource(imageSource, this.TargetTintColor);
    }

    public bool IsValidForTransformation(Color color)
    {
        // Color.Default has negative values for R,G,B,A
        return color.R >= 0 &&
                color.G >= 0 &&
                color.B >= 0;
    }
}
There we go, pretty simple once broken down.
  • Platform-specific implementations for loading image assets
  • Some platform specifics for file system access
  • Cross-platform image processing with SkiaSharp
  • Cross-platform control which leverages the default image renderers
  • Simple caching to minimize the performance hit
Demo source code is available on Github.

About the author

Related articles