Figure 1: The application in action - all sections expanded
(though not visible in the screenshot)
Figure 2: Screenshot showing collapsed sections and the
ability to copy text via the context menu
Introduction
The Exif Compare Utility is an application I wrote for my specific
needs. It is a WinDiff equivalent for image files that compares the Exif
meta-data and displays the differences and similarities. The application is
written using WPF and MVVM, and also makes use of my
ExifReader
library. In the article I will briefly explain how to use the application and
also discuss some of the interesting code implementation details.
Code Formatting
The code snippets in the article body have been ruthlessly word wrapped so
they fit within the 600 pixel width requirement that Code Project has. The code
formatting in the actual source files is way more pleasant.
Using the utility
Choose Compare Files either from the menu or from the toolbar, and
you'll get two File Open dialogs which'll let you choose two image files. Once
you choose the files you will see the Exif comparison results (see Figure 1) via
four collapsible sections.
- Differing Exif properties
- Exif properties only in left image
- Exif properties only in right image
- Identical Exif properties
Use Clear Files (menu/toolbar) to clear the selections. You can right
click on an entry to bring up the context menu (see Figure 2) which has just one
entry - a Copy command that copies a textual representation to the
clipboard.
Interesting coding details
The WPF application was written using VS 2010 RC and uses the MVVM pattern.
It uses my ExifReader library for reading the Exif data, but implements
the comparison in the model class. I will be referring to a couple of classes
from the
ExifReader
library, and to avoid repetition I will not describe them in this article. Those
of you who want to look it up can read the
ExifReader
article. Note that the source download for this article does include the
ExifReader project too, so you won't need any additional downloads to
compile and run the application.
Model
There is a class called ExifPropertyComparison
which represents
a particular Exif property. It will include either the left or right values, or
both depending on whether a property exists in both images or just one.
internal class ExifPropertyComparison
{
private const string EMPTYSTRING = "N/A";
public ExifPropertyComparison(ExifProperty exifProperty)
: this(exifProperty, exifProperty)
{
}
public ExifPropertyComparison(ExifProperty left, ExifProperty right)
{
if (left == null && right == null)
{
throw new InvalidOperationException(
"Both arguments cannot be null.");
}
if (left == null)
{
this.IsLeftEmpty = true;
this.LeftValue = EMPTYSTRING;
}
else
{
this.LeftValue = left.ToString();
}
if (right == null)
{
this.IsRightEmpty = true;
this.RightValue = EMPTYSTRING;
}
else
{
this.RightValue = right.ToString();
}
this.TagName = (left ?? right).ExifTag.ToString();
}
public bool IsLeftEmpty { get; private set; }
public bool IsRightEmpty { get; private set; }
public string LeftValue { get; private set; }
public string RightValue { get; private set; }
public string TagName { get; private set; }
}
A couple of properties in there (IsLeftEmpty
and
IsRightEmpty
) are provided for data binding convenience. Some of you may
be wondering if this breaks the MVVM model, since we have properties in a
Model class purely for the View's convenience. But remember that the
View has absolutely no idea of this Model class. The data
association is done at run-time by the WPF data-binding framework. You could
move this class to the View-Model too if you are that pedantic about these
things, but I personally prefer to choose the simplest option to more
doctrinaire and convoluted ones.
The property comparison is based on comparing the Exif tag as well as the
Exif property value. Since both the ExifReader
and
ExifProperty
classes do not support equality checks, I implemented the
following comparer classes.
internal class ExifPropertyTagEqualityComparer
: IEqualityComparer<ExifProperty>
{
public bool Equals(ExifProperty x, ExifProperty y)
{
return x.ExifTag == y.ExifTag;
}
public int GetHashCode(ExifProperty obj)
{
return (int)obj.ExifTag;
}
}
This one is fairly simple, as all it does it compare the ExifTag
properties.
internal class ExifPropertyValueEqualityComparer
: IEqualityComparer<ExifProperty>
{
public bool Equals(ExifProperty x, ExifProperty y)
{
if (x.ExifValue.Count != y.ExifValue.Count
|| x.ExifDatatype != y.ExifDatatype)
return false;
bool equal = true;
object[] xValues = x.ExifValue.Values.Cast<object>().ToArray();
object[] yValues = y.ExifValue.Values.Cast<object>().ToArray();
for (int i = 0; i < xValues.Length; i++)
{
if (!(equal = xValues[i].Equals(yValues[i])))
{
break;
}
}
return equal;
}
public int GetHashCode(ExifProperty obj)
{
return (int)obj.ExifTag * (int)obj.ExifDatatype
+ obj.ExifValue.Count;
}
}
Comparing the Exif value needs a little more code since there can often be
multiple values. Once the comparers are available, writing the actual Exif
comparison code is fairly easy, with a bit of LINQ usage. This is implemented in
the ExifCompareModel
class.
internal class ExifCompareModel
{
private static ExifPropertyTagEqualityComparer tagEqualityComparer
= new ExifPropertyTagEqualityComparer();
private static ExifPropertyValueEqualityComparer valueEqualityComparer
= new ExifPropertyValueEqualityComparer();
private List<ExifPropertyComparison> onlyInLeftProperties
= new List<ExifPropertyComparison>();
public List<ExifPropertyComparison> OnlyInLeftProperties
{
get { return onlyInLeftProperties; }
}
private List<ExifPropertyComparison> onlyInRightProperties
= new List<ExifPropertyComparison>();
public List<ExifPropertyComparison> OnlyInRightProperties
{
get { return onlyInRightProperties; }
}
private List<ExifPropertyComparison> identicalProperties
= new List<ExifPropertyComparison>();
public List<ExifPropertyComparison> IdenticalProperties
{
get { return identicalProperties; }
}
private List<ExifPropertyComparison> differingProperties
= new List<ExifPropertyComparison>();
public List<ExifPropertyComparison> DifferingProperties
{
get { return differingProperties; }
}
public ExifCompareModel(string leftFileName, string rightFileName)
{
var leftproperties = new ExifReader(
leftFileName).GetExifProperties();
var rightproperties = new ExifReader(
rightFileName).GetExifProperties();
var onlyInLeft = leftproperties.Except(
rightproperties, tagEqualityComparer);
this.onlyInLeftProperties = onlyInLeft.Select(
p => new ExifPropertyComparison(p, null)).ToList();
var onlyInRight = rightproperties.Except(
leftproperties, tagEqualityComparer);
this.onlyInRightProperties = onlyInRight.Select(
p => new ExifPropertyComparison(null, p)).ToList();
var commonpropertiesInLeft = leftproperties.Except(
onlyInLeft, tagEqualityComparer).OrderBy(
exprop => exprop.ExifTag).ToArray();
var commonpropertiesInRight = rightproperties.Except(
onlyInRight, tagEqualityComparer).OrderBy(
exprop => exprop.ExifTag).ToArray();
for (int i = 0; i < commonpropertiesInLeft.Length; i++)
{
if (valueEqualityComparer.Equals(
commonpropertiesInLeft[i], commonpropertiesInRight[i]))
{
this.identicalProperties.Add(
new ExifPropertyComparison(commonpropertiesInLeft[i]));
}
else
{
this.differingProperties.Add(
new ExifPropertyComparison(
commonpropertiesInLeft[i],
commonpropertiesInRight[i]));
}
}
}
}
The class exposes four properties of type List<ExifPropertyComparison>
representing the four categories of properties - those properties only in either
the left or the right image, those in both images and with identical values, and
those in both but with different values.
View Model
Here's the code (partly snipped) for the main window's View-Model.
internal class MainWindowViewModel : ViewModelBase
{
. . .
private ImageUserControlViewModel leftImageUserControlViewModel
= ImageUserControlViewModel.Empty;
private ImageUserControlViewModel rightImageUserControlViewModel
= ImageUserControlViewModel.Empty;
public MainWindowViewModel()
{
this.OnlyInLeftProperties =
new ObservableCollection<ExifPropertyComparison>();
this.OnlyInRightProperties =
new ObservableCollection<ExifPropertyComparison>();
this.IdenticalProperties =
new ObservableCollection<ExifPropertyComparison>();
this.DifferingProperties =
new ObservableCollection<ExifPropertyComparison>();
}
public ObservableCollection<ExifPropertyComparison>
OnlyInLeftProperties { get; private set; }
public ObservableCollection<ExifPropertyComparison>
OnlyInRightProperties { get; private set; }
public ObservableCollection<ExifPropertyComparison>
IdenticalProperties { get; private set; }
public ObservableCollection<ExifPropertyComparison>
DifferingProperties { get; private set; }
public ICommand ExitCommand
{
get
{
return exitCommand ??
(exitCommand = new DelegateCommand(
() => Application.Current.Shutdown()));
}
}
public ICommand CompareFilesCommand
{
get
{
return compareFilesCommand ??
(compareFilesCommand = new DelegateCommand(BrowseForFiles));
}
}
public ICommand ClearFilesCommand
{
get
{
return clearFilesCommand ??
(clearFilesCommand =
new DelegateCommand(ClearFiles, CanClearFiles));
}
}
public ICommand AboutCommand
{
get
{
return aboutCommand ??
(aboutCommand =
new DelegateCommand<Window>(
(owner) => new AboutWindow()
{ Owner = owner }.ShowDialog()));
}
}
public ICommand ExifCompareCopy
{
get
{
return exifCompareCopy ??
(exifCompareCopy =
new DelegateCommand<ExifPropertyComparison>(
ExifCompareCopyToClipBoard));
}
}
public ImageUserControlViewModel LeftImageUserControlViewModel
{
get
{
return leftImageUserControlViewModel;
}
set
{
if (this.leftImageUserControlViewModel.FilePath
!= value.FilePath)
{
this.leftImageUserControlViewModel = value;
this.FirePropertyChanged("LeftImageUserControlViewModel");
}
}
}
public ImageUserControlViewModel RightImageUserControlViewModel
{
get
{
return rightImageUserControlViewModel;
}
set
{
if (this.rightImageUserControlViewModel.FilePath
!= value.FilePath)
{
this.rightImageUserControlViewModel = value;
this.FirePropertyChanged("RightImageUserControlViewModel");
}
}
}
public void BrowseForFiles()
{
OpenFileDialog fileDialog = new OpenFileDialog()
{
Filter = "Image Files(*.PNG;*.JPG)|*.PNG;*.JPG;"
};
if (fileDialog.ShowDialog().GetValueOrDefault())
{
string tempLeftFilePath = fileDialog.FileName;
if (fileDialog.ShowDialog().GetValueOrDefault())
{
try
{
ExifCompareModel exifCompare = new ExifCompareModel(
tempLeftFilePath, fileDialog.FileName);
Repopulate(this.OnlyInLeftProperties,
exifCompare.OnlyInLeftProperties);
Repopulate(this.OnlyInRightProperties,
exifCompare.OnlyInRightProperties);
Repopulate(this.IdenticalProperties,
exifCompare.IdenticalProperties);
Repopulate(this.DifferingProperties,
exifCompare.DifferingProperties);
this.LeftImageUserControlViewModel =
new ImageUserControlViewModel(tempLeftFilePath);
this.RightImageUserControlViewModel =
new ImageUserControlViewModel(fileDialog.FileName);
}
catch (ExifReaderException ex)
{
MessageBox.Show(ex.Message, "Error reading EXIF data",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}
public void ExifCompareCopyToClipBoard(
ExifPropertyComparison parameter)
{
var stringData = String.Format(
"TagName = {0}, LeftValue = {1}, RightValue = {2}",
parameter.TagName, parameter.LeftValue, parameter.RightValue);
Clipboard.SetText(stringData);
}
private void Repopulate(
ObservableCollection<ExifPropertyComparison> observableCollection,
List<ExifPropertyComparison> list)
{
observableCollection.Clear();
list.ForEach(p => observableCollection.Add(p));
}
public void ClearFiles()
{
this.OnlyInLeftProperties.Clear();
this.OnlyInRightProperties.Clear();
this.IdenticalProperties.Clear();
this.DifferingProperties.Clear();
this.LeftImageUserControlViewModel = ImageUserControlViewModel.Empty;
this.RightImageUserControlViewModel = ImageUserControlViewModel.Empty;
}
public bool CanClearFiles()
{
return this.LeftImageUserControlViewModel
!= ImageUserControlViewModel.Empty;
}
}
The View-Model exposes four ObservableCollection<ExifPropertyComparison>
properties which it populates from the equivalent properties returned from the
Model. The preview image views have their own View-Model and these are returned
via the LeftImageUserControlViewModel
and
RightImageUserControlViewModel
properties. Notice the code that brings up
the About dialog, where the Owner
window is set through the
command-parameter argument. I'll talk about this later when I show the
corresponding View code.
internal class ImageUserControlViewModel : ViewModelBase
{
. . .
private static ImageUserControlViewModel empty =
new ImageUserControlViewModel();
public static ImageUserControlViewModel Empty
{
get { return ImageUserControlViewModel.empty; }
}
private ImageUserControlViewModel()
{
}
public ImageUserControlViewModel(string filePath)
{
this.fileName = Path.GetFileName(filePath);
this.filePath = filePath;
}
public string FileName
{
get
{
return fileName ?? "No image selected";
}
}
public string FilePath
{
get
{
return filePath;
}
}
public override string ToString()
{
return FilePath ?? "No image selected";
}
}
Theoretically I could have put all this in the main view-model, but I did
this for convenience and for simpler code organization. In addition, there's a
View-Model class for the About dialog where information is shown based on the
assembly's version information. I used a fairly common MVVM technique of raising
an event in the View-Model that's handled by the View to close the About dialog.
People like Josh Smith have blogged and written about this technique in various
articles. This is in contrast to the technique I used to pass the owner
Window
to the code that brings up the About dialog. I could have used the same
technique here, but I chose to do it this way to get a feel for either
techniques. The negative in this latter approach is that you need to have code
in the View class, which may offend MVVM purists, though strictly speaking the
only thing that code does is close the dialog. With the former technique there's
no code needed in the View, but it's some rather inelegant binding code that's
used to pass the Owner window as a command parameter, which some people may not
want to do. Here's the code for the About dialog View-Model.
internal class AboutWindowViewModel : ViewModelBase
{
private FileVersionInfo fileVersionInfo;
private ICommand closeCommand;
public AboutWindowViewModel()
{
fileVersionInfo = FileVersionInfo.GetVersionInfo(
Assembly.GetExecutingAssembly().Location);
}
public event EventHandler CloseRequested;
private void FireCloseRequested()
{
EventHandler handler = CloseRequested;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
public ICommand CloseCommand
{
get
{
return closeCommand ?? (
closeCommand = new DelegateCommand(() => FireCloseRequested()));
}
}
public string Comments
{
get
{
return fileVersionInfo.Comments;
}
}
public string InternalName
{
get
{
return fileVersionInfo.InternalName;
}
}
public string FileDescription
{
get
{
return fileVersionInfo.FileDescription;
}
}
public string FileVersion
{
get
{
return fileVersionInfo.FileVersion;
}
}
public string LegalCopyright
{
get
{
return fileVersionInfo.LegalCopyright;
}
}
}
View
There's nothing of interest the About dialog Xaml except for the code in the
constructor that handles the View-Model event (that I talked about earlier).
public AboutWindow()
{
InitializeComponent();
var viewModel = new AboutWindowViewModel();
viewModel.CloseRequested += (s, e) => Close();
this.DataContext = viewModel;
}
Here's the artificially word-wrapped Xaml for the ImageUserControl
view class.
<UserControl x:Class="ExifCompare.ImageUserControl"
. . .
d:DesignHeight="264" d:DesignWidth="320" Background="Transparent">
<dropShadow:SystemDropShadowChrome CornerRadius="15, 15, 0, 0" Width="320">
<Grid Background="Transparent">
<StackPanel Orientation="Vertical" Tag="{Binding}">
<StackPanel.ToolTip>
<ToolTip
DataContext="{Binding Path=PlacementTarget,
RelativeSource={x:Static RelativeSource.Self}}"
Content="{Binding Tag}" FontSize="14"
Background="#FF2B4E2B" Foreground="YellowGreen" />
</StackPanel.ToolTip>
<Border BorderThickness="0" Background="#FF2B4E2B"
Width="320" CornerRadius="15, 15, 0, 0">
<TextBlock x:Name="textBlock" Padding="5,2,4,1"
Background="Transparent" Text="{Binding FileName}"
Width="320" Height="24" FontSize="15" Foreground="White" />
</Border>
<Border Background="#FF427042">
<Image VerticalAlignment="Top" Width="320" Height="240"
Source="{Binding FilePath}" />
</Border>
</StackPanel>
</Grid>
</dropShadow:SystemDropShadowChrome>
</UserControl>
Look at the highlighted code where the tool-tip is setup. Initially I didn't
have code that looked like that, instead I merely bound to the current object so
I would just see the ToString
result of the
ImageUserControlViewModel
object. I found that this only worked the first
time the code was called, and when I opened fresh files, the tool-tip did not
update. It took me a while and some Googling before I realized that the
ToolTip will not inherit the data-context since it was not part of the visual
tree. So in this case, the PlacementTarget
will be the containing
StackPanel
and I bind its Tag
property to the
ImageUserControlViewModel
object, which is what the tool-tip gets
too when it pops up and invokes data-binding. Problem solved.
Here's the Xaml for the main window. Some of the word wrapping where a
binding string is wrapped may actually break the code. But then I don't expect
anyone to copy/paste this code into an IDE - since you can always look at the
provided source download.
<nsmvvm:SystemMenuWindow x:Class="ExifCompare.MainWindow"
. . .
Title="Exif Compare Utility" Height="750" Width="1000"
MinHeight="300" MinWidth="750">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="ExifCompareResourceDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<nsmvvm:SystemMenuWindow.MenuItems>
<nsmvvm:SystemMenuItem Command="{Binding AboutCommand}"
CommandParameter="{Binding RelativeSource=
{RelativeSource Mode=FindAncestor, AncestorType=Window}}"
Header="About" Id="100" />
</nsmvvm:SystemMenuWindow.MenuItems>
I've used my
SystemMenuWindow class to add the About menu entry to the system window. But
the interesting code there is the CommandParameter
binding. I find
the first Window ancestor and pass that to the command, since the About dialog
needs to set an owner window for centering. This way we avoid a back-reference
to the View from the View-Model, even though there are MVVM implementations
where the View-Model has a reference to the View. I could also have implemented
an event in the View-Model that the View handles to pass in an owner, but I
thought this was simpler to do.
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60" />
<RowDefinition />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Menu Height="25" VerticalAlignment="Top">
<MenuItem Header="File">
<MenuItem Command="{Binding CompareFilesCommand}"
Header="Compare Files" />
<MenuItem Command="{Binding ClearFilesCommand}"
Header="Clear Files" />
<MenuItem Command="{Binding ExitCommand}"
Header="Exit" />
</MenuItem>
<MenuItem Header="Help">
<MenuItem Command="{Binding AboutCommand}"
CommandParameter="{Binding RelativeSource=
{RelativeSource Mode=FindAncestor,
AncestorType=Window}}"
Header="About" />
</MenuItem>
</Menu>
<ToolBar Height="35" VerticalAlignment="Bottom" FontSize="14">
<Button Command="{Binding CompareFilesCommand}">
<StackPanel Orientation="Horizontal">
<Image Width="32"
Source="/ExifCompare;component/Images/CompareFiles.png"></Image>
<TextBlock VerticalAlignment="Center">
Compare Files</TextBlock>
</StackPanel>
</Button>
<Button Command="{Binding ClearFilesCommand}">
<StackPanel Orientation="Horizontal">
<Image Width="32"
Source="/ExifCompare;component/Images/Clear.png">
</Image>
<TextBlock VerticalAlignment="Center">
Clear Files</TextBlock>
</StackPanel>
</Button>
<Button Command="{Binding AboutCommand}"
CommandParameter="{Binding RelativeSource=
{RelativeSource Mode=FindAncestor, AncestorType=Window}}">
<StackPanel Orientation="Horizontal">
<Image Width="32"
Source="/ExifCompare;component/Images/HelpAbout.png">
</Image>
<TextBlock VerticalAlignment="Center">About</TextBlock>
</StackPanel>
</Button>
<ToolBar.Background>
<LinearGradientBrush EndPoint="1,0.5" StartPoint="0,0.5">
<GradientStop Color="#FF9DB79D" Offset="0" />
<GradientStop Color="#FF19380B" Offset="1" />
</LinearGradientBrush>
</ToolBar.Background>
</ToolBar>
</Grid>
The menu and toolbar implement the same functionality and use the same
View-Model commands.
<ScrollViewer Grid.Row="1">
<ScrollViewer.Background>
<LinearGradientBrush EndPoint="0,0" StartPoint="1,0">
<GradientStop Color="#FFA7B7A7" Offset="0" />
<GradientStop Color="#FF195219" Offset="1" />
</LinearGradientBrush>
</ScrollViewer.Background>
<Grid Margin="20">
<StackPanel Orientation="Vertical">
<Expander VerticalAlignment="Top" Header="Preview Images"
IsExpanded="True" ExpandDirection="Down"
FontSize="16" FontWeight="Bold">
<Grid VerticalAlignment="Stretch"
Background="#FFB7D4B7">
<StackPanel Orientation="Horizontal"
Height="260" VerticalAlignment="Top"
HorizontalAlignment="Center">
<local:ImageUserControl
DataContext="{Binding LeftImageUserControlViewModel}"
Margin="5" />
<local:ImageUserControl
DataContext="{Binding RightImageUserControlViewModel}"
Margin="5" />
</StackPanel>
</Grid>
</Expander>
The image preview user controls specify the DataContext
in the
Xaml and bind to the appropriate View-Model objects.
<Expander VerticalAlignment="Top"
Header="Differing EXIF properties"
IsExpanded="True" ExpandDirection="Down"
FontSize="16" FontWeight="Bold">
<Grid VerticalAlignment="Stretch" MinHeight="40"
Background="#FFB7D4B7" TextBlock.FontSize="13"
TextBlock.FontWeight="Normal">
<ListBox ItemsSource="{Binding DifferingProperties}"
Style="{StaticResource ExifListBox}"
ItemTemplate="{StaticResource ExifListBoxItemTemplate}"
ItemContainerStyle="{StaticResource ExifListBoxItem}">
</ListBox>
</Grid>
</Expander>
<Expander VerticalAlignment="Top"
Header="EXIF Properties only in left image"
IsExpanded="True" ExpandDirection="Down"
FontSize="16" FontWeight="Bold">
<Grid VerticalAlignment="Stretch" MinHeight="40"
Background="#FFB7D4B7" TextBlock.FontSize="13"
TextBlock.FontWeight="Normal">
<ListBox ItemsSource="{Binding OnlyInLeftProperties}"
Style="{StaticResource ExifListBox}"
ItemTemplate="{StaticResource ExifListBoxItemTemplate}"
ItemContainerStyle="{StaticResource ExifListBoxItem}">
</ListBox>
</Grid>
</Expander>
<Expander VerticalAlignment="Top"
Header="EXIF Properties only in right image"
IsExpanded="True" ExpandDirection="Down"
FontSize="16" FontWeight="Bold">
<Grid VerticalAlignment="Stretch" MinHeight="40"
Background="#FFB7D4B7"
TextBlock.FontSize="13"
TextBlock.FontWeight="Normal">
<ListBox ItemsSource="{Binding OnlyInRightProperties}"
Style="{StaticResource ExifListBox}"
ItemTemplate="{StaticResource ExifListBoxItemTemplate}"
ItemContainerStyle="{StaticResource ExifListBoxItem}">
</ListBox>
</Grid>
</Expander>
<Expander VerticalAlignment="Top"
Header="Identical EXIF properties"
IsExpanded="True" ExpandDirection="Down"
FontSize="16" FontWeight="Bold">
<Grid VerticalAlignment="Stretch" MinHeight="40"
Background="#FFB7D4B7"
TextBlock.FontSize="13"
TextBlock.FontWeight="Normal">
<ListBox ItemsSource="{Binding IdenticalProperties}"
Style="{StaticResource ExifListBox}"
ItemTemplate="{StaticResource ExifListBoxItemTemplate}"
ItemContainerStyle="{StaticResource ExifListBoxItem}">
</ListBox>
</Grid>
</Expander>
. . .
The property comparisons are shown using styled and templated list-boxes.
They are all wrapped inside Expander
blocks, and the Expander
is also styled differently since the default look did not look natural - given
the rest of the styling/theming. I'll briefly go through some of the styles and
templates that are defined in a separate resource dictionary.
Styles and Templates
For customizing the Expander control, I started off with this
MSDN example.
I removed some of the bits (example, those that dealt with the disabled state)
and then customized it to my preference.
<ControlTemplate x:Key="ExpanderToggleButton"
TargetType="ToggleButton">
<Border Name="Border"
CornerRadius="2,0,0,0"
Background="{StaticResource BasicBrush}"
BorderBrush="{StaticResource NormalBorderBrush}"
BorderThickness="0,0,1,0">
<Path Name="Arrow"
Fill="{StaticResource GlyphBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M 0 0 L 8 8 L 16 0 Z"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="ToggleButton.IsMouseOver" Value="true">
<Setter TargetName="Border" Property="Background"
Value="{StaticResource DarkBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="true">
<Setter TargetName="Border" Property="Background"
Value="{StaticResource PressedBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="true">
<Setter TargetName="Arrow" Property="Data"
Value="M 0 8 L 8 0 L 16 8 Z" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
This is the control template for the ToggleButton
on the
Expander
control. The arrow, and its flipped version are both created
using basic Path
controls.
<Style TargetType="Expander">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Grid>
. . .
<Border Name="Border"
Grid.Row="0"
Background="{StaticResource LightBrush}"
BorderBrush="{StaticResource NormalBorderBrush}"
BorderThickness="1"
CornerRadius="2,2,0,0" >
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="25" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ToggleButton
IsChecked="{Binding Path=IsExpanded,Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
OverridesDefaultStyle="True"
Template="{StaticResource ExpanderToggleButton}" />
<ContentPresenter Grid.Column="1"
Margin="4"
ContentSource="Header"
RecognizesAccessKey="True" />
</Grid>
</Border>
<Border Name="Content"
Grid.Row="1"
Background="{StaticResource WindowBackgroundBrush}"
BorderBrush="{StaticResource SolidBorderBrush}"
BorderThickness="1,0,1,1"
CornerRadius="0,0,2,2" >
<ContentPresenter />
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="ContentRow" Property="Height"
Value="{Binding ElementName=Content,Path=DesiredHeight}" />
</Trigger>
</ControlTemplate.Triggers>
. . .
In the control template for the Expander
, we use the customized
ToggleButton
template.
<Style TargetType="{x:Type ListBox}" x:Key="ExifListBox">
. . .
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<ItemsPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={x:Static
RelativeSource.Self}, Path=Items.Count}" Value="0">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<TextBlock Foreground="#FF183E11"
FontSize="14">This section is empty.</TextBlock>
</ControlTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
Here's a style I use on the ListBox
, so that when the
ListBox
is empty I can show a custom empty message. This works because of
the binding where I bind to the Count
property of the Items
property.
<DataTemplate x:Key="ExifListBoxItemTemplate">
<Grid Background="#FF6E9A96" HorizontalAlignment="Stretch"
x:Name="mainItemGrid"
TextBlock.FontSize="15"
MaxWidth="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType=ListBox}, Path=ActualWidth}">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding LeftValue}" Grid.Column="0" x:Name="leftValue"
Padding="5" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding TagName}" Grid.Column="1" x:Name="tagName"
Padding="5" TextTrimming="CharacterEllipsis"
Background="#FF3B726C" />
<TextBlock Text="{Binding RightValue}" Grid.Column="2" x:Name="rightValue"
Padding="5" TextTrimming="CharacterEllipsis" />
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsLeftEmpty}" Value="True">
<Setter TargetName="leftValue" Property="Opacity" Value="0.3" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRightEmpty}" Value="True">
<Setter TargetName="rightValue" Property="Opacity" Value="0.3" />
</DataTrigger>
<DataTrigger Binding="{Binding RelativeSource={RelativeSource
Mode=FindAncestor, AncestorType=ListBoxItem}, Path=IsSelected}" Value="True">
<Setter TargetName="mainItemGrid"
Property="Background" Value="#FF526C66" />
<Setter TargetName="tagName"
Property="Background" Value="#FF254843" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
Here's the data template for the ListBoxItem
- this is where we
actually display the property comparison data. There are some Exif properties
where the ToString
implementation in the ExifProperty
class returns a display string of hex bytes. This resulted in horizontal
scrolling, and to avoid this I make sure using a binding that the MaxWidth
is equal to the present width of the ListBox
. That's the
highlighted code on top.
The data triggers show how the IsLeftEmpty
and
IsRightEmpty
properties are used to change the opacity based on whether a
property is available or not. This was what I was referring to earlier when I
discussed the Model implementation.
<ContextMenu x:Key="ListBoxItemContextMenu">
<MenuItem Header="Copy"
Command="{Binding RelativeSource={
RelativeSource Mode=FindAncestor,
AncestorType=ListBox}, Path=DataContext.ExifCompareCopy}"
CommandParameter="{Binding}"/>
</ContextMenu>
<Style TargetType="{x:Type ListBoxItem}" x:Key="ExifListBoxItem">
<Style.Resources>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
Color="Transparent"/>
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}"
Color="Transparent"/>
</Style.Resources>
<Setter Property="ContextMenu" Value="{StaticResource ListBoxItemContextMenu}" />
</Style>
This was another piece of code where I stumbled for a few minutes. The
ListBoxItem
's data context will be the ExifPropertyComparison
object associated with it. Since I wanted to bind to a command in the
View-Model, I had to first fetch the correct data source by using
FindAncestor
to lookup the data context associated with the ListBox
(which will be the View-Model instance).
Conclusion
I guess there wasn't anything earth shattering in this article, but I was
primarily trying to discuss issues I ran into and how I solved them. Obviously
if you think there are better ways to solve these issues, please do use the
article forum to put forward your suggestions, criticism, and ideas. Thank you.
History
- April 10, 2009 - Article first published