Articles in this Series
Source Code for Calcium and Example Apps
The source code for this article is located in the Calcium source code repository located at https://calcium.codeplex.com/SourceControl/latest
See the solution \Trunk\Source\Calcium\Xamarin\Installation\CalciumTemplates.Xamarin.sln.
Introduction
Image resource management is a challenge when building cross-platform apps with Xamarin. Each platform requires images to be handled differently. Both Android and iOS projects use their own systems; placing images in a different directories. Windows Phone, however, lets you place images almost anywhere you please. If you wish to be able to use the same images across your projects, then you need to link them into various locations. Windows Phone is the biggest loser in this regard, because images must be placed in the root directory of your project, which is far from ideal. The other approach is to embed image resources into assemblies, which isn't a good idea when a CLR is present, as increasing the size of the assembly increases the time that the assembly takes to load.
Visual Studio Android and iOS projects require that all images be placed in a single directory. I'm not keen on having to place images into a single directory. I prefer to group things by functional relatedness. It’s not uncommon to see large web projects become littered with orphaned images, after the pages to which they once belonged have long since been removed.
I set about inventing a better way of sharing image resources between projects; one that would retain the flexibility of the Windows Phone system, but which would be compatible with iOS and Android. My approach uses a T4 template that copies the files from your shared projects into the various iOS and Android resources directories, adds them to your iOS and Android project, and correctly sets the Build Action of the images. You see later in the article how custom markup extensions translate image resource URLs so that images are correctly resolved regardless of platform. This approach gives you the all the flexibility of the Windows Phone resource system, while still retaining compatibility with iOS and Android.
The Image element is commonly used in Xamarin Forms to display a PNG or JPG image on a page. See the following example:
<Image Source="CalciumLogo.png" />
For the image to display correctly on each platform, it must be located in the Resources directory of the iOS project, the Resources/Drawable directory of the Android project, or the root directory of the Windows Phone.
TIP. There is a thorough introduction to working with the Image element on the Xamarin website at http://developer.xamarin.com/guides/cross-platform/xamarin-forms/working-with/images/
While it’s useful to have a solid understanding of the image resources in Xamarin Forms, most of the friction goes away when using the approach described in this article.
There is a PNG image named CalciumLogo.png located in the /Views/MainView/Images directory of the shared project. Unlike Windows Phone, the following path is not resolved when using Xamarin Forms:
<Image Source="/Views/MainView/Images/CalciumLogo.png" />
Let’s make it work.
The Source
property of the Image element is an ImageSource
. A type converter is used to convert the string value provided in XAML to an actual ImageSource
instance. We can take over the responsibility for creating an ImageSource
object from the specified URL by creating a custom markup extension.
One terrific feature of Xamarin Forms is the inclusion of markup extensions. I was pleasantly surprised to see them present in the first release of Xamarin Forms. Having the ability to create custom markup extensions gives us a powerful way to extend what we can do in XAML. We’re not stuck waiting for ‘official’ support for everything; we can go MacGyver and make it ourselves.
The ImageUrlTransformer
class, located in the Outcoder.Calcium.Android project in the Calcium source code repository, translates a string value to a platform specific URL. It has a single method named TransformForCurrentPlatform
. See Listing 1. The method accepts a URL and, if the app is running on iOS or Android, it replaces the path separator slashes with underscores. For example, if we pass in “/Views/MainView/Images/CalciumLogo.png” to this method on iOS or Android, the method should return “Views_MainView_Images_CalciumLogo.png”
Conversely, if the app is running on Windows Phone, the task becomes more straight forward; the markup extension resolves the image using the unaltered path provided.
Of course, for this to work correctly on iOS and Android there must be an image named Views_MainView_Images_CalciumLogo.png sitting in the respective resource directory for each platform. We examine this aspect in greater detail later in this section.
Please note that if the platform is iOS or Android and the specified URL begins with file:/// it is indicative that a Uri object, not a string, has been used to specify the image location. That’s okay, but it must be stripped from the URL for the image to be resolved correctly.
Listing 1. ImageUrlTransformer.TransformForCurrentPlatform method
public string TransformForCurrentPlatform(string url)
{
string result = ArgumentValidator.AssertNotNull(url, "url");
if (Device.OS == TargetPlatform.Android || Device.OS == TargetPlatform.iOS)
{
const string filePrefix = "file:///";
if (url.StartsWith(filePrefix))
{
result = url.Substring(filePrefix.Length);
}
result = result.Replace("/", "_").Replace("\\", "_");
if (result.StartsWith("_") && result.Length > 1)
{
result = result.Substring(1);
}
}
else if (Device.OS == TargetPlatform.WinPhone)
{
if (url.StartsWith("/") && url.Length > 1)
{
result = result.Substring(1);
}
}
return result;
}
Custom markup extensions give you an extensibility point in which to use custom logic to resolve a value at runtime. The Xamarin.Forms.Xaml.IMarkupExtension
interface contains a single method named ProvideValue
, which is intended to process a value and return an object to a property set within a XAML element.
The ImageResourceExtension
class, in the Calcium source code repository, is a custom markup extension that leverages the ImageUrlTransformer
class. See Listing 2.
Listing 2. ImageResourceExtension class (excerpt)
[ContentProperty("Source")]
public class ImageResourceExtension : IMarkupExtension
{
public string Source { get; set; }
public object ProvideValue(IServiceProvider serviceProvider)
{
…
}
}
An ImageSource
object is returned from the ImageResourceExtension
’s ProvideValue
method. See Listing 3. The method uses an implementation of the IImageUrlTransformer
to transform the value of the Source
property to a platform specific URL.
NOTE. If you would like to change how URLs are processed and modify the behaviour of the ImageUrlTransformer
class, you can do so by registering your own implementation of the IImageUrlTransformer
.
The class uses the Xamarin Forms Device.OS
property to determine on what platform the app is running. If the app happens to be running on iOS or Android then the URL is flattened and the image is expected to reside in a resource directory. The static ImageSource.FromFile
method is used to create an ImageSource
object.
If the app is running on Windows Phone, then a Stream is created to read the image file, which is passed within a lambda expression to the static ImageSource.FromStream
method.
Listing 3. ImageResourceExtension.ProvideValue method
public object ProvideValue(IServiceProvider serviceProvider)
{
if (Source == null)
{
return null;
}
ImageSource imageSource = null;
var transformer = Dependency.Resolve<IImageUrlTransformer, ImageUrlTransformer>(true);
string url = transformer.TransformForCurrentPlatform(Source);
if (Device.OS == TargetPlatform.Android)
{
imageSource = ImageSource.FromFile(url);
}
else if (Device.OS == TargetPlatform.iOS)
{
imageSource = ImageSource.FromFile(url);
}
else if (Device.OS == TargetPlatform.WinPhone)
{
#if WINDOWS_PHONE
if (url.StartsWith("/") && url.Length > 1)
{
url = url.Substring(1);
}
var stream = System.Windows.Application.GetResourceStream(new Uri(url, UriKind.Relative));
if (stream != null)
{
imageSource = ImageSource.FromStream(() => stream.Stream);
}
else
{
ILog log;
if (Dependency.TryResolve<ILog>(out log))
{
log.Debug("Unable to located create ImageSource using URL: " + url);
}
}
#endif
}
if (imageSource == null)
{
imageSource = ImageSource.FromFile(url);
}
return imageSource;
}
With the ImageResourceExtension
class in place, you are now able to specify the path to an image file like so:
<Image Source="{calcium:ImageResource /Views/MainView/Images/CalciumLogo.png}" />
We could stop here, but this would leave us having to duplicate, rename and shuffle the images about for each platform. We could even try linking and renaming the files. But we’d soon run into the same kinds of maintainability issues that we started out trying to solve. Let’s do something better.
Sharing Image Resources with T4
In this section you see how to create a reuseable T4 template that copies all the images from a shared project to the resource directory for your iOS or Android project. It then automatically imports the files into your project, setting the correct Build Action for the file depending on the platform.
It wasn’t a stretch to come up with this approach after the Localization solution that we saw in Part 3 of this series: Building Localizable Cross-Platform Apps with Xamarin Forms and Calcium. T4 comes in handy for tasks like this. You could probably achieve the same result with a custom MS Build task. I chose the T4 approach because I feel it’s less bother to configure and get working. And the code is easily modifiable and not buried in an external assembly. I’m guessing, however, that in the long run, Xamarin will probably choose MS Build. But for now, T4 gets the job done.
Using T4 to Import Images into an Android Project
Let’s begin by looking at incorporating the T4 templates into an Android project. The ProjectTemplate.Xamarin.CSharp.Android project in the downloadable sample contains two directories relevant to this example: a /Resources/Drawable directory, which is the required location for images of default resolution; and the /ResourcesModel/T4Templates directory, which contains some required T4 include files.
The following steps outline the process for setting up the T4 image sharing templates:
- Create a directory named ResourcesModel, and within that a T4Templates directory, in your Android project.
- Add the Images.ttinclude file and the MultiFileOutput.ttinclude file, which are located in the downloadable sample, to the /ResourcesModel/T4Templates directory.
- Set the Build Action of the Images.ttinclude file and the MultiFileOutput.ttinclude file to None.
- Use Visual Studio’s Add New Item dialog to add a Text file to the /Resources/Drawable directory of the Android project. Before clicking OK, rename the file Images.tt.
- Paste the following text into the Images.tt file:
<#@ include file="..\..\ResourcesModel\T4Templates\Images.ttinclude" #>
<#@ output extension=".txt" #>
<#@ template language="C#" hostSpecific="true" #><#
Process("CalciumSampleApp", "AndroidResource");
#> - Change the text “CalciumSampleApp” to the name of your shared project.
Notice that the second argument to the Process
method defines the Build Action of the images. For Android this must be specified as AndroidResource.
If the paths have been specified correctly, when you save the Images.tt file, images should appear as child items of the Images.tt file. See Figure 1. The images located in the Shared project are successfully copied, renamed, and included into the Android project.
Figure 1. Images are imported into the resources directory.
Using T4 to Import Images into an iOS Project
The process for setting up an iOS project to import shared images is almost identical to that of an Android project. There are, however, two differences: the images directory for iOS is /Resources, and the build action argument must be specified as BundleResource. The content of the Images.tt should resemble the following:
<#@ include file="..\ResourcesModel\T4Templates\Images.ttinclude" #>
<#@ output extension=".txt" #>
<#@ template language="C#" hostSpecific="true" #><#
Process("CalciumSampleApp", "BundleResource");
#>
Behind the Scenes with Importing Images using T4
The logic for producing images is contained within the Process
method of the Images.ttinclude file. The method uses the Visual Studio DTE object to traverse the solution for projects and files.
Within the Process method, the DTE is retrieved like so:
IServiceProvider hostServiceProvider = (IServiceProvider)Host;
EnvDTE.DTE dte = (EnvDTE.DTE)hostServiceProvider.GetService(typeof(EnvDTE.DTE));
The method retrieves all the projects for the solution using a method named GetProjects
. Retrieving the list of projects is a recursive activity because projects may be located within solution folders.
With the list of projects in hand, the Process
method attempts to locate the project in which the images are located by iterating over the projects in the solution.
var solution = dte.Solution;
var items = GetProjects();
foreach (EnvDTE.Project item in items)
{
if (item.Name.EndsWith(projectName))
{
project = item;
break;
}
}
The GetFiles
function uses tail recursion to retrieve all files with the specified file extension. See Listing 4.
Listing 4. Images.ttinclude GetFiles function
void GetFiles(ProjectItem projectItem, string fileExtension, IList<string> fileList)
{
string fullPath = projectItem.Properties.Item("FullPath").Value.ToString();
if (fullPath.ToLower().EndsWith(fileExtension))
{
fileList.Add(fullPath);
}
var childItems = projectItem.ProjectItems;
if (childItems != null)
{
foreach (ProjectItem childItem in childItems)
{
GetFiles(childItem, fileExtension, fileList);
}
}
}
The Process
function uses the GetFiles
function to populate a list containing the paths to all PNG and JPG files, as shown:
var fileExtensions = new string[] { ".png", ".jpg" };
var fileList = new List<string>();
var projectItems = project.ProjectItems;
if (projectItems != null)
{
foreach (ProjectItem projectItem in project.ProjectItems)
{
foreach (string extension in fileExtensions)
{
GetFiles(projectItem, extension, fileList);
}
}
}
The Process function then creates a flattened name from each image path by replacing the path separator character with an underscore. See Listing 5. If the image already exists as a resource then the LastWriteTime
values of the two files are compared. If the original image has an older LastWriteTime
value then nothing needs to be done. This step avoids unnecessarily checking out files from source control.
Listing 5. Images.ttinclude Copying Files within the Process function
string templateDirectory = Path.GetDirectoryName(Host.TemplateFile);
foreach (string item in fileList)
{
if (!item.StartsWith(projectDirectory))
{
continue;
}
string fileNameInProject = Path.GetFileName(item);
string fileDirectory = Path.GetDirectoryName(item);
string pathSubstring = fileDirectory.Substring(projectDirectoryLength);
if (pathSubstring.StartsWith("\\"))
{
pathSubstring = pathSubstring.Substring(1);
}
string flattenedPathSubstring = pathSubstring.Replace("\\","_");
if (flattenedPathSubstring.Length > 0)
{
flattenedPathSubstring = flattenedPathSubstring + "_";
}
string flattenedFileName = flattenedPathSubstring + fileNameInProject;
string newOutputPath = Path.Combine(templateDirectory, flattenedFileName);
if (File.Exists(newOutputPath))
{
var projectFileInfo = new System.IO.FileInfo(item);
var newFileInfo = new System.IO.FileInfo(newOutputPath);
if (projectFileInfo.LastWriteTimeUtc < newFileInfo.LastWriteTimeUtc)
{
continue;
}
}
File.Copy(item, newOutputPath, true);
...
}
Finally, the item is added to the project using the AddProjectItem
function from the MultiFileOutput.ttinclude file, as shown in the following excerpt:
AddProjectItem(flattenedFileName, buildAction);
Thanks go out to Oleg Sych for his helpful article on generating multiple outputs from single T4 template.
If you read the previous article, Part 4: Creating a Cross-Platform Application Bar with Xamarin Forms and Calcium, you may recall I mentioned that icon images are resolved using the IImageUrlTransformer. In Listing 6 you see again how the TransformForCurrentPlatform returns an image URL that is compatible with the flattened output of the Images.tt template.
Listing 6. Resolving icon images using an IImageUrlTransformer.
var uri = appBarItem.IconUri;
if (uri != null)
{
string url = uri.ToString();
string transformedUrl = imageUrlTransformer.TransformForCurrentPlatform(url);
item.Icon = transformedUrl;
}
NOTE. Occasionally, when building your project, Visual Studio may complain that File x is already defined. Be sure that the build action for T4 (.tt) templates is set to none. Visual Studio sometimes likes to switch build actions on these files.
Conclusion
The disparity between how images are resolved for the various platforms places some restrictions on where you can place images in Xamarin Forms. By using the techniques provided in this article, you are able to circumvent those restrictions; giving you the flexibility of the Windows Phone resource system, while still retaining compatibility with iOS and Android.
In this article you saw how to share and consume image files between projects in a unified manner. You saw how to employ a T4 template to copy image files from a shared project into iOS and Android resources directories; to add them to your iOS and Android projects; and to set the Build Action of the images. Finally, you saw how a custom markup extension is used to translate image resource URLs so that images can be referenced in XAML and correctly resolved regardless of platform.
I hope you find this project useful. If so, then I'd appreciate it if you would rate it and/or leave feedback below. This will help me to make my next article better.
In the next article you explore Calcium’s User Options system that allows you to define an option in a single line of code. The option is then automatically displayed on an options view in your app, and when the user modifies the option, the options system automatically persists the change.
History
November 2014