I'm working on a WPF program for editing a proprietary script class. The model is composed of the following classes:
public MethodCall
{
public Dictionary<string, object> Parameters { get; }
public MethodCall()
{
Parameters = new Dictionary<string, object>();
}
public MethodCall(Dictionary<string, object> parameters)
{
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters);
}
public void Execute(Client client)
{
// Does stuff
}
}
public class Script
{
public List<Request> Requests { get; }
public void Execute(Client client)
{
foreach (var request in Requests)
{
request.Execute(client);
}
}
}
To display a Script
containing a CallMethod
request, I have the following View Model classes.
public class ScriptViewModel : INotifyPropertyChanged
{
public Script Script { get; set; } // Setter raises PropertyChanged
public ObservableCollection<RequestViewModel> { get; }
}
public class RequestViewModel : INotifyPropertyChanged
{
public Request Request { get; set; } // Setter raises PropertyChanged
public ObservableCollection<ParameterViewModel> Parameters { get; }
}
public class ParameterViewModel : INotifyPropertyChanged
{
public bool IsDirty { get; set; } // Raises PropertyChanged
public string Name { get; set; } // Raises PropertyChanged
public object Value { get; set; } // Raises PropertyChanged
}
Currently, I'm using a DataGrid
control to display & edit the Parameters
collection. But, because the Value
property is an object, it can be anything, including an array of objects. My problem is how to edit this data in the WPF app. I'm using a DataGridTextColumn
to display the Name property, and that works fine. Currently, I'm also using a DataGridTextColumn
to display the Value
property, but that's not working so well.
I think I want to use a DataGridTemplateColumn' for the
Valueproperty, but I'd need to use different templates depending upon what the particular type is. If the type isn't an array, I'd use a TextBox, but if it is an Array, I'd probably use another
DataGrid`.
How do I write that XAML?
Considering how little traction this question got, I ended up rolling my own solution. I share it here in case someone else can make use of it in a similar situation.
What I did first was to define a number of different DataTemplates
in the App.xaml:
<DataTemplate x:Key="ObjectArrayTemplate"
DataType="system:Array">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<DataGrid x:Name="ParameterItemsGrid"
Grid.Column="0"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserReorderColumns="False"
CanUserResizeRows="False"
CanUserResizeColumns="False"
CanUserSortColumns="False"
Initialized="OnArrayDataGridInitialized">
<DataGrid.Columns>
<DataGridTemplateColumn CellTemplateSelector="{StaticResource ValueDataSelector}"
Width="*" />
<DataGridTemplateColumn Width="52">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel>
<Button Command="local:EditorCommands.DeleteItem"
CommandParameter="{Binding Path=Item, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type DataGridRow}}}"
Content="Delete"
Height="25"
Margin="5"
VerticalAlignment="Top" />
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<StackPanel Grid.Column="1">
<Button Content="Add Item"
Command="local:EditorCommands.AddItem"
Margin="5" />
</StackPanel>
</Grid>
</DataTemplate>
<DataTemplate x:Key="ValueArrayTemplate"
DataType="{x:Type system:Array}">
<DataGrid Initialized="OnArrayDataGridInitialized">
<DataGrid.Columns>
<DataGridTextColumn Header="Item" />
</DataGrid.Columns>
</DataGrid>
</DataTemplate>
<DataTemplate x:Key="ValueTemplate"
DataType="system:Object">
<TextBox Initialized="OnValueTextBoxInitialized" />
</DataTemplate>
The first DataTemplate
is used to display a parameter whose type is an array of objects defined in the program. The second is used to display an array of objects of a simple type (int, string, etc.). The third template is used to display a parameter whose type is just a simple type, or an item whose value is a simple type.
There are other templates for different program-specific classes that I'm not going to show. The important points regarding these are that I implemented INotifyPropertyChanged
on all of these classes and I had to set the UpdateSourceTrigger
on the binding to PropertyChanged
. I did all of this so the bindings would be 2 way and work.
The trick to making this work can be found in the Initialized
event handler defined in the 2 DataGrids
and the single simple type TextBox
:
private void OnArrayDataGridInitialized(object sender, EventArgs e)
{
var valueGrid = (DataGrid)sender;
var parameterVm = (ParameterViewModel)valueGrid.DataContext;
// Bind the value grid's ItemsSource property to the ParameterViewModel's Items property.
var itemsBinding = new Binding("Items")
{
Source = parameterVm,
ValidatesOnDataErrors = true
};
valueGrid.SetBinding(DataGrid.ItemsSourceProperty, itemsBinding);
// Bind the value grid's SelectedItem property to the ParameterViewModel's CurrentItem property.
var currentItemBinding = new Binding("CurrentItem")
{
Mode = BindingMode.TwoWay,
Source = parameterVm,
UpdateSourceTrigger = UpdateSourceTrigger.LostFocus
};
valueGrid.SetBinding(DataGrid.SelectedItemProperty, currentItemBinding);
// Bind the value grid's SelectedIndex property to the ParameterViewModel's CurrentItemIndex property.
var currentItemIndexBinding = new Binding("CurrentItemIndex")
{
Mode = BindingMode.TwoWay,
Source = parameterVm,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
};
valueGrid.SetBinding(DataGrid.SelectedIndexProperty, currentItemIndexBinding);
}
private void OnValueTextBoxInitialized(object sender, EventArgs e)
{
TextBox textBox = (TextBox)sender;
DataGridRow parentRow = VisualTreeHelpers.FindAncestor<DataGridRow>(textBox);
if (!(parentRow.Item is ParameterViewModel parameterVm))
return;
var valueBinding = new Binding("Value")
{
Mode = BindingMode.TwoWay,
Source = parameterVm,
ValidatesOnDataErrors = true
};
textBox.SetBinding(TextBox.TextProperty, valueBinding);
}
}
This code lives in the App.xaml.cs file and uses the VisualTreeHelpers
class, which you can find in this post on Rachel Lim's blog.
As you can see, the OnArrayDataGridInitialized
event handler finds the parent DataGrid
control, then retrieves its DataContext
and casts it to a ParameterViewModel
. From that, it creates the needed bindings to connect the view to the objects.
In the PrameterViewModel
class, I had to add an ObservableCollection<object>
property called Items
which would hold the individual array elements if the property is an array. I also added an object
property called CurrentItem
and an int
property called CurrentItemIndex
. These are referenced in the above XAML & Initialized
event handlers.
The setter for the Value
property now looks like this:
public object Value
{
get => _value;
set
{
if (_value == value)
return;
if (_value is INotifyPropertyChanged npc)
npc.PropertyChanged -= OnItemPropertyChanged;
_value = value;
RaisePropertyChanged();
npc = _value as INotifyPropertyChanged;
if (npc != null)
npc.PropertyChanged += OnItemPropertyChanged;
IsDirty = true;
RaisePropertyChanged(nameof(IsValid));
}
}
private void OnItemPropertyChanged(object sender, PropertyChangedEventArgs e)
{
RaisePropertyChanged(nameof(Value));
}
In the constructor, after initializing the Items
collection, I subscribe to it's CollectionChanged
event; here's how I process those events:
private void OnItemsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems != null)
{
foreach (object item in e.OldItems)
{
if (item is INotifyPropertyChanged npc)
npc.PropertyChanged -= OnItemPropertyChanged;
}
}
if (e.NewItems != null)
{
foreach (object item in e.NewItems)
{
if (item is INotifyPropertyChanged npc)
npc.PropertyChanged += OnItemPropertyChanged;
}
}
Finally, I had to make some changes to the ParameterViewModel
class to get everything to display & update properly. Here is the ParameterViewModel
constructor:
public ParameterViewModel(
string objectName,
string method,
string name,
Type type,
object value,
bool isNameReadonly = false) : this(findContext)
{
if (string.IsNullOrEmpty(objectName))
throw new ArgumentNullException(nameof(objectName));
if (string.IsNullOrEmpty(method))
throw new ArgumentNullException(nameof(method));
_name = name ?? throw new ArgumentException(nameof(name));
_type = type ?? throw new ArgumentNullException(nameof(type));
_elementType = _type.GetElementType() ?? _type;
_value = value;
if (_value != null && _type.IsArray && ((Array)_value).Length > 0)
LoadItems(_value, _elementType);
RaisePropertyChanged(nameof(IsValid));
}
Here's the LoadItems
method that the constructor calls:
private void LoadItems(object value, Type type)
{
if (type.IsClass)
{
foreach (object item in (object[])_value)
{
Items.Add(item);
if (item is INotifyPropertyChanged npc)
npc.PropertyChanged += OnItemPropertyChanged;
}
return;
}
if (type == typeof(bool))
{
LoadItems((bool[])value);
}
else if (type == typeof(byte))
{
LoadItems((byte[])value);
}
else if (type == typeof(char))
{
LoadItems((char[])value);
}
else if (type == typeof(DateTime))
{
LoadItems((DateTime[])value);
}
else if (type == typeof(decimal))
{
LoadItems((decimal[])value);
}
else if (type == typeof(double))
{
LoadItems((double[])value);
}
else if (type == typeof(float))
{
LoadItems((float[])value);
}
else if (type == typeof(int))
{
LoadItems((int[])value);
}
else if (type == typeof(long))
{
LoadItems((long[])value);
}
else if (type == typeof(sbyte))
{
LoadItems((sbyte[])value);
}
else if (type == typeof(short))
{
LoadItems((short[])value);
}
else if (type == typeof(string))
{
LoadItems((string[])value);
}
else if (type == typeof(TimeSpan))
{
LoadItems((TimeSpan[])value);
}
else if (type == typeof(uint))
{
LoadItems((uint[])value);
}
}
private void LoadItems<T>(T[] array)
{
foreach (T item in array)
{
Items.Add(item);
}
}
That's pretty much it. There was no way to do the whole thing in XAML because the DataGrid
control doesn't participate in the Visual Tree structure like a normal control. When the program is running, you can navigate to everything normally using Snoop, but the bindings don't inherit normally. This works and, with the custom templates for custom types, everything is easy for the user to read and navigate.