PropertyGrid expandable collection
Asked Answered
P

3

10

I want to automatically show every IList as expandable in my PropertyGrid (By "expandable", I obviously mean that the items will be shown). I don't want to use attributes on each list (Once again, I want it to work for EVERY IList)

I tried to achive it by using a custom PropertyDescriptor and an ExpandableObjectConverter. It works, but after I delete items from the list, the PropertyGrid is not being refreshed, still displaying the deleted items.

I tried to use ObservableCollection along with raising OnComponentChanged, and also RefreshProperties attribute, but nothing worked.

This is my code:

public class ExpandableCollectionPropertyDescriptor : PropertyDescriptor
{
    private IList _collection;

    private readonly int _index = -1;

    internal event EventHandler RefreshRequired;

    public ExpandableCollectionPropertyDescriptor(IList coll, int idx) : base(GetDisplayName(coll, idx), null)
    {
        _collection = coll
        _index = idx;
    }

    public override bool SupportsChangeEvents
    {
        get { return true; }
    }

    private static string GetDisplayName(IList list, int index)
    {

        return "[" + index + "]  " + CSharpName(list[index].GetType());
    }

    private static string CSharpName(Type type)
    {
        var sb = new StringBuilder();
        var name = type.Name;
        if (!type.IsGenericType)
            return name;
        sb.Append(name.Substring(0, name.IndexOf('`')));
        sb.Append("<");
        sb.Append(string.Join(", ", type.GetGenericArguments()
                                        .Select(CSharpName)));
        sb.Append(">");
        return sb.ToString();
    }

    public override AttributeCollection Attributes
    {
        get 
        { 
            return new AttributeCollection(null);
        }
    }

    public override bool CanResetValue(object component)
    {

        return true;
    }

    public override Type ComponentType
    {
        get 
        { 
            return _collection.GetType();
        }
    }

    public override object GetValue(object component)
    {
        OnRefreshRequired();

        return _collection[_index];
    }

    public override bool IsReadOnly
    {
        get { return false;  }
    }

    public override string Name
    {
        get { return _index.ToString(); }
    }

    public override Type PropertyType
    {
        get { return _collection[_index].GetType(); }
    }

    public override void ResetValue(object component)
    {
    }

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

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

    protected virtual void OnRefreshRequired()
    {
        var handler = RefreshRequired;
        if (handler != null) handler(this, EventArgs.Empty);
    }
}

.

internal class ExpandableCollectionConverter : ExpandableObjectConverter
{
    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destType)
    {
        if (destType == typeof(string))
        {
            return "(Collection)";
        }
        return base.ConvertTo(context, culture, value, destType);
    }

    public override PropertyDescriptorCollection GetProperties(ITypeDescriptorContext context, object value, Attribute[] attributes)
    {
        IList collection = value as IList;
        PropertyDescriptorCollection pds = new PropertyDescriptorCollection(null);

        for (int i = 0; i < collection.Count; i++)
        {
            ExpandableCollectionPropertyDescriptor pd = new ExpandableCollectionPropertyDescriptor(collection, i);
            pd.RefreshRequired += (sender, args) =>
            {
                var notifyValueGivenParentMethod = context.GetType().GetMethod("NotifyValueGivenParent", BindingFlags.NonPublic | BindingFlags.Instance);
                notifyValueGivenParentMethod.Invoke(context, new object[] {context.Instance, 1});
            };
            pds.Add(pd);
        }
        // return the property descriptor Collection
        return pds;
    }
}

And I use it for all ILists with the following line:

TypeDescriptor.AddAttributes(typeof (IList), new TypeConverterAttribute(typeof(ExpandableCollectionConverter)));

Some Clarifications

I want the grid to automatically update when I change the list. Refreshing when another property changes, does not help.

A solution that works, is a solution where:

  1. If you expand the list while it is empty, and then add items, the grid is refreshed with the items expanded
  2. If you add items to the list, expand it, and then remove items (without collapsing), the grid is refreshed with the items expanded, and not throwing ArgumentOutOfRangeException because it is trying to show items that were deleted already
  3. I want this whole thing for a configuration utility. Only the PropertyGrid should change the collections

IMPORTANT EDIT:

I did manage to make the expanded collections update with Reflection, and calling NotifyValueGivenParent method on the context object when the PropertyDescriptor GetValue method is called (when RefreshRequired event is raised):

