다음을 통해 공유


WPF: Change Tracking

This article uses a sample to explain a method for tracking which data changed in a WPF application.


Introduction

A fairly common user request is to highlight the fields in which they ( or some process) have changed values. That way they can pay particular attention to reviewing them before they commit edits. Some larger organisations have a standard specifying all their applications must do this.

This sample presents a ready rolled base class you can inherit from to easily implement this functionality.  It provides an almost cut and paste solution to the requirement.  You do need to do a little extra work on each of your properties. A Visual Studio snippet is included with the zip to make that rather easier.  

Another useful feature of this is that you can make ViewModels self change tracking rather than relying on the overhead of Entity Framework change tracking.  This aspect is very useful with large result sets and or where retaining a Context through a session is impractical.

You can download the sample from here.

The Sample Window in Action


Usage

Change of simple value types is illustrated in the DataGrid.  Once you edit a cell, as it loses focus the change will be transferred to the viewmodel and you will notice it gets a different colour background ( Moccasin ).

If you click on the "Change Complex" button, the values you see will change and the background of these will also change similarly.

The change complex is a one shot click, once it's changed it's done.

The reset Background button resets everything back to the original ( white ) background.  In a real production application this would be part of the process after changes were successfully saved to the database/file/etc.

In the above screen shot you can see the result of editing several cells and clicking the Change Complex button.  You can see the button still has focus.


Technical Overview

A business application would usually have each model/entity/row of data wrapped by a ViewModel rather than presenting it directly.  That approach is simulated by wrapping a plain class with a ViewModel.  This approach makes it easier to use the sample by obviating any database.

Properties of the model are each "wrapped" using a property in the ViewModel which provides them via it's Getter and does some DifferentValue processing in it's setter.

That processing is rather more complicated for a complex type, since comparison of complex types is not just a case of checking equals. Two objects of the same type are always equal because the type is compared by default unless you implement IEqualityComparer.  Writing that code would be worthwhile if you had to compare many instances of a type but in this instance we compare a single one to another so there is little to be gained from improving efficiency.

If there is a difference, the IsDirty flag is marked and the colour held in an observabledictionary ( Dr WPF )  changed.

The background of those fields are each bound to an element in that observabledictionary.

That notifies change using INotifyPropertyChanged and the background changes in the view.


Code  

The reader is best advised to view both the code and this article rather than rely on either on their own.  
This article assumes you read the code as well. 

MainWindow Markup

Most of the markup for the MainWindow is fairly straightforward.

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="30"/>
        <RowDefinition/>
        <RowDefinition Height="0"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="2*" />
        <ColumnDefinition Width="3*" />
        <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
    <Grid Margin="0,30,0.2,-0.2" Grid.RowSpan="3">
        <Grid.RowDefinitions>
            <RowDefinition Height="30"/>
            <RowDefinition Height="30"/>
            <RowDefinition Height="30"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="30"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
 
        <TextBlock Grid.Row="0" Grid.ColumnSpan="2"
            VerticalAlignment="Center" HorizontalAlignment="Left" Margin="10,0,0,0">
            Complex Type ( 1 change only )
        </TextBlock>
        <TextBlock Grid.Row="1"
            VerticalAlignment="Center" HorizontalAlignment="Right" Margin="0,0,10,0">
            Point:
        </TextBlock>
        <StackPanel Grid.Row="1"
                    Orientation="Horizontal" Grid.Column="1"
                    Background="{Binding  IMC.BGColours[Pointy]}"
                    VerticalAlignment="Center" HorizontalAlignment="Center">
            <TextBlock Text="{Binding IMC.Pointy.X}"/>
            <TextBlock Text=" - "/>
            <TextBlock Text="{Binding IMC.Pointy.Y}"/>
        </StackPanel>
 
        <TextBlock VerticalAlignment="Center" HorizontalAlignment="Right" Margin="0,0,10,0"
                   Grid.Row="2">
            Color:
        </TextBlock>
        <TextBlock Text="{Binding IMC.AColour.ColourString}"
                   Background="{Binding  IMC.BGColours[AColour]}"
                   Grid.Row="2" Grid.Column="1"
                   VerticalAlignment="Center" HorizontalAlignment="Center"
                   />
        <Button Grid.Row="4" Command="{Binding MakeChanges}">Change Complex</Button>
        <Button Grid.Row="4" Grid.Column="1" Command="{Binding ResetColour}">Reset Background</Button>
    </Grid>
        <DataGrid x:Name="dg"
              ItemsSource="{Binding MediaVMs}"
              AutoGenerateColumns="False"
              Foreground="Black" Grid.Column="1" Grid.RowSpan="3"
              CanUserAddRows="False" CanUserResizeColumns="True" HeadersVisibility="Column"
              >
        <DataGrid.Columns>
            <DataGridTextColumn  Binding="{Binding MediaType}" Header="Media Type">
                <DataGridTextColumn.CellStyle>
                    <Style TargetType="DataGridCell">
                        <Setter Property="Foreground" Value="Black" />
                        <Setter Property="Background" Value="{Binding BGColours[MediaType]}" />
                    </Style>
                </DataGridTextColumn.CellStyle>
            </DataGridTextColumn>
            <DataGridTextColumn Binding="{Binding Container}"  Header="Container">
                <DataGridTextColumn.CellStyle>
                    <Style TargetType="DataGridCell">
                        <Setter Property="Foreground" Value="Black" />
                        <Setter Property="Background" Value="{Binding BGColours[Container]}" />
                    </Style>
                </DataGridTextColumn.CellStyle>
            </DataGridTextColumn>
            <DataGridTextColumn Binding="{Binding Volume}"     Header="Volume">
                <DataGridTextColumn.CellStyle>
                    <Style TargetType="DataGridCell">
                        <Setter Property="Foreground" Value="Black" />
                        <Setter Property="Background" Value="{Binding BGColours[Volume]}" />
                    </Style>
                </DataGridTextColumn.CellStyle>
            </DataGridTextColumn>
            <DataGridTextColumn Binding="{Binding ColourId}"   Header="ColourId">
                <DataGridTextColumn.CellStyle>
                    <Style TargetType="DataGridCell">
                        <Setter Property="Foreground" Value="Black" />
                        <Setter Property="Background" Value="{Binding BGColours[ColourId]}" />
                    </Style>
                </DataGridTextColumn.CellStyle>
            </DataGridTextColumn>
        </DataGrid.Columns>
    </DataGrid>
