Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

EXIF Compare Utility using WPF

0.00/5 (No votes)
12 Apr 2010 1  
The Exif Compare Utility 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.

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.

  1. Differing Exif properties
  2. Exif properties only in left image
  3. Exif properties only in right image
  4. 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; }
    }

    /// <summary>
    /// Initializes a new instance of the ExifCompareModel class.
    /// </summary>
    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;

    /// <summary>
    /// Initializes a new instance of the MainWindowViewModel class.
    /// </summary>
    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()
    {
    }

    /// <summary>
    /// Initializes a new instance of the ImageUserControlViewModel class.
    /// </summary>
    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;

    /// <summary>
    /// Initializes a new instance of the AboutWindowViewModel class.
    /// </summary>
    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

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here