ObservableCollection element-wise Transform/Projection Wrapper
Asked Answered
D

1

16

When creating ViewModels in WPF it's sometimes necessary to transform data that is available in an ObservableCollection (the source collection) into a collection of wrapper elements that extend/restrict/project the original elements (the target collection), while the number and order of the elements always mirror the original collection.

Just like the Select extension method, except that it is continuously updated and can therefore be used for WPF bindings.

If an element is added to the source at index x, the Wrapper of the same element is added at the same index x in the target collection. If the element at index y is removed in the source collection, the element at index y is removed in the target collection.

Say there is an ObservableCollection<ClassA>, but what I need to bind to is an ReadOnlyObservableCollection<ClassB> (or equivalent), where ClassB -> ClassA as follows:

class ClassB : INotifyPropertyChanged, IDisposable
{
    public ClassB(ClassA a)
    {
        Wrapped = a;
        (Wrapped as INotifyPropertyChanged).PropertyChanged+=WrappedChanged;
    }
    public ClassA Wrapped { get; private set; }
    public int SomeOtherProperty { get { return SomeFunction(Wrapped); }
    WrappedChanged(object s, NotifyPropertyChangedArgs a) { ... }
    ...
}

I can write my own TemplatedTransformCollectionWrapper, where I can write this:

ObservableCollection<ClassA> source;
TemplatedTransformCollectionWrapper theCollectionThatWillBeUsedInABinding
    = TemplatedTransformCollectionWrapper(source, classA => new ClassB(classA));

TemplatedTransformCollectionWrapper ideally wraps all collections that implement INotifyCollectionChanged and correctly handles all possible add, remove, replace operations of the original, wrapped, collection.

It's not trivial to write TemplatedTransformCollectionWrapper correctly and it seems to be the kind of thing that someone else has already done, maybe it's even part of the core framework. But I can't find it.

Demirelief answered 17/12, 2012 at 19:21 Comment(10)
I resolved this by using an IEnumerable<System.Dynamic.DynamicObject> that basically Proxies all Property gets and sets to the underlying model.Nettle
Its a long story. That alone took me a couple of weeks to design, but I finally got a generic ViewModel that can be used to wrap any Model type and react to property changes from the UI and from code. Take a look at this deanchalk.com/post/…Nettle
Thanks. I think this is the answer to a related but not the same problem. In my question I ask about the collection (item added, item removed, item changed), while your post seems to cover wrapping the individual objects in the collection; in case of the question it covers what 'ClassB' is doing.Demirelief
I don't understand your point completely. If its a read only collection, how can items be added to it? why not just var newcollection = new ReadOnlyCollection(col.Select(x => new ClassB(x));Nettle
The ReadOnlyObservableCollection[msdn.microsoft.com/en-us/library/ms668620.aspx] wraps an ObservableCollection and notifies it's subscribers of any changes that happen in the source ObservableCollection.Demirelief
It is still not clear to me what you need to achieve. Why not just var readonly col = new ReadOnlyObservableCollection<TItems>(yourCol.Select(x => new TNewItem(){Item = x}));Nettle
Because afterwards doing a yourCol.Add(something) would not update the target collection.Demirelief
I still don't understand what you want this for. Why not use a CollectionView?Nettle
CollectionView supports filter and sort, but I do not see how it supports element-wise transform. If it does support transform and I missed how, then that would be the answer to my question. Updated the question to make it clearer that the target collection needs to mirror the source even after the source updates.Demirelief
You're right. It doesn't. Thats a missing feature of my framework as well. Once I wrap my models into my Dynamic ViewModels, I am no longer able to modify the original Model collection, because these changes will not be reflected in the UI.Nettle
D
6

I'm posting my workaround - which is a custom class - here. Still hoping for better answers.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;

namespace ViewLayer
{
    public class TransformObservableCollection<T,Source> : INotifyCollectionChanged, IList, IReadOnlyList<T>, IDisposable
    {
        public TransformObservableCollection(ObservableCollection<Source> wrappedCollection, Func<Source,T> transform)
        {
            m_WrappedCollection = wrappedCollection;
            m_TransformFunc = transform;
            ((INotifyCollectionChanged)m_WrappedCollection).CollectionChanged += TransformObservableCollection_CollectionChanged;
            m_TransformedCollection = new ObservableCollection<T>(m_WrappedCollection.Select(m_TransformFunc));
        }
        public void Dispose()
        {
            if (m_WrappedCollection == null) return;
            ((INotifyCollectionChanged)m_WrappedCollection).CollectionChanged -= TransformObservableCollection_CollectionChanged;
            m_WrappedCollection = null;
        }
        void TransformObservableCollection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    if (e.NewItems == null || e.NewItems.Count != 1)
                        break;
                    m_TransformedCollection.Insert(e.NewStartingIndex,m_TransformFunc((Source)e.NewItems[0]));
                    return;
                case NotifyCollectionChangedAction.Move:
                    if (e.NewItems == null || e.NewItems.Count != 1 || e.OldItems == null || e.OldItems.Count != 1)
                        break;
                    m_TransformedCollection.Move(e.OldStartingIndex, e.NewStartingIndex);
                    return;
                case NotifyCollectionChangedAction.Remove:
                    if (e.OldItems == null || e.OldItems.Count != 1)
                        break;
                    m_TransformedCollection.RemoveAt(e.OldStartingIndex);
                    return;
                case NotifyCollectionChangedAction.Replace:
                    if (e.NewItems == null || e.NewItems.Count != 1 || e.OldItems == null || e.OldItems.Count != 1 || e.OldStartingIndex != e.NewStartingIndex)
                        break;
                    m_TransformedCollection[e.OldStartingIndex] = m_TransformFunc((Source)e.NewItems[0]);
                    return;
            } // This  is most likely called on a Clear(), we don't optimize the other cases (yet)
            m_TransformedCollection.Clear();
            foreach (var item in m_WrappedCollection)
                m_TransformedCollection.Add(m_TransformFunc(item));
        }

        #region IList Edit functions that are unsupported because this collection is read only
        public int Add(object value) { throw new InvalidOperationException(); }
        public void Clear() { throw new InvalidOperationException(); }
        public void Insert(int index, object value) { throw new InvalidOperationException(); }
        public void Remove(object value) { throw new InvalidOperationException(); }
        public void RemoveAt(int index) { throw new InvalidOperationException(); }
        #endregion IList Edit functions that are unsupported because this collection is read only

        #region Accessors
        public T this[int index] { get { return m_TransformedCollection[index]; } }
        object IList.this[int index] { get { return m_TransformedCollection[index]; } set { throw new InvalidOperationException(); } }
        public bool Contains(T value) { return m_TransformedCollection.Contains(value); }
        bool IList.Contains(object value) { return ((IList)m_TransformedCollection).Contains(value); }
        public int IndexOf(T value) { return m_TransformedCollection.IndexOf(value); }
        int IList.IndexOf(object value) { return ((IList)m_TransformedCollection).IndexOf(value); }
        public int Count { get { return m_TransformedCollection.Count; } }
        public IEnumerator<T> GetEnumerator() { return m_TransformedCollection.GetEnumerator(); }
        IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable)m_TransformedCollection).GetEnumerator(); }
        #endregion Accessors

        public bool IsFixedSize { get { return false; } }
        public bool IsReadOnly { get { return true; } }
        public void CopyTo(Array array, int index) { ((IList)m_TransformedCollection).CopyTo(array, index); }
        public void CopyTo(T[] array, int index) { m_TransformedCollection.CopyTo(array, index); }
        public bool IsSynchronized { get { return false; } }
        public object SyncRoot { get { return m_TransformedCollection; } }

        ObservableCollection<T> m_TransformedCollection;
        ObservableCollection<Source> m_WrappedCollection;
        Func<Source, T> m_TransformFunc;

        event NotifyCollectionChangedEventHandler INotifyCollectionChanged.CollectionChanged
        {
            add { ((INotifyCollectionChanged)m_TransformedCollection).CollectionChanged += value; }
            remove { ((INotifyCollectionChanged)m_TransformedCollection).CollectionChanged -= value; }
        }
    }
}
Demirelief answered 18/12, 2012 at 12:26 Comment(11)
I still don't see the need to do this. If you want something to be read-only, don't write code that modifies it, and you're done. I don't see how it makes sense to you to expose a read only collection that wraps a non read only one. Unless you're letting your UI elements manipulate the bound collection by adding or removing items from it, which is something I recommend against.Nettle
Not sure if I get you right, but you seem to refer to 2 very different issues. First is the need for a collection that mirrors another collection but does an element-by-element transform, second is the fact that I make a collection that would break if the user changed it directly read only. The reason for the first is that in many cases in MVVM where the Models expose containers of items, an element-by-element projection of those containers is required in the View Model.Demirelief
Yep I understand the first, but the second?Nettle
The ViewModel is an API towards the View, the xaml part of the View is sometimes written by a different developer/designer than the ViewModel. APIs should be easy to use right and hard to use wrong, which is why an API should not expose an ObservableCollection if a ReadOnlyObservableCollection - which is more limiting - is sufficient. If an ObservableCollection is used where a ReadOnlyObservableCollection can be used, someone might bind it to a DataGrid, and leave the default for CanUserAddRows, which is true. In many companies, 'might' can be replaced with 'will'.Demirelief
Wow.... I'd love to be in a project where there are designers working in XAML / WPF =)Nettle
I wrote a very similar class yesterday and popped on here to see if there were other implementations. Nice work. Have you run into any issues using this? I noticed that you are not dispatching any of the collection modification methods - are you only adding to the original collection from the UI thread?Klinger
@ach correct. It's not thread-safe, as it assumes the original collection lives on the UI thread. I agree that it could be a useful extension to add dispatching to this class, but keep in mind that this would not make changes to the items inside the collection thread-safe.Demirelief
@HighCore: As I've repeatedly run into the same situation, another example of where such a read-only wrapper is needed: I often have lists of items in my M. The VMs add some information to each of the items that is required by the V, but must not be part of the M (as it is entirely view-related). So, the items themselves are writeable; the additional information (in a wrapper around each item) is supplied by the VM logic. As I do not want anyone outside of the VM to write any bogus data into the wrapper, the list of wrapped items must be read-only, reflecting the items from the writeable list.Mind
Thinking about this, are all the methods really necessary? Wouldn't a class hooking into the CollectionChanged event of the wrapped collection and just implementing INotifyCollectionChanged suffice, without all the other list accessor methods?Mind
IList is necessary because there needs to be a way to access the elements in the collection, especially if the collection starts non-empty. It might work with IEnumerable instead, which would probably reduce performance. 'IReadOnlyList' could probably be skipped - I guess it's here because I come from a C++ background where const correctness is a big thing. IDisposable is necessary to avoid a leak if the lifetime of the Model is longer than that of a ViewModel, which can happen if the ViewModel gets destroyed if there are no more Views using it and recreated if a new View needs it.Demirelief
You could get rid of the IDisposable pattern by using a weak event listener pattern.Parthena

© 2022 - 2024 — McMap. All rights reserved.