Monday 23 January 2012

C# - Dynamically skinning your WPF applications

Far from making suggestions about how to inflict great pain and suffering on your WPF applications, this should make your applications that little bit more interactive and customisable. The way we'll be doing this is by dynamically loading in resource dictionaries into the Application's merged dictionaries. Lost? Don't worry, I was at first too. I'll elaborate what I know further down.

These methods are rather generic (in a non object-oriented way), but are set up to read through an application's subdirectory called 'Skins' and return all the skins into functional menuitems which will be put into a menu called themes. Change and adjust as necessary. Then, when one of these menu items is clicked, it will use the name to find the resource dictionary in question, clear off any styles you have currently and apply the new ones. Obviously, this behaviour won't suit every taste, so butcher it up as you please. Note, though, that any styles you want to set dynamically like this must be set to be a DynamicResource. Ok, to the code!

The code



// not all of these will be needed, but it is what I had for my code
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Input;
using System.Media;
using System.Runtime.InteropServices;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Net;
using System.Windows.Interop;
using System.Windows.Controls;
using System.Text;
using System.Reflection;
using System.ComponentModel;

private void CreateCustomSkinMenuItems()
{
BackgroundWorker _skinLoadWorker = new BackgroundWorker();
_skinLoadWorker.DoWork += skinLoadWork;
_skinLoadWorker.RunWorkerCompleted += updateThemeMenu;
_skinLoadWorker.RunWorkerAsync();
}

private void skinLoadWork(object sender, DoWorkEventArgs e)
{
string _skinsDirectory = "Skins\\";
string filename = null;
List _builtInSkins = new List();
_builtInSkins.Add("True_Blue.xaml");
_builtInSkins.Add("Fire_Red.xaml");
_builtInSkins.Add("Console.xaml");
List newItems = new List();
foreach (string filepath in Directory.GetFiles(_skinsDirectory))
{
filename = filepath.Replace(_skinsDirectory, "");
if (_builtInSkins.Contains(filename) ||
!filename.EndsWith(".xaml"))
{
continue; // we don't bother with these anymore
}
string item = "_" + filename.Substring(0, filename.Length - 5).Replace('_', ' ');
newItems.Add(item);
}
e.Result = newItems;
}

private void updateThemeMenu(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Error != null)
{
MessageBox.Show("An error occured loading custom skins. Find error details below: \n\n" + e.Error.Message, "Error loading custom skins",
MessageBoxButton.OK, MessageBoxImage.Error);
}
else
{
List newItems = e.Result as List;
foreach (string item in newItems)
{
MenuItem menuItem = new MenuItem();
menuItem.Header = item;
menuItem.Click += customThemeMenuItem_Click;
themeMenu.Items.Add(menuItem);
}
}
}

private void customThemeMenuItem_Click(object sender, RoutedEventArgs e)
{
MenuItem _sendingItem = sender as MenuItem;
string _skinFile = _sendingItem.Header.ToString().Replace("_", "").Replace(' ', '_');
LoadSkin(_skinFile);
}

private void LoadSkin(string filename)
{
string filePath = "Skins\\" + filename + ".xaml";
if (File.Exists(filePath))
{
try
{
ResourceDictionary skin = new ResourceDictionary();
Uri _skinFile = new Uri("pack://siteoforigin:,,,/Skins/" + filename + ".xaml");
skin.Source = _skinFile;
Application.Current.Resources.MergedDictionaries.Clear();
Application.Current.Resources.MergedDictionaries.Add(skin);
}
catch (Exception ex)
{
MessageBox.Show("An error occured in processing the theme. Please see the below error:\n" + ex.Message, "Unable to load theme",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
else
{
MessageBox.Show("The theme's skin could not be found.\n" + filePath, "Skin not found",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}

Explanations



I hope that most of the code above will make some sense to you and will maybe even seem like a primative hack to some coding deities out there. Stuff like Background Workers and similar I'll assume you know (not that there's anything wrong if you don't), but there's probably a couple of things that need explaining.

First off, this business with string replacements allows the following, provided people stick to a convention with any skin files they write for your resource dictionaries, it will replace underscores in the file name with spaces in the menu item and put them back when looking for the file. It will also prepend an underscore to the first letter of the menu item to make it easily reachable with accelerator keys (more on those in a future post). Of course, if whoever's making your nice .xaml resource dictionaries forgets about underscores for spaces, it won't be pretty, you could insert your own validation for that.

The resource dictionaries are basically dictionary structures written in xaml holding resources. What we're doing in the LoadSkin() method is setting the source of a ResourceDictionary we're making to the file we want to load. Then, we're clearing the application's MergedDictionary and then adding in the one we want, setting anything with a DynamicResource that looks for one of the styles in your ResourceDictionary to the one you want. Voila!

Uri Pack? Yep. Better known as a Pack Uri. The siteoforigin in that address means it points to where the application resides on the host computer, regardless of where it is. For more on these little beauties, see the MSDN article.

Please note though, that this functionality isn't always desirable. You don't always want to hand over the keys to your hard worked on look and feel. Sometimes it's nice and it is good for user generated content. But sometimes particular constraints may mean that some things have to stay resolutely stuck. For those, you could try modifying the LoadSkin() method to add in a default ResourceDictionary before adding in the newer one. This solution is untested, so if anyone has time to try such an approach, I'd appreciate the feedback below. Similarly, if I've missed anything on this, please feel free to point it out.

I hope this helps you with further skinning exploits, as long as they're within the limits of law and more importantly common decency.

1 comment:

As always, feel free to leave a comment.