jump to navigation

AggregateCollection March 31, 2007

Posted by Karl Hulme in .Net, C#, WPF, XAML.
6 comments

This blog is no longer actively maintained. The content is well over 5 years old – which is like 50 coder years. Use at your own risk!

My apparent obsession with collection classes continues, and hopefully reaches it’s conclusion, with the AggregateCollection class.  Simply put, it allows you to access multiple collections from a single collection object.  A basic usage would be something like…

        private void TestAggregate()
        {
            List<Int32> listOne = new List<Int32>();
            List<String> listTwo = new List<String>();
            for (int i = 0; i < 10; i += 2)
            {
                listOne.Add(i + 1);
                listTwo.Add(i.ToString());
            }
 
            AggregateCollection agg = new AggregateCollection(listOne, listTwo);
            foreach (Object o in agg)
            {
                Console.WriteLine(o.ToString());
            }
        }

And the output is as you might expect…

The lists passed to the constructor need only support IEnumerable, but if they also support INotifyCollectionChanged then you’ll get a better WPF binding experience.  I wrote this class for WPF in the first place, because I want to be able to pull information together from different lists into the UI and I don’t want to be monitoring for the change events myself.

The AggregateCollection class itself supports the IEnumerable interface of course, and it also implements IList (which thus brings ICollection into the fray).  The fact that it implements IList, means that even if the constituent lists don’t support IList, you can still access all the objects in the aggregate collection via a single index (from 0 to “count of all the lists”).  I think this is quite cool, but it serves a greater purpose…

When you bind to a collection class in WPF, a CollectionView (or derivative) is created as a wrapper around your collection, and WPF will bind to that instead.  This allows items to be sorted, grouped, etc, without affecting the underlying data.  Bea Costa (data binding supremo) explains this very well (post November 22nd ’06).  The AggregateCollection class supports IList, so that at binding-time WPF will instantiate a ListCollectionView, instead of a CollectionView – which is necessary to support sorting.

The example app below demonstrates the AggregateCollection class in action in WPF, and shows how, with sorting, this can be helpful.  The first and second columns show the contents of two seperate ObservableCollections.  There’s also some buttons for adding and removing objects from those collections, and for moving those items around.  The ListBox in the third column is bound to an AggregateCollection containing the first 2 lists.  The ListBox in the fourth column is bound to a sorted CollectionViewSource which references the same AggregateCollection.  As the items in the first two collections change the ListBoxes in columns 3 and 4 will update automatically.

Example application exe  (save to your local machine first, then run)
Example application solution

The AggregateCollection class can’t be synchronised, because I only intended for it to be used in the UI layer, but I guess there would be a way to make this work.  I also made it read-only because I couldn’t determine a satisfactory strategy for which constituent list a new item should be added to.  Finally, you can’t add or remove constituent lists after it’s been created, but there’s no fundamental obstacle to implementing this.  The code for the AggregateCollection class is below.

using System;
using System.Collections.Generic;
using System.Text;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Windows.Threading;
using System.Collections;
using System.Collections.ObjectModel;
 
namespace AggCollectionTest
{
    public class AggregateCollection : IEnumerable, IList, INotifyCollectionChanged
    {
        private IEnumerable[] lists;
        private List<Object> cache = new List<Object>();
        private Boolean cacheValid = false;
        private Object syncRoot = new Object();
 
        public AggregateCollection(params IEnumerable[] lists)
        {
            if (lists == null) throw new ArgumentNullException("lists");
 
            this.lists = lists;
            foreach (IEnumerable list in lists)
            {
                if (list is INotifyCollectionChanged)
                {
                    ((INotifyCollectionChanged)list).CollectionChanged +=
                        delegate(Object sender, NotifyCollectionChangedEventArgs e)
                        {
                            cacheValid = false;
                            NotifyCollectionChangedEventArgs eventArgs;
                            switch (e.Action)
                            {
                                case NotifyCollectionChangedAction.Add:
                                    eventArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add,
                                        e.NewItems, DetermineIndexInAggregateCollection(sender as IEnumerable, e.NewStartingIndex));
                                    break;
                                case NotifyCollectionChangedAction.Move:
                                    eventArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move,
                                        e.NewItems, DetermineIndexInAggregateCollection(sender as IEnumerable, e.NewStartingIndex),
                                        DetermineIndexInAggregateCollection(sender as IEnumerable, e.OldStartingIndex));
                                    break;
                                case NotifyCollectionChangedAction.Remove:
                                    eventArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove,
                                        e.OldItems, DetermineIndexInAggregateCollection(sender as IEnumerable, e.OldStartingIndex));
                                    break;
                                case NotifyCollectionChangedAction.Replace:
                                    eventArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace,
                                        e.NewItems, e.OldItems, DetermineIndexInAggregateCollection(sender as IEnumerable, e.OldStartingIndex));
                                    break;
                                default:
                                case NotifyCollectionChangedAction.Reset:
                                    eventArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset);
                                    break;
                            }
 
                            OnCollectionChanged(eventArgs);
                        };
                }
            }
        }
 
        private int DetermineIndexInAggregateCollection(IEnumerable list, int index)
        {
            if(list == null) throw new ArgumentNullException("list");
            if (index == -1) return -1;
 
            Int32 adjustedIndex = -1;
            for (int i = 0; i < lists.Length; i++)
            {
                IEnumerable l = lists[i];
                if (l == list) { break; }
                    else adjustedIndex++;
 
                foreach (Object o in l)
                    adjustedIndex++;
            }
 
            return list == lists[0] ? adjustedIndex + index + 1 :
                adjustedIndex + index;
        }
 
        #region IEnumerable Members
 
        public IEnumerator GetEnumerator()
        {
            return new Enumerator(lists);
        }
 
        private class Enumerator : IEnumerator
        {
            private Int32 listIndex = -1;
            private IEnumerator[] enumerators;
 
            public Enumerator(IEnumerable[] lists)
            {
                enumerators = new IEnumerator[lists.Length];
 
                for (int i = 0; i < lists.Length; i++) 
                    enumerators[i] = lists[i].GetEnumerator();
            }
 
            public object Current
            {
                get
                {
                    try
                    {
                        return enumerators[listIndex].Current;
                    }
                    catch (IndexOutOfRangeException)
                    {
                        throw new InvalidOperationException();
                    }
                }
            }
 
            public bool MoveNext()
            {
 
                if (listIndex > -1 && listIndex < enumerators.Length && 
                    enumerators[listIndex].MoveNext())
                {
                    return true;
                }
                else
                {
                    listIndex++;
                    if (listIndex < enumerators.Length)
                    {
                        try
                        {
                            enumerators[listIndex].Reset();
                        }
                        catch (InvalidOperationException)
                        {
                            return false;
                        }
 
                        return MoveNext();
                    }
                    else return false;
                }
            }
 
            public void Reset()
            {
                listIndex = -1;
            }
        }
 
        #endregion
 
        #region INotifyCollectionChanged Members
 
        protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            if (e == null) throw new ArgumentNullException("e");
 
            if (CollectionChanged != null) 
                CollectionChanged(this, e);
        }
 
        public event NotifyCollectionChangedEventHandler CollectionChanged;
 
        #endregion
 
        #region IList Members
 
        public int Add(object value)
        {
            throw new NotSupportedException();
        }
 
        public void Clear()
        {
            throw new NotSupportedException();
        }
 
        public bool Contains(object value)
        {
            return IndexOf(value) != -1;
        }
 
        public int IndexOf(object value)
        {
            Int32 i = -1;
            foreach (Object o in this)
            {
                i++;
                if ((value == null && o == null) | (value != null && value.Equals(o)))
                {
                    return i;
                }
            }
 
            return -1;
        }
 
        public void Insert(int index, object value)
        {
            throw new NotSupportedException();
        }
 
        public bool IsFixedSize
        {
            get { return false; }
        }
 
        public bool IsReadOnly
        {
            get { return true; }
        }
 
        public void Remove(object value)
        {
            throw new NotSupportedException();
        }
 
        public void RemoveAt(int index)
        {
            throw new NotSupportedException();
        }
 
        public object this[int index]
        {
            get
            {
                if (!cacheValid)
                {
                    cache.Clear();
                    foreach (Object o in this)
                    {
                        cache.Add(o);
                    }
                    cacheValid = true;
                }
 
                return cache[index];
            }
            set
            {
                throw new NotSupportedException();
            }
        }
 
        #endregion
 
        #region ICollection Members
 
        public void CopyTo(Array array, int index)
        {
            Int32 i = -1;
            foreach (Object o in this)
                array.SetValue(o, index + ++i);
        }
 
        public int Count
        {
            get
            {
                Int32 count = 0;
                foreach (Object o in this) count++;
                return count;
            }
        }
 
        public bool IsSynchronized
        {
            get { return false; }
        }
 
        public object SyncRoot
        {
            get { return syncRoot; }
        }
 
        #endregion
    }
}

Using a ContentControl and DataTemplate to indicate new and/or modified data March 6, 2007

Posted by Karl Hulme in .Net, C#, WPF, XAML.
13 comments

This blog is no longer actively maintained. The content is well over 5 years old – which is like 50 coder years. Use at your own risk!

I was familiar with using DataTemplate’s for multiple items, say for items in a ListBox, but what if you want to use a DataTemplate for a single item? 

Why would I want to do that?  I can declare the UIElements I want, and bind the properties that I want.  You don’t need a DataTemplate here!!

Well, not strictly true!  If you want to be able to use properties of your business object within conditions that control the layout, then you’re going to need the services of DataTrigger’s. And although most UIElements have a Triggers property, DataTriggers are only supported on a DataTemplate.  So this blog entry is an example of how to set that up…

 

Think about Visual Studio tabs for a second.  Whenever you change the content of a document, a small asterisk appears after the filename to indicate to you that it’s been changed, as illustrated below.

I wanted to do a similar thing in my application.  I wanted to..

  • Show an asterisk whenever the data has been changed since the last save, and
  • Show if an item was new (that is has never been saved.

For me, the second point is particularly important because my application deals with Customer entities, and until the customer is saved they don’t have the all-important customer number.

The first step is to add the required properties to the business object being bound to, and implement INotifyPropertyChanged so that we know when they’re changing.  A heavily abridged/shell-like version of my Entity class is below.  (Please note, the code below is only intended to highlight the need to define the properties and inplement INotifyPropertyChanged on the business object in use.)

using System; using System.Collections.Generic; using System.Text; using System.Xml; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; namespace MyNamespace { /// <summary> /// Represents an entity loaded from the database. /// </summary> public class Entity: INotifyPropertyChanged { #region properties private Int32? id; /// <summary> /// Gets the unique id of the entity. /// </summary> public Int32? Id { get { return id; } internal set { id = value; OnPropertyChanged("Id"); OnPropertyChanged("IsNew"); } } private EntityDefinition definition; /// <summary> /// Gets the type of the entity. /// </summary> public EntityDefinition Definition { get { return definition; } } /// <summary> /// Gets a value that indicates if the entity has been modified /// since it was loaded. /// </summary> public Boolean IsModified { get { return changeHistory.Count > 0; } } private EntityDataFieldChangeCollection changeHistory = new EntityDataFieldChangeCollection(); public EntityDataFieldChangeCollection ChangeHistory { get { return changeHistory; } } #endregion #region INotifyPropertyChanged public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged(String propertyName) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } #endregion } }

Then in XAML, define a ContentControl and set the Content of that control to be an instance of your business object.  Clearly the ContentControl doesn’t know how to render said business object, so I’ve provided a DataTemplate.  This DataTemplate uses a horizontal StackPanel to lay out a series of TextBlock elements.  I then use the Triggers to turn the visibility on and off, dependent upon the IsNew and IsModified properties. 

<ContentControl Grid.Row="0" Content="{Binding Source={StaticResource entity}}"> <ContentControl.ContentTemplate> <DataTemplate> <StackPanel Orientation="Horizontal"> <TextBlock Name="newBlock" Text="New " Visibility="Visible"/> <TextBlock Name="nameBlock" Text="{Binding Path=Definition.Name}" Visibility="Visible"/> <TextBlock Name="spacer" Text=" " Visibility="Collapsed"/> <TextBlock Name="idBlock" Text="{Binding Path=Id}" Visibility="Collapsed" /> <TextBlock Name="modifiedBlock" Text="*" Visibility="Collapsed"/> </StackPanel> <DataTemplate.Triggers> <DataTrigger Binding="{Binding Path=IsNew}" Value="False"> <DataTrigger.Setters> <Setter TargetName="newBlock" Property="UIElement.Visibility" Value="Collapsed" /> <Setter TargetName="idBlock" Property="UIElement.Visibility" Value="Visible" /> <Setter TargetName="spacer" Property="UIElement.Visibility" Value="Visible" /> </DataTrigger.Setters> </DataTrigger> <DataTrigger Binding="{Binding Path=IsModified}" Value="True"> <DataTrigger.Setters> <Setter TargetName="modifiedBlock" Property="UIElement.Visibility" Value="Visible" /> </DataTrigger.Setters> </DataTrigger> </DataTemplate.Triggers> </DataTemplate> </ContentControl.ContentTemplate> </ContentControl>

So the tab headers throughout the life of one of my Customer entities would start out as follows…

Then, I would expect something to be changed…

Once the first set of changes can be made, it should be saved.  This will result in an Id being returned from the database…

Obviously the data could be modified again at this point…

I originally implemented this feature using IValueConverters (and the like) but I believe the XAML solution is much nicer. 

So if you need to display information related to a business object and you have some conditions associated with the display, then you could try using a data-templated content control.

Masking input to a WPF TextBox February 15, 2007

Posted by Karl Hulme in .Net, C#, WPF, XAML.
20 comments

This blog is no longer actively maintained. The content is well over 5 years old – which is like 50 coder years. Use at your own risk!

How do you go about masking the input to a textbox.  Say you wanted to allow numbers but not letters or any punctuation, how would you do it?  Well the simplest way is to trap the TextChanged event…

        private void TextChangedEventHandler(Object sender, EventArgs e) 
        { 
            TextBox textBox = sender as TextBox; 
            Int32 selectionStart = textBox.SelectionStart; 
            Int32 selectionLength = textBox.SelectionLength; 

            String newText = String.Empty; 
            foreach (Char c in textBox.Text.ToCharArray()) 
            { 
                if(Char.IsDigit(c) || Char.IsControl(c)) newText += c; 
            } 

            textBox.Text = newText; 

            textBox.SelectionStart = selectionStart <= textBox.Text.Length ? 
                selectionStart : textBox.Text.Length; 
        }

This method is simple to understand (always a positive) but there are a few problems with this as I see it, namely..

  • An additional TextChanged event is raised when input is rejected
  • You can’t tell what the change was, you can only inspect the end result
  • You have to re-implement the code that determines the correct caret position

This is not a great way to solve the original problem, because we’re basically hacking at the input after it’s already been accepted.  Leaving us trying to fix the text and compensate for the changes in caret position.  It’s easier to reject invalid characters as they’re presented.  Let’s start by defining a very simple method for stripping out the input we don’t want..  

        private Boolean IsTextAllowed(String text) 
        { 
            foreach (Char c in text.ToCharArray()) 
            { 
                if (Char.IsDigit(c) || Char.IsControl(c)) continue; 
                    else return false; 
            } 
            return true; 
        }

This could be defined slightly more elegantly using a predicate..

        private Boolean IsTextAllowed(String text) 
        { 
            return Array.TrueForAll<Char>(text.ToCharArray(), 
                delegate(Char c) { return Char.IsDigit(c) || Char.IsControl(c); }); 
        }

We then need to identify the entry points to the control and for each one, identify how we will be notified about it’s use.  For typical a TextBox these entry points might be:

  • Typing into the TextBox
    • Hook up to the PreviewTextInput event
  • Copy/Pasting into the TextBox
    • Hook up to the DataObject.Pasting event

It’s easy to implement and hook up the handlers for these events..

      <TextBox PreviewTextInput="PreviewTextInputHandler" 
               DataObject.Pasting="PastingHandler" />

        // Use the PreviewTextInputHandler to respond to key presses 
        private void PreviewTextInputHandler(Object sender, TextCompositionEventArgs e) 
        { 
            e.Handled = !IsTextAllowed(e.Text); 
        } 

        // Use the DataObject.Pasting Handler  
        private void PastingHandler(object sender, DataObjectPastingEventArgs e) 
        { 
            if (e.DataObject.GetDataPresent(typeof(String))) 
            { 
                String text = (String)e.DataObject.GetData(typeof(String)); 
                if (!IsTextAllowed(text)) e.CancelCommand(); 
            } 
            else e.CancelCommand(); 
        }

In some cases, you may need to support other scenarios (such as allowing users to Drag-and-Drop text onto it) and these can be dealt with in a similar fashion – identify an appropriate event, check the input, and if it’s invalid then prevent any further processing.

I hope this information serves as a starting point for implementing a masked textbox.  That is, display an actual mask (using underlines) and rigidly control the input of the user.  One immediate change is that you’d look to subclass TextBox (or base class) and override the protected handlers (e.g. OnPreviewTextInput) rather than hook up directly to the events.  The underlines are also important because without them the user has little clue what is expected of them.  Infact, in a basic validation scenario I think it better to accept dodgy input and then provide UI cues as to why it isn’t acceptable.  I’m holding off doing this myself just in case it turns up in the standard library, perhaps when Orcas ships?  If it doesn’t, I’ll post animplementation because my project needs one.