var notifyValueGivenParentMethod = context.GetType().GetMethod("NotifyValueGivenParent", BindingFlags.NonPublic | BindingFlags.Instance);
notifyValueGivenParentMethod.Invoke(context, new object[] {context.Instance, 1});

It works perfectly, except it causes the event to be raised infinite times, because calling NotifyValueGivenParent causes a reload of the PropertyDescriptor, and therfore, raising the event, and so on.

I tried to solve it by adding a simple flag that will prevent the reloading if it is already reloading, but for some reason NotifyValueGivenParent behaves asynchronously, and therefore the reloading happens after the flag is turned off. Maybe it is another direction to explore. The only problem is the recursion

Perfume answered 15/9, 2015 at 9:30 Comment(5)
Why don't you just call TypeDescriptor.AddAttributes(typeof(IList), new TypeConverterAttribute(typeof(ExpandableObjectConverter))); instead of your custom class?Hill
@SimonMourier because then I can't see the items in the collection, but the Capacity and Count propertiesPerfume
This requirement does not appear in your question. BTW, it works for me with an property of ArrayList type. I suppose it depends on the class in the SelectedObject. you should fill your question with all the relevant code and the full question as well.Hill
@SimonMourier I though it was obvious that my intention was to show the items. (I edited the question to state this)Perfume
Hi, you can give the ownerGrid property with reflection instead the notifyParent method, how Steve Medley suggest here link. Working good here.Sunday
H
5

There is no need for using ObservableCollection. You can modify your descriptor class as follows:

public class ExpandableCollectionPropertyDescriptor : PropertyDescriptor
{
    private IList collection;
    private readonly int _index;

    public ExpandableCollectionPropertyDescriptor(IList coll, int idx)
        : base(GetDisplayName(coll, idx), null)
    {
        collection = coll;
        _index = idx;
    }

    private static string GetDisplayName(IList list, int index)
    {
        return "[" + index + "]  " + CSharpName(list[index].GetType());
    }

    private static string CSharpName(Type type)
    {
        var sb = new StringBuilder();
        var name = type.Name;
        if (!type.IsGenericType)
            return name;
        sb.Append(name.Substring(0, name.IndexOf('`')));
        sb.Append("<");
        sb.Append(string.Join(", ", type.GetGenericArguments()
                                        .Select(CSharpName)));
        sb.Append(">");
        return sb.ToString();
    }

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

    public override Type ComponentType
    {
        get { return this.collection.GetType(); }
    }

    public override object GetValue(object component)
    {
        return collection[_index];
    }

    public override bool IsReadOnly
    {
        get { return false; }
    }

    public override string Name
    {
        get { return _index.ToString(CultureInfo.InvariantCulture); }
    }

    public override Type PropertyType
    {
        get { return collection[_index].GetType(); }
    }

    public override void ResetValue(object component)
    {
    }

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

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

Instead of the ExpandableCollectionConverter I would derive the CollectionConverter class, so you can still use the ellipsis button to edit the collection in the old way (so you can add/remove items if the collection is not read-only):

public class ListConverter : CollectionConverter
{
    public override bool GetPropertiesSupported(ITypeDescriptorContext context)
    {
        return true;
    }

    public override PropertyDescriptorCollection GetProperties(ITypeDescriptorContext context, object value, Attribute[] attributes)
    {
        IList list = value as IList;
        if (list == null || list.Count == 0)
        return base.GetProperties(context, value, attributes);

        var items = new PropertyDescriptorCollection(null);
        for (int i = 0; i < list.Count; i++)
        {
            object item = list[i];
            items.Add(new ExpandableCollectionPropertyDescriptor(list, i));
        }
        return items;
    }
}

And I would use this ListConverter on the properties where I want to see expandable list. Of course, you can register the type converter generally as you do in your example, but that overrides everything, which might not be overall intended.

public class MyClass 
{
    [TypeConverter(typeof(ListConverter))]
    public List<int> List { get; set; }

    public MyClass()
    {
        List = new List<int>();
    }