</Grid>

You can see the labels, the DataGrid and the two buttons there.  Let's set these aside to  focus on the unusual and hence particularly interesting parts.

For each of those columns in the datagrid, the background is bound to an entry in the BGColours ObservableDictionary.  It's not yet obvious because it's not been explained but that dictionary is built in a generic base class using reflection.  By using the field name as the key, we can bind using field name in an easily understood developer friendly manner.

MainWindowViewModel

The DataContext of MainWindow is set to an instance of MainWindowViewModel.

public class  MainWindowViewModel : NotifyUIBase
{
    public ImComplicated IMC { get; set; }
    public Colour TheColour { get; set; }
    public ObservableCollection<MediaVM> MediaVMs {get;set;}
    public RelayCommand MakeChanges { get; private  set; }
    public RelayCommand ResetColour { get; private  set; }
    public MainWindowViewModel()
    {
        MakeChanges = new  RelayCommand(ChangeExecute);
        ResetColour = new  RelayCommand(ResetColourExecute);
 
        IMC = new  ImComplicated();
        TheColour = new  Support.Colour {ColourString="Pink", Id=2 };
        MediaVMs = new  ObservableCollection<MediaVM>
        {
          new MediaVM{TheMedia = new Media{ ColourId=4, Container="Bottle", MediaType="Acrylic Paint", Volume=59}},
          new MediaVM{TheMedia =  new Media{ ColourId=1, Container="Tube", MediaType="Oil Paint", Volume=24}},
          new MediaVM{TheMedia =  new Media{ ColourId=3, Container="Bottle", MediaType="Acrylic Paint", Volume=59}},
          new MediaVM{TheMedia =  new Media{ ColourId=2, Container="Tube", MediaType="Oil Paint", Volume=24}},
          new MediaVM{TheMedia =  new Media{ ColourId=5, Container="Bottle", MediaType="Acrylic Paint", Volume=59}}      
        };
        RaisePropertyChanged("MediaVMs");
        RaisePropertyChanged("IMC");
    }
    private void  ChangeExecute()
    {
         IMC.Pointy = new  Point(99, 123);
         IMC.AColour = new  Colour {MediaColour=Colors.Pink, Id=37, ColourString="Pink" };
    }
    private void  ResetColourExecute()
    {
        var msg = new  ResetMessage { };
        Messenger.Default.Send<ResetMessage>(msg);
    }
 
}

Most of that code just sets up the mocked data.

The left panel is bound to an object of Type ImComplicated and another of Type colour.  At the risk of  labouring what is probably already obvious, these are used to represent complex types in order to demonstrate complex type comparison.

The DataGrid is bound to an ObservableCollection of MediaVM -  these are the oil and acrylic paints you can see in the screen shot.

