2
votes

I have a strange use case for WPF DataGrid using MVVM through ReactiveUI that doesn't quite fit any other solution I've found so far.

The Problem Set

I have a DataSet that contains a list of Users. Each User has a string Id and a set of uniquely-identified data fields associated with it that can be represented as a set of string key-value pairs. All Users within a DataSet will have the same set of fields, but different DataSets may have different fields. For example, all Users in one DataSet may have fields "Name", "Age", and "Address"; while Users in another DataSet may have fields "Badge #" and "Job Title".

I would like to present the DataSets in a WPF DataGrid where the columns can be dynamically populated. I would also like to add some metadata to fields that identify what type of data is stored there and display different controls in the DataGrid cells based on that metadata: Pure text fields should use a TextBox, Image filepath fields should have a TextBox to type in a path and a Button to bring up a file-select dialog, etc.

What I Have That Works (but isn't what I want)

I break my data up into ReactiveUI ViewModels. (omitting RaisePropertyChanged() calls for brevity)

public class DataSetViewModel : ReactiveObject
{
    public ReactiveList<UserViewModel> Users { get; }
    public UserViewModel SelectedUser { get; set; }
};

public class UserViewModel : ReactiveObject
{
    public string Id { get; set; }
    public ReactiveList<FieldViewModel> Fields { get; }

    public class FieldHeader
    {
         public string Key { get; set; }
         public FieldType FType { get; set; } // either Text or Image
    }
    public ReactiveList<FieldHeader> FieldHeaders { get; }
};

public class FieldViewModel : ReactiveObject
{
    public string Value { get; set; } // already knows how to update underlying data when changed
}

I display all of this in a DataSetView. Since Id is always present in Users, I add the first DataGridTextColumn here. Omitting unnecessary XAML for more brevity.

<UserControl x:Class="UserEditor.UI.DataSetView"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:local="clr-namespace:UserEditor.UI"
         x:Name="DataSetControl">
    <DataGrid Name="UserDataGrid"
              SelectionMode="Single" AutoGenerateColumns="False"
              HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
              DataContext="{Binding Path=ViewModel.Users, ElementName=DataSetControl}">
        <DataGrid.Columns>
            <DataGridTextColumn Header="Id" Binding="{Binding Id}" MinWidth="60" Width="SizeToCells"/>
        </DataGrid.Columns>
    </DataGrid>
</UserControl>

And I create additional columns in the code-behind, omitting boiler plate:

public partial class DataSetView : UserControl, IViewFor<DataSetViewModel>
{
    // ViewModel DependencyProperty named "ViewModel" declared here

    public DataSetView()
    {
        InitializeComponent();

        this.WhenAnyValue(_ => _.ViewModel).BindTo(this, _ => _.DataContext);
        this.OneWayBind(ViewModel, vm => vm.Users, v => v.UserDataGrid.ItemsSource);
        this.Bind(ViewModel, vm => vm.SelectedUser, v => v.UserDataGrid.SelectedItem);
    }

    // this gets called when the ViewModel is set, and when I detect fields are added or removed
    private void InitHeaders(bool firstInit)
    {
        // remove all columns except the first, which is reserved for Id
        while (UserDataGrid.Columns.Count > 1)
        {
            UserDataGrid.Columns.RemoveAt(UserDataGrid.Columns.Count - 1);
        }

        if (ViewModel == null)
            return;

        // using all DataGridTextColumns for now
        for (int i = 0; i < ViewModel.FieldHeaders.Count; i++)
        {
            DataGridColumn column;
            switch (ViewModel.FieldHeaders[i].Type)
            {
                case DataSet.UserData.Field.FieldType.Text:
                    column = new DataGridTextColumn
                    {
                        Binding = new Binding($"Fields[{i}].Value")
                    };
                    break;

                case DataSet.UserData.Field.FieldType.Image:
                    column = new DataGridTextColumn
                    {
                        Binding = new Binding($"Fields[{i}].Value")
                    };
                    break;
            }

            column.Header = ViewModel.FieldHeaders[i].Key;
            column.Width = firstInit ? DataGridLength.SizeToCells : DataGridLength.SizeToHeader;

            UserDataGrid.Columns.Add(column);
        }
    }

When Fields get added or remove, the UserViewModels are updated in DataSetViewModel and InitHeaders is called to recreate the columns. The resulting DataGridCells bind to their respective FieldViewModels and everything works.

What I'm Trying To Do (but doesn't work)

I would like to break FieldViewModel into two derived classes, TextFieldViewModel and ImageFieldViewModel. Each has their respective TextFieldView and ImageFieldView with their own ViewModel dependency property. UserViewModel still contains a ReactiveList. My new InitHeaders() looks like this:

    private void InitHeaders(bool firstInit)
    {
        // remove all columns except the first, which is reserved for Id
        while (UserDataGrid.Columns.Count > 1)
        {
            UserDataGrid.Columns.RemoveAt(UserDataGrid.Columns.Count - 1);
        }

        if (ViewModel == null)
            return;

        for (int i = 0; i < ViewModel.FieldHeaders.Count; i++)
        {
            DataGridTemplateColumn column = new DataGridTemplateColumn();
            DataTemplate dataTemplate = new DataTemplate();
            switch (ViewModel.FieldHeaders[i].Type)
            {
                case DataSet.UserData.Field.FieldType.Text:
                    {
                        FrameworkElementFactory factory = new FrameworkElementFactory(typeof(TextFieldView));
                        factory.SetBinding(TextFieldView.ViewModelProperty, 
                            new Binding($"Fields[{i}]"));
                        dataTemplate.VisualTree = factory;
                        dataTemplate.DataType = typeof(TextFieldViewModel);
                    }
                    break;

                case DataSet.UserData.Field.FieldType.Image:
                    {
                        FrameworkElementFactory factory = new FrameworkElementFactory(typeof(ImageFieldView));
                        factory.SetBinding(ImageFieldView.ViewModelProperty, 
                            new Binding($"Fields[{i}]"));
                        dataTemplate.VisualTree = factory;
                        dataTemplate.DataType = typeof(ImageFieldViewModel);
                    }
                    break;
            }

            column.Header = ViewModel.FieldHeaders[i].Key;
            column.Width = firstInit ? DataGridLength.SizeToCells : DataGridLength.SizeToHeader;
            column.CellTemplate = dataTemplate;

            UserDataGrid.Columns.Add(column);
        }
    }

The idea is that I create a DataGridTemplateColumn that generates the correct View and then binds the indexed FieldViewModel to the ViewModel dependency property. I have also tried adding a Converter to the Bindings that converts from the base VM to the correct derived type.

The end result is that the DataGrid populates with the correct view, but the DataContext is always a UserViewModel rather than the appropriate FieldViewModel-derived type. The ViewModel is never set, and the VMs don't bind properly. I'm not sure what else I'm missing, and would appreciate any suggestions or insight.

1

1 Answers

0
votes

I've figured out an answer that works, though it may not be the best one. Rather than binding to the ViewModel property in my views, I instead bind directly to the DataContext:

factory.SetBinding(DataContextProperty, new Binding($"Fields[{i}]"));

In my views, I add some boilerplate code to listen to the DataContext, set the ViewModel property, and perform my ReactiveUI binding:

public TextFieldView()
{
    InitializeComponent();

    this.WhenAnyValue(_ => _.DataContext)
        .Where(context => context != null)
        .Subscribe(context =>
        {
            // other binding occurs as a result of setting the ViewModel
            ViewModel = context as TextFieldViewModel;
        });
}