Tuesday, 16 April 2019
Patrick Allwood
5 minute read
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?
In this article, I'm going to walk through an implementation for option #4. Source code is available here.
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);
}
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.
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;
}
}
}
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/ Hopefully simple once broken down:
SkiaSharp
Demo source code is available on Github.
Last updated: Monday, 19 June 2023
Senior Software Developer
He/him
Patrick is a Senior Software Developer at Rock Solid Knowledge.
We're proud to be a Certified B Corporation, meeting the highest standards of social and environmental impact.
+44 333 939 8119