The buttons are bound to two  MVVMLight RelayCommands.

ChangeExecute is the method which changes the complex types and you can see it changes them using hard coded values.

ResetColourExecute sends a MVVM light message which the various base classes will subscribe to. 

MediaVM

This class is used to wrap each instance of Media.  Media would be whatever class you get per row out your model.  We can imagine a table in a database with a table in it where each record is returned by Entity Framework as an instance of Media.

public class  MediaVM : NotifyWithColour
{
    #region wrapper fields        
    public string  MediaType 
    { 
        get {return TheMedia.MediaType; }
        set
        {
            if (DifferentValue<string>(TheMedia.MediaType, value, typeof(string)))
            {
                TheMedia.MediaType = value;
                RaisePropertyChanged();
            }
        }
    }
 
 
    public string  Container 
    {
        get { return TheMedia.Container; }
        set
        {
            if (DifferentValue<string>(TheMedia.MediaType, value, typeof(string)))
            {
                TheMedia.MediaType = value;
                RaisePropertyChanged();
            }
        } 
    }
    public double  Volume
    {
        get { return TheMedia.Volume; }
        set
        {
            if (DifferentValue<double>(TheMedia.Volume, value, typeof(double)))
            {
                TheMedia.Volume = value;
                RaisePropertyChanged();
            }
        }
    }
    public int  ColourId
    {
        get { return TheMedia.ColourId; }
        set
        {
            if (DifferentValue<int>(TheMedia.ColourId, value, typeof(int)))
            { 
                TheMedia.ColourId = value;
                RaisePropertyChanged();
            }
        }
    }
    #endregion
    public MediaVM()
        : base()    // Call base constructor
    {
        InitiateColours();
        Messenger.Default.Register<ResetMessage>(this, (action) => ReceiveResetMessage(action));
    }
    public static  PropertyInfo[] _properties;
    private void  InitiateColours()
    {
        // Only reflect the list of properties once - this is a fairly expensive operation
        if (_properties == null)
        {
            _properties = typeof(MediaVM).GetProperties();
        }
        base.properties = _properties;
        base.InitiateDictionary();
    }
 
    /// <summary>
    ///  Equivalent to an entity framework entity:
    /// </summary>
     
    public Media TheMedia { get; set; }
    private object  ReceiveResetMessage(ResetMessage r)
    {
        base.ResetColours();
        return null;
    }
 
}

The base class NotifyColour has no way of knowing what properties it should be interested in.
The subclass knows which object is important and you can see InitiateColours setting up that list of properties.
It checks to see this hasn't been done before and then reflects through all properties, adding a PropertyInfo for each to the _properties array.
The base array of properties is then set to that.

An alternative approach would be to have an Entity property in the base class. This would then allow us to push this initiation into the base class but at the cost of having a generic name for every entity in all the viewmodel subclasses.  
One could make an argument for either approach and of course this code has to go with one.

Note that the constructor calls the base constructor - of NotifyWithColour.

The base InitiateDictionary is then called.

ResetMessage is subscribed to and this will fire ResetColours in the base class when received.  This is the part which resets all the backgrounds. 

Each of the properties of Media are "wrapped" with a property in MediaVm, for example. 

public double  Volume
{
    get { return TheMedia.Volume; }
    set
    {
        if (DifferentValue<double>(TheMedia.Volume, value, typeof(double)))
        {
            TheMedia.Volume = value;
            RaisePropertyChanged();
        }
    }
}

Here a Volume property in MediaVM gets it's value from the Volume property in the wrapped instance of Media.

Similarly when you change a value for Volume, that Setter will be hit and as well as changing the wrapped Media's Volume property it calls the DifferentValue method. More about which is to follow.

NotifyWithColour

This is the key base class that the ViewModels inherit from and it is this which holds those background colours.

In there we have the ObservableDictionary already mentioned.

protected ObservableDictionary<string, SolidColorBrush> bGColours =
     new ObservableDictionary<string, SolidColorBrush>();
public ObservableDictionary<string, SolidColorBrush> BGColours
{
    get
    {
        return bGColours;
    }
    set { bGColours = value; }
}

An ObservableDictionary works rather like a regular Dictionary except it implements a number of interfaces in order to do change notification.  You can read more about how it does this in the article linked in the Other Resources section below.
BGCOlours will end up with an element per property of our wrapped entity.  That element has the name of the property as Key and a SolidColorBrush as Value.

There are a couple of methods which are used to initialise/reset these.

protected PropertyInfo[] properties;
 
protected void  InitiateDictionary()
{
    foreach (PropertyInfo property in properties)
    {
        bGColours.Add(property.Name, UnChangedBrush);
    }
}
protected void  ResetColours()
{
    foreach (string key in bGColours.Keys.ToList())
    { 
        bGColours[key] = UnChangedBrush;
        IsDirty = false;
    }
}

InitiateDictionary will be called once as the subclass is instantiated.  This iterates the list of properties set up from the subclass and adds an entry for each.  As explained in the markup section, the key is the name of that property and the value is a SolidColorBrush which is initiated to UnchangedBrush.

ResetColours iterates all the entries in the observabledictionary and resets them to that UnchangeBrush.  It also resets the IsDirty flag.

DifferentValue is used to decide whether the new value is definitely different. 

protected bool  DifferentValue<T>(T newValue, T oldValue, Type type)
{
    bool isDifferent = true;
    if (type.IsPrimitive || type.Equals(typeof(string)))
    {
        if (newValue.ToString() == oldValue.ToString())
        {
            isDifferent = false;
        }
    }
    else
    {
        isDifferent = IsComplexDifferent<T>(newValue, oldValue, type);
    }
    return isDifferent; 
}

This method will return a true for different and false for same.

The type to compare, plus the before and after values are passed in.

Strings and primitives can be compared directly so they're handled fairly simply by just converting them ToString and checking equality.

Complex types are much trickier and they are handed off to another method.

protected bool  IsComplexDifferent<T>(T newObj, T oldObj, Type t)
{
    //return false if any of the object is false
    if (oldObj == null && newObj != null)
    {
        return true;
    }
 
    if (oldObj != null && newObj == null)
    {
        return true;
    }
 
    foreach (System.Reflection.PropertyInfo property in t.GetProperties())
    {
        if (property.Name != "ExtensionData")
        {
            string OldValue = string.Empty;
            string NewValue = string.Empty;
            if (t.GetProperty(property.Name).GetValue(newObj,  null) != null)
            {
                OldValue = t.GetProperty(property.Name).GetValue(newObj, null).ToString();
            }
            if (t.GetProperty(property.Name).GetValue(oldObj,  null) != null)
            {
                NewValue = t.GetProperty(property.Name).GetValue(oldObj, null).ToString();
            }
            if (OldValue.Trim() != NewValue.Trim())
            {
                return true;
            }
        }
    }
    return false;
}

IsComplexDifferent again returns true for different and false for same.

This iterates through the (potentially huge ) tree of parent child properties, converts the value of each to a string so it can compares them.

In practice, you might decide not to handle comparison of complex types or to add a flag so you can choose which to include.

Dictionary1

The background colours are from a merged ResourceDictionary - the values can easily be changed to match your preference.
Banks seem to particularly like Yellow.  You might also prefer a more meaningful name for the ResourceDictionary  in your live application.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <SolidColorBrush x:Key="UnChangedBrush" Color="White"  />
    <SolidColorBrush x:Key="ChangedBrush" Color="Moccasin" />

NotifyWIthColour picks these two brushes up out of Application.Current.Resources:

protected SolidColorBrush HighLighter = Application.Current.Resources["ChangedBrush"] as  SolidColorBrush;
protected SolidColorBrush UnChangedBrush = Application.Current.Resources["UnChangedBrush"] as  SolidColorBrush;

Use in Your Projects

In order to use this technique you will need to include in your project:

  • NotifyWithColour
  • ObservableDictionary 
  • ChangedBrush and UnChangedBrush resources merged in
  • Nuget - MVVM Light Libraries only

You will also need your ViewModels to do the property mapping described above.


Snippet

There's a Visual Studio snippet which you can use to generate a skeleton for a wrapper property.

If you unzip the code and look in the folder you will see propch.snippet.  

To use:

From Visual Studio select Tools > Code Snippets Manager.  Choose Visual C# from the Language combo. Click import and navigate to where you unzipped the code. Choose propch.snippet.

Now when you type "propch" you will get a template similar to a propfull template which inserts the following:

public string  MyProperty
{ 
    get {return TheEntity.EntProp; }
    set
    {
        if (DifferentValue<string>(TheEntity.EntProp, value, typeof(string)))
        {
            TheEntity.EntProp = value;
            RaisePropertyChanged();
        }
    }
}

As with any snippet, you overtype the default names so you overtype that first string and change to int then all instances of string will change to int. 


See Also

WPF Resources on the Technet Wiki
Snippetty Tip


Other Resources

Can I Bind my ItemsControl to a Dictionary - Dr WPF's ObservableDictionary.