    [RefreshProperties(RefreshProperties.All)]
    [Description("Change this property to regenerate the List")]
    public int Count
    {
        get { return List.Count; }
        set { List = Enumerable.Range(1, value).ToList(); }
    }
}

Important: The RefreshProperties attribute should be defined for the properties that change other properties. In this example, changing the Count replaces the whole list.

Using it as propertyGrid1.SelectedObject = new MyClass(); produces the following result:

enter image description here

Hardenberg answered 25/9, 2015 at 15:36 Comment(2)
This is not what I wanted. I don't want it to refresh when other property refreshes. I want it to refresh when the list is changed. I add items to the list, expand it, add more items, but the items are not updatedPerfume
@AndroidJoker It's funny, that when you remove the set accessor from List property in this example the list of items actually gets updated when you edit the collection. I guess, since the list can be created only once statically, you can take into consideration removing the set accessor.Avow
H
3

I don't want it to refresh when other property refreshes. I want it to refresh when the list is changed. I add items to the list, expand it, add more items, but the items are not updated

This is a typical misuse of PropertyGrid. It is for configuring a component, and not for reflecting the concurrent changes on-the-fly by an external source. Even wrapping the IList into an ObservableCollection will not help you because it is used only by your descriptor, while the external source manipulates directly the underlying IList instance.

What you can still do is an especially ugly hack:

public class ExpandableCollectionPropertyDescriptor : PropertyDescriptor
{
    // Subscribe to this event from the form with the property grid
    public static event EventHandler CollectionChanged;

    // Tuple elements: The owner of the list, the list, the serialized content of the list
    // The reference to the owner is a WeakReference because you cannot tell the
    // PropertyDescriptor that you finished the editing and the collection
    // should be removed from the list.
    // Remark: The references here may survive the property grid's life
    private static List<Tuple<WeakReference, IList, byte[]>> collections;
    private static Timer timer;

    public ExpandableCollectionPropertyDescriptor(ITypeDescriptorContext context, IList collection, ...)
    {
        AddReference(context.Instance, collection);
        // ...
    }

    private static void AddReference(object owner, IList collection)
    {
        // TODO:
        // - serialize the collection into a byte array (BinaryFormatter) and add it to the collections list
        // - if this is the first element, initialize the timer
    }

    private static void Timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        // TODO: Cycle through the collections elements
        // - If WeakReference is not alive, remove the item from the list
        // - Serialize the list again and compare the result to the last serialized content
        // - If there a is difference:
        //   - Update the serialized content
        //   - Invoke the CollectionChanged event. The sender is the owner (WeakReference.Target).
    }
}

Now you can use it like this:

public class Form1 : Form
{
    MyObject myObject = new MyObject();

    public MyForm()
    {
        InitializeComponent();
        ExpandableCollectionPropertyDescriptor.CollectionChanged += CollectionChanged();
        propertyGrid.SelectedObject = myObject;
    }

    private void CollectionChanged(object sender, EventArgs e)
    {
        if (sender == myObject)
            propertyGrid.SelectedObject = myObject;
    }
}

But honestly, I would not use it at all. It has serious flaws:

  • What if a collection element is changed by the PropertyGrid, but the timer has not updated the last external change yet?
  • The implementer of the IList must be serializable
  • Ridiculous performance overhead
  • Though using weak references may reduce memory leaks, it does not help if the objects to edit have longer life cycle than the editor form, because they will remain in the static collection
Hardenberg answered 27/9, 2015 at 13:20 Comment(1)
I don't want it to update concurrently. I want it exactly for configuring. The problem is that when you expand a list, and then change it, from the PropertyGrid the expanded list doesn't update. I added this and a probable solution to the original questionPerfume
C
2

Putting it all together, this works:

Here is the class with the lists that we will put an instance of in our property grid. Also to demonstrate usage with a list of a complex object, I have the NameAgePair class.

public class SettingsStructure
{
    public SettingsStructure()
    {
        //To programmatically add this to properties that implement ILIST for the naming of the edited node and child items:
        //[TypeConverter(typeof(ListConverter))]
        TypeDescriptor.AddAttributes(typeof(IList), new TypeConverterAttribute(typeof(ListConverter)));

        //To programmatically add this to properties that implement ILIST for the refresh and expansion of the edited node
        //[Editor(typeof(CollectionEditorBase), typeof(System.Drawing.Design.UITypeEditor))]
        TypeDescriptor.AddAttributes(typeof(IList), new EditorAttribute(typeof(CollectionEditorBase), typeof(UITypeEditor)));
    }

    public List<string> ListOfStrings { get; set; } = new List<string>();
    public List<string> AnotherListOfStrings { get; set; } = new List<string>();
    public List<int> ListOfInts { get; set; } = new List<int>();
    public List<NameAgePair> ListOfNameAgePairs { get; set; } = new List<NameAgePair>();
}

public class NameAgePair
{
    public string Name { get; set; } = "";
    public int Age { get; set; } = 0;

    public override string ToString()
    {
        return $"{Name} ({Age})";
    }
}

Here is the ListConverter class to handle making the child nodes.

public class ListConverter : CollectionConverter
{
    public override bool GetPropertiesSupported(ITypeDescriptorContext context)
    {
        return true;
    }

    public override PropertyDescriptorCollection GetProperties(ITypeDescriptorContext context, object value, Attribute[] attributes)
    {
        IList list = value as IList;
        if (list == null || list.Count == 0)
            return base.GetProperties(context, value, attributes);

        var items = new PropertyDescriptorCollection(null);
        for (int i = 0; i < list.Count; i++)
        {
            object item = list[i];
            items.Add(new ExpandableCollectionPropertyDescriptor(list, i));
        }
        return items;
    }

    public override object ConvertTo(ITypeDescriptorContext pContext, CultureInfo pCulture, object value, Type pDestinationType)
    {
        if (pDestinationType == typeof(string))
        {
            IList v = value as IList;
            int iCount = (v == null) ? 0 : v.Count;
            return $"({iCount} Items)";
        }
        return base.ConvertTo(pContext, pCulture, value, pDestinationType);
    }
}

Here is the ExpandableCollectionPropertyDescriptor class for the individual items.

public class ExpandableCollectionPropertyDescriptor : PropertyDescriptor
{
    private IList _Collection;
    private readonly int _Index;

    public ExpandableCollectionPropertyDescriptor(IList coll, int idx) : base(GetDisplayName(coll, idx), null)
    {
        _Collection = coll;
        _Index = idx;
    }

    private static string GetDisplayName(IList list, int index)
    {
        return "[" + index + "] " + CSharpName(list[index].GetType());
    }

    private static string CSharpName(Type type)
    {
        var sb = new StringBuilder();
        var name = type.Name;
        if (!type.IsGenericType) return name;
        sb.Append(name.Substring(0, name.IndexOf('`')));
        sb.Append("<");
        sb.Append(string.Join(", ", type.GetGenericArguments().Select(CSharpName)));
        sb.Append(">");
        return sb.ToString();
    }

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

    public override Type ComponentType
    {
        get { return this._Collection.GetType(); }
    }

    public override object GetValue(object component)
    {
        return _Collection[_Index];
    }

    public override bool IsReadOnly
    {
        get { return false; }
    }

    public override string Name
    {
        get { return _Index.ToString(CultureInfo.InvariantCulture); }
    }

    public override Type PropertyType
    {
        get { return _Collection[_Index].GetType(); }
    }

    public override void ResetValue(object component)
    {
    }

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

    public override void SetValue(object component, object value)
    {
        _Collection[_Index] = value;
    }
}

And then the CollectionEditorBase class for refreshing the property grid after the collection editor is closed.

public class CollectionEditorBase : CollectionEditor
{
    protected PropertyGrid _PropertyGrid;
    private bool _ExpandedBefore;
    private int _CountBefore;

    public CollectionEditorBase(Type type) : base(type) { }

    public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
    {
        //Record entry state of property grid item
        GridItem giThis = (GridItem)provider;
        _ExpandedBefore = giThis.Expanded;
        _CountBefore = (giThis.Value as IList).Count;

        //Get the grid so later we can refresh it on close of editor
        PropertyInfo piOwnerGrid = provider.GetType().GetProperty("OwnerGrid", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
        _PropertyGrid = (PropertyGrid)piOwnerGrid.GetValue(provider);

        //Edit the collection
        return base.EditValue(context, provider, value);
    }

    protected override CollectionForm CreateCollectionForm()
    {
        CollectionForm cf = base.CreateCollectionForm();
        cf.FormClosing += delegate (object sender, FormClosingEventArgs e)
        {
            _PropertyGrid.Refresh();
            //Because nothing changes which grid item is the selected one, expand as desired
            if (_ExpandedBefore || _CountBefore == 0) _PropertyGrid.SelectedGridItem.Expanded = true; 
        };
        return cf;
    }

    protected override object CreateInstance(Type itemType)
    {
        //Fixes the "Constructor on type 'System.String' not found." when it is an empty list of strings
        if (itemType == typeof(string)) return string.Empty;
        else return Activator.CreateInstance(itemType);
    }
}

Now the usage produces:

List nodes expanded

And performing various operations produces:

enter image description here

You can tweak it to operate like you like.

Chamness answered 20/10, 2020 at 19:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.