Xceed WPF propertyGrid show item for expanded collection
Asked Answered
A

2

14

How, do I display a ObservableCollection<> of custom objects in the Xceed WPF PropertyGrid in which each List Item can be expanded to display the custom objects properties. (ie:

----PropertyGrid-----

CoreClass

  • (+/-) ObservableCollection< CustomClass >

    • (+/-) CustomClass.Object1

      • Property1: Value

      • Property2: Value

      • PropertyN: Value

    • (+/-) CustomClass.Object2

      • Property1: Value

      • Property2: Value

      • PropertyN: Value

If I use [ExpandableObject] on the ObservableCollection<> it only shows the Counts property.

Edit:(Added code)

MainWindow.xaml:

<Window x:Class="PropGridExample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:PropGridExample"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <xctk:PropertyGrid x:Name="PropertyGrid" SelectedObject="{Binding BindingItem}"></xctk:PropertyGrid>
    </Grid>
</Window>

MainWindow.xaml.cs

public partial class MainWindow : Window
{
    public MainWindow()
    {
        MainWindowViewModel mwvm = new MainWindowViewModel();
        this.DataContext = mwvm;
        InitializeComponent();
    }
}

MainWindowViewModel.cs

public class MainWindowViewModel
{
    public Item BindingItem { get; set; }

    public MainWindowViewModel()
    {
        BindingItem = new Item();
    }

    public class Item
    {
        public int ID { get; set; }
        [ExpandableObject()]
        public ObservableCollection<CustomClass> Classes { get; set; }

        public Item()
        {
            ID = 1;
            Classes = new ObservableCollection<CustomClass>();
            Classes.Add(new CustomClass() { Name = "CustomFoo" });
        }
    }

    public class CustomClass
    {
        public string Name { get; set; }
        [ExpandableObject()]
        public ObservableCollection<type> Types { get; set; }

        public CustomClass()
        {
            Types = new ObservableCollection<type>();
            Types.Add(new type() { name = "foo", value = "bar" });
            Types.Add(new type() { name = "bar", value = "foo" });
        }
    }

    public class type
    {
        public string name { get; set; }
        public string value { get; set; }
    }
}
Automata answered 29/3, 2016 at 13:58 Comment(9)
@jstreet this is not possible because I want to show other properties of the custom class as well. Also I want to be able to change values of collectionitems properties.Automata
I can't just use the ToArray() - method because im using MVVM with Databinding. Also the SelectedObject changes at runtime. But if you wish i can post a example application, the real code is to vast.Automata
yes, i want to expand it "inline", so if i expand the collection i see all the items within the collection. I don't want to use the collectioneditorAutomata
@Automata I believe that this might not be possible using the PropertyGrid. Does it have to be a PropertyGrid specifically or something like a property grid?Haemolysis
@MackieChan It is possible with the winform property grid: codeproject.com/Articles/4448/… . So it is hard for me to believe that this is not possible with the wpf version. But no it doesn't has to be a propertyGrid, I've also thaught about using a treeview with the possibilty of editing the values of each item.Automata
@Automata I originally discarded that because it appeared to require modification of the classes to display (which I had assumed was not desired, but in retrospect probably does not matter), but you're right, it may possible using a similar technique. Have you tried making the same changes as noted in the article?Haemolysis
@MackieChan No didn't tried this yet, I thought there could be an easier way to do this with the wpf propertygrid. Also they do not working the same wayAutomata
@MackieChan I've done the article 1 by 1 and it almost worked, but i can not expand the items in the collection, even though I used [ExpandableObject] on the item-classesAutomata
@Automata the way that they implement the EmployeeCollectionPropertyDescriptor.Attributes property prevents the WPF PropertyGrid from expanding the items. At a minimum (to get it working) it needs to return an attribute collection that contains a ExpandableObjectAttribute so that the PropertyGrid will expand each object.Haemolysis
H
14

Note that most of this idea comes from the CodeProject project you linked to. The article gets you most of the way there, but as you note, it does not expand each item in the collection for the WPF PropertyGrid. In order to do that, each "item" needs to have an ExpandableObjectAttribute.

In order to allow future StackOverflow readers to understand, I'm going to start from beginning.

From the beginning

So, starting from this example:

public class MainWindowViewModel
{
  /// <summary> This the object we want to be able to edit in the data grid. </summary>
  public ComplexObject BindingComplexObject { get; set; }

  public MainWindowViewModel()
  {
    BindingComplexObject = new ComplexObject();
  }
}

public class ComplexObject
{
  public int ID { get; set; }

  public ObservableCollection<ComplexSubObject> Classes { get; set; }

  public ComplexObject()
  {
    ID = 1;
    Classes = new ObservableCollection<ComplexSubObject>();
    Classes.Add(new ComplexSubObject() { Name = "CustomFoo" });
    Classes.Add(new ComplexSubObject() { Name = "My Other Foo" });
  }
}

public class ComplexSubObject
{
  public string Name { get; set; }

  public ObservableCollection<SimpleValues> Types { get; set; }

  public ComplexSubObject()
  {
    Types = new ObservableCollection<SimpleValues>();
    Types.Add(new SimpleValues() { name = "foo", value = "bar" });
    Types.Add(new SimpleValues() { name = "bar", value = "foo" });
  }
}

public class SimpleValues
{
  public string name { get; set; }
  public string value { get; set; }
}

In order for the WPF PropertyGrid to be able to edit each item in the ObservableCollection, we need to provide a type descriptor for the collection which return the items as "Properties" of that collection so they can be edited. Because we cannot statically determine the items from a collection (as each collection has different number of elements), it means that the collection itself must be the TypeDescriptor, which means implementing ICustomTypeDescriptor.

(note that only GetProperties is important for our purposes, the rest just delegates to TypeDescriptor):

public class ExpandableObservableCollection<T> : ObservableCollection<T>,
                                                 ICustomTypeDescriptor
{
  PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties()
  {
    // Create a collection object to hold property descriptors
    PropertyDescriptorCollection pds = new PropertyDescriptorCollection(null);

    for (int i = 0; i < Count; i++)
    {
      pds.Add(new ItemPropertyDescriptor<T>(this, i));
    }

    return pds;
  }

  #region Use default TypeDescriptor stuff

  AttributeCollection ICustomTypeDescriptor.GetAttributes()
  {
    return TypeDescriptor.GetAttributes(this, noCustomTypeDesc: true);
  }

  string ICustomTypeDescriptor.GetClassName()
  {
    return TypeDescriptor.GetClassName(this, noCustomTypeDesc: true);
  }

  string ICustomTypeDescriptor.GetComponentName()
  {
    return TypeDescriptor.GetComponentName(this, noCustomTypeDesc: true);
  }

  TypeConverter ICustomTypeDescriptor.GetConverter()
  {
    return TypeDescriptor.GetConverter(this, noCustomTypeDesc: true);
  }

  EventDescriptor ICustomTypeDescriptor.GetDefaultEvent()
  {
    return TypeDescriptor.GetDefaultEvent(this, noCustomTypeDesc: true);
  }

  PropertyDescriptor ICustomTypeDescriptor.GetDefaultProperty()
  {
    return TypeDescriptor.GetDefaultProperty(this, noCustomTypeDesc: true);
  }

  object ICustomTypeDescriptor.GetEditor(Type editorBaseType)
  {
    return TypeDescriptor.GetEditor(this, editorBaseType, noCustomTypeDesc: true);
  }

  EventDescriptorCollection ICustomTypeDescriptor.GetEvents()
  {
    return TypeDescriptor.GetEvents(this, noCustomTypeDesc: true);
  }

  EventDescriptorCollection ICustomTypeDescriptor.GetEvents(Attribute[] attributes)
  {
    return TypeDescriptor.GetEvents(this, attributes, noCustomTypeDesc: true);
  }

  PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties(Attribute[] attributes)
  {
    return TypeDescriptor.GetProperties(this, attributes, noCustomTypeDesc: true);
  }

  object ICustomTypeDescriptor.GetPropertyOwner(PropertyDescriptor pd)
  {
    return this;
  }

  #endregion
}

Additionally, we need an implementation of ItemPropertyDescriptor, which I provide here:

public class ItemPropertyDescriptor<T> : PropertyDescriptor
{
  private readonly ObservableCollection<T> _owner;
  private readonly int _index;

  public ItemPropertyDescriptor(ObservableCollection<T> owner, int index)
    : base("#" + index, null)
  {
    _owner = owner;
    _index = index;
  }

  public override AttributeCollection Attributes
  {
    get
    {
      var attributes = TypeDescriptor.GetAttributes(GetValue(null), false);
      if (!attributes.OfType<ExpandableObjectAttribute>().Any())
      {
        // copy all the attributes plus an extra one (the
        // ExpandableObjectAttribute)
        // this ensures that even if the type of the object itself doesn't have the
        // ExpandableObjectAttribute, it will still be expandable. 
        var newAttributes = new Attribute[attributes.Count + 1];
        attributes.CopyTo(newAttributes, newAttributes.Length - 1);
        newAttributes[newAttributes.Length - 1] = new ExpandableObjectAttribute();

        // overwrite the array
        attributes = new AttributeCollection(newAttributes);
      }

      return attributes;
    }
  }

  public override bool CanResetValue(object component)
  {
    return false;
  }

  public override object GetValue(object component)
  {
    return Value;
  }

  private T Value
    => _owner[_index];

  public override void ResetValue(object component)
  {
    throw new NotImplementedException();
  }

  public override void SetValue(object component, object value)
  {
    _owner[_index] = (T)value;
  }

  public override bool ShouldSerializeValue(object component)
  {
    return false;
  }

  public override Type ComponentType
    => _owner.GetType();

  public override bool IsReadOnly
    => false;

  public override Type PropertyType
    => Value?.GetType();
}

Which for the most part, just sets up reasonable defaults, which you can tweak to serve your needs.

One thing to note is that you may implement the Attributes property differently, depending on your use case. If you don't do the "add it to the attribute collection if it's not there", then you need to add the attribute to the classes/types that you want to expand; if you do keep that code in, then you'll be able to expand every item in the collection no matter if the class/type has the attribute or not.

It then becomes a matter of using ExpandableObservableCollection in place of ObservableCollection. This kind of sucks as it means your ViewModel has view-stuff-ish stuff in it, but ¯\_(ツ)_/¯.

Additionally, you need to add the ExpandableObjectAttribute to each of the properties that is a ExpandableObservableCollection.

Code Dump

If you're following along at home, you can use the following dialog code to run the example:

<Window x:Class="WpfDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfDemo"
        xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
      <xctk:PropertyGrid x:Name="It" />
    </Grid>
</Window>

-

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;

namespace WpfDemo
{
  /// <summary>
  /// Interaction logic for MainWindow.xaml
  /// </summary>
  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();

      It.SelectedObject = new MainWindowViewModel().BindingComplexObject;
    }
  }
}

And here's the complete ViewModel implementation:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Xceed.Wpf.Toolkit.PropertyGrid.Attributes;

namespace WpfDemo
{
  public class MainWindowViewModel
  {
    /// <summary> This the object we want to be able to edit in the data grid. </summary>
    public ComplexObject BindingComplexObject { get; set; }

    public MainWindowViewModel()
    {
      BindingComplexObject = new ComplexObject();
    }
  }

  [ExpandableObject]
  public class ComplexObject
  {
    public int ID { get; set; }

    [ExpandableObject]
    public ExpandableObservableCollection<ComplexSubObject> Classes { get; set; }

    public ComplexObject()
    {
      ID = 1;
      Classes = new ExpandableObservableCollection<ComplexSubObject>();
      Classes.Add(new ComplexSubObject() { Name = "CustomFoo" });
      Classes.Add(new ComplexSubObject() { Name = "My Other Foo" });
    }
  }

  [ExpandableObject]
  public class ComplexSubObject
  {
    public string Name { get; set; }

    [ExpandableObject]
    public ExpandableObservableCollection<SimpleValues> Types { get; set; }

    public ComplexSubObject()
    {
      Types = new ExpandableObservableCollection<SimpleValues>();
      Types.Add(new SimpleValues() { name = "foo", value = "bar" });
      Types.Add(new SimpleValues() { name = "bar", value = "foo" });
    }
  }

  public class SimpleValues
  {
    public string name { get; set; }
    public string value { get; set; }
  }

  public class ExpandableObservableCollection<T> : ObservableCollection<T>,
                                                   ICustomTypeDescriptor
  {
    PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties()
    {
      // Create a collection object to hold property descriptors
      PropertyDescriptorCollection pds = new PropertyDescriptorCollection(null);

      for (int i = 0; i < Count; i++)
      {
        pds.Add(new ItemPropertyDescriptor<T>(this, i));
      }

      return pds;
    }

    #region Use default TypeDescriptor stuff

    AttributeCollection ICustomTypeDescriptor.GetAttributes()
    {
      return TypeDescriptor.GetAttributes(this, noCustomTypeDesc: true);
    }

    string ICustomTypeDescriptor.GetClassName()
    {
      return TypeDescriptor.GetClassName(this, noCustomTypeDesc: true);
    }

    string ICustomTypeDescriptor.GetComponentName()
    {
      return TypeDescriptor.GetComponentName(this, noCustomTypeDesc: true);
    }

    TypeConverter ICustomTypeDescriptor.GetConverter()
    {
      return TypeDescriptor.GetConverter(this, noCustomTypeDesc: true);
    }

    EventDescriptor ICustomTypeDescriptor.GetDefaultEvent()
    {
      return TypeDescriptor.GetDefaultEvent(this, noCustomTypeDesc: true);
    }

    PropertyDescriptor ICustomTypeDescriptor.GetDefaultProperty()
    {
      return TypeDescriptor.GetDefaultProperty(this, noCustomTypeDesc: true);
    }

    object ICustomTypeDescriptor.GetEditor(Type editorBaseType)
    {
      return TypeDescriptor.GetEditor(this, editorBaseType, noCustomTypeDesc: true);
    }

    EventDescriptorCollection ICustomTypeDescriptor.GetEvents()
    {
      return TypeDescriptor.GetEvents(this, noCustomTypeDesc: true);
    }

    EventDescriptorCollection ICustomTypeDescriptor.GetEvents(Attribute[] attributes)
    {
      return TypeDescriptor.GetEvents(this, attributes, noCustomTypeDesc: true);
    }

    PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties(Attribute[] attributes)
    {
      return TypeDescriptor.GetProperties(this, attributes, noCustomTypeDesc: true);
    }

    object ICustomTypeDescriptor.GetPropertyOwner(PropertyDescriptor pd)
    {
      return this;
    }

    #endregion
  }

  public class ItemPropertyDescriptor<T> : PropertyDescriptor
  {
    private readonly ObservableCollection<T> _owner;
    private readonly int _index;

    public ItemPropertyDescriptor(ObservableCollection<T> owner, int index)
      : base("#" + index, null)
    {
      _owner = owner;
      _index = index;
    }

    public override AttributeCollection Attributes
    {
      get
      {
        var attributes = TypeDescriptor.GetAttributes(GetValue(null), false);


        if (!attributes.OfType<ExpandableObjectAttribute>().Any())
        {
          // copy all the attributes plus an extra one (the
          // ExpandableObjectAttribute)
          // this ensures that even if the type of the object itself doesn't have the
          // ExpandableObjectAttribute, it will still be expandable. 
          var newAttributes = new Attribute[attributes.Count + 1];
          attributes.CopyTo(newAttributes, newAttributes.Length - 1);
          newAttributes[newAttributes.Length - 1] = new ExpandableObjectAttribute();

          // overwrite the original
          attributes = new AttributeCollection(newAttributes);
        }

        return attributes;
      }
    }

    public override bool CanResetValue(object component)
    {
      return false;
    }

    public override object GetValue(object component)
    {
      return Value;
    }

    private T Value
      => _owner[_index];

    public override void ResetValue(object component)
    {
      throw new NotImplementedException();
    }

    public override void SetValue(object component, object value)
    {
      _owner[_index] = (T)value;
    }

    public override bool ShouldSerializeValue(object component)
    {
      return false;
    }

    public override Type ComponentType
      => _owner.GetType();

    public override bool IsReadOnly
      => false;

    public override Type PropertyType
      => Value?.GetType();
  }
}
Haemolysis answered 6/4, 2016 at 4:54 Comment(3)
Yesterday I've read a post from one of the developer who wrote "this is not possible": wpftoolkit.codeplex.com/discussions/653034 . But yet here you are, hacking the s*** out of it :D. Thank you very much great work.Automata
Also note: Ensure you have a default (empty) constructor available for the type you're expanding, otherwise you'll find they're all removed if you cancel from the collection control! It seems underneath the items are removed and replaced if you cancel out, however, reflection must be used here hence the need for the default constructor.Evyn
Great job @FriendlyGuy. I took the liberty to turn your code into a nuget package (nuget.org/packages/Extended.Wpf.Toolkit.PropertyGrid.Collection) and hope that eventually it is included into the wpftoolkit itself. (github.com/xceedsoftware/wpftoolkit/issues/1479)Cartier
M
6

MackieChan provided the major clues for this...

There is no need to inherit from ICustomTypeDescriptor as similar results can be achieved using type converters.

Firstly create an expandable object type converter and override the GetProperties method. For example, if you wish to maintain the index order of a generic IList type:

using Xceed.Wpf.Toolkit.PropertyGrid.Attributes;
using System.ComponentModel;

public class MyExpandableIListConverter<T> : ExpandableObjectConverter
{
    public override PropertyDescriptorCollection GetProperties(ITypeDescriptorContext context, object value, Attribute[] attributes)
    {
        if (value is IList<T> list)
        {
            PropertyDescriptorCollection propDescriptions = new PropertyDescriptorCollection(null);
            IEnumerator enumerator = list.GetEnumerator();
            int counter = -1;
            while (enumerator.MoveNext())
            {
                counter++;
                propDescriptions.Add(new ListItemPropertyDescriptor<T>(list, counter));

            }
            return propDescriptions;
        }
        else
        {
            return base.GetProperties(context, value, attributes);
        }
    }        
}

With the ListItemPropertyDescriptor being defined as follows:

using Xceed.Wpf.Toolkit.PropertyGrid.Attributes;
using System.ComponentModel;

public class ListItemPropertyDescriptor<T> : PropertyDescriptor
{
    private readonly IList<T> owner;
    private readonly int index;

    public ListItemPropertyDescriptor(IList<T> owner, int index) : base($"[{index}]", null)
    {
        this.owner = owner;
        this.index = index;

    }

    public override AttributeCollection Attributes
    {
        get
        {
            var attributes = TypeDescriptor.GetAttributes(GetValue(null), false);
            //If the Xceed expandable object attribute is not applied then apply it
            if (!attributes.OfType<ExpandableObjectAttribute>().Any())
            {
                attributes = AddAttribute(new ExpandableObjectAttribute(), attributes);
            }

            //set the xceed order attribute
            attributes = AddAttribute(new PropertyOrderAttribute(index), attributes);

            return attributes;
        }
    }
    private AttributeCollection AddAttribute(Attribute newAttribute, AttributeCollection oldAttributes)
    {
        Attribute[] newAttributes = new Attribute[oldAttributes.Count + 1];
        oldAttributes.CopyTo(newAttributes, 1);
        newAttributes[0] = newAttribute;

        return new AttributeCollection(newAttributes);
    }

    public override bool CanResetValue(object component)
    {
        return false;
    }

    public override object GetValue(object component)
    {
        return Value;
    }

    private T Value
      => owner[index];

    public override void ResetValue(object component)
    {
        throw new NotImplementedException();
    }

    public override void SetValue(object component, object value)
    {
        owner[index] = (T)value;
    }

    public override bool ShouldSerializeValue(object component)
    {
        return false;
    }

    public override Type ComponentType
      => owner.GetType();

    public override bool IsReadOnly
      => false;

    public override Type PropertyType
      => Value?.GetType();

}

Then you need to dynamically decorate the types you wish to display in the property grid with ExpandableObjectAttribute and TypeConverterAttribute. I create a 'decoration manager' to achieve this as follows.

using System.ComponentModel;
using Xceed.Wpf.Toolkit.PropertyGrid.Attributes;

public static class TypeDecorationManager
{
    public static void AddExpandableObjectConverter(Type T)
    {
        TypeDescriptor.AddAttributes(T, new TypeConverterAttribute(typeof(ExpandableObjectConverter)));
        TypeDescriptor.AddAttributes(T, new ExpandableObjectAttribute());
    }
    public static void AddExpandableIListConverter<I>(Type T)
    {
        TypeDescriptor.AddAttributes(T, new TypeConverterAttribute(typeof(MyExpandableIListConverter<I>)));
        TypeDescriptor.AddAttributes(T, new ExpandableObjectAttribute());
    }
}

Call AddExpandableObjectConverter for any type you would like to be expandable in the property grid and AddExpandableIListConverter for any IList type you would like to be expandable on the grid.

For example, if you have a curve object with some properties including an IList then all of the properties and list items can be made expandable as follows:

TypeDecorationManager.AddExpandableObjectConverter(typeof(Curve));
TypeDecorationManager.AddExpandableObjectConverter(typeof(CurvePoint));

AddCoreExpandableListConverter<CurvePoint>(typeof(IList<CurvePoint>));
Manakin answered 15/4, 2016 at 19:9 Comment(6)
Note that the '"["+ index+"]"' part is important as the extended toolkit PropertyGrid will use that to try to bind to the simulated property of the list. Anything else results in a System.Windows.Data binding error.Manakin
Also note that if the target collection has an overload of this[] (e.g.: this[string]) then this[int] must be declared before the overload. And if this[int] is inherented from base and the overload (this[string]) is not, then this[int] will not be considered declared before the overload so you will have to make a new this[int] that forward the base[int]Astolat
This is the way to go if you don't have control over your model classes.Scirrhus
Instead of using "[" + index + "]" as the property name (which I changed to $"[{index}]" for clarity btw), I see the other answer uses "#" + index (pound sign instead of brackets.) Could that get around the issue of the indexers as mentioned in the comment by @Astolat above?Excogitate
This is a great solution! However, I have one minor problem: How can I add a set of PropertyDefinitions (preferably in xaml) for the objects in the list? I'd love to be able to set DisplayName etc in xaml. When I add them to the set of PropertyGrid.PropertyDefinitions for the "root", they don't seem to have any effect at all.Chairman
Also, another minor thing: The indentation of the Category headers are a bit too little indented, for categories on the objects in the expanded collection. No big issue, but can look a bit messy. Anyone knows if it's possible to indent category headers more?Chairman

© 2022 - 2024 — McMap. All rights reserved.