Monday, April 16, 2007

Generic Grid with column autodetection

There are a lot of questions such as "how can I generate columns in my grid, based on my XML data?", how to implement generic sort algorithm?" etc. In this post I'll try to explain how to use ListView with GridView, how to sort your data presentation, without sorting data source and lost binding. How to parse generic Excel or CSV data and put it into grid. 

So, first thing, you should do is to create generic ListView with Grid as view. We'll put it into UserControl in order us to be able to use it externally. This part is really simple.

 

<ListView Name="myListView" >
        <ListView.View>
          <GridView x:Name="myGridView"/>
        </ListView.View>
</ListView>

Now, we'll create dependency property for our data source. We'll have to handle the assignment of data source in order to great columns dynamically within the GridView


 



public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(MyGrid),
            new PropertyMetadata(null, new PropertyChangedCallback(OnItemsSourceChanged)));
 
        static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue == null)
                return;
 
            MyGrid grid = d as MyGrid;
 
            grid.myListView.SetValue(ItemsControl.ItemsSourceProperty, e.NewValue);
 
            ReadOnlyObservableCollection<XmlNode> col = e.NewValue as ReadOnlyObservableCollection<XmlNode>;
            if (col != null)
            {
                grid.myGridView.Columns.Clear();
                if (col.Count > 0)
                {
                    foreach (XmlNode node in col[0].ChildNodes)
                    {
                        bindGridViewColumn(node, grid.myGridView);
                    }
                    foreach (XmlAttribute attr in col[0].Attributes)
                    {
                        bindGridViewColumn(attr, grid.myGridView);
                    }
                }
                else
                {
                    throw new NotSupportedException("No description row found in data provided. You should have at least one row to populate control columns");
                }
            }
            else
            {
                throw new NotImplementedException("This patch is working only for XmlSource by now");
            }
 
        }

So, what are we doing here? First of all, if we have null or unsupported data as datasource we'll do nothing. Then, if the data is valid, we'll clear all old columns and create new set, based on data passed. We'll look into all child nodes of DocumentRoot in order to build markup. The next step is to bind each column data of our data source to the column and then bind all data to datasource of the grid. We'll do it either for elements and attributes of our XML. Here is comes


 



static internal void bindGridViewColumn(XmlNode node, GridView view)
        {
            bindGridViewColumn(node.Name, view);
        }
static internal void bindGridViewColumn(XmlAttribute attr, GridView view)
        {
            bindGridViewColumn("@"+attr.Name, view);
        }
static void bindGridViewColumn(string XPath, GridView view)
        {
            GridViewColumn gvc = new GridViewColumn();
            gvc.Header = XPath[0]=='@'?XPath.Substring(1):XPath;
            Binding b = new Binding();
            b.XPath = XPath;
            gvc.DisplayMemberBinding = b;
            view.Columns.Add(gvc);
        }

Simple setter and getter for DP and we done.


 



public IEnumerable ItemsSource
        {
            get
            {
                GetValue(ItemsSourceProperty);
                return (IEnumerable)myGridView.GetValue(ItemsControl.ItemsSourceProperty);
            }
            set
            {
                SetValue(ItemsSourceProperty, value);
                myGridView.SetValue(ItemsControl.ItemsSourceProperty, value);
            }
        }

Now, let's take care on data sorting. We'll handle click on grig view column header as trigger for sort so, GridViewColumnHeader.Click="onSort". Now, let's handle it. The code is rather straight forward, so I'll now going to explain it


 



GridViewColumnHeader m_lastHeaderClicked = null;
        ListSortDirection m_lastDirection = ListSortDirection.Ascending;
 
        void onSort(object sender, RoutedEventArgs e)
        {
            GridViewColumnHeader headerClicked = e.OriginalSource as GridViewColumnHeader;
            ListSortDirection direction;
 
            if (headerClicked != null)
            {
                if (headerClicked.Role != GridViewColumnHeaderRole.Padding)
                {
                    if (headerClicked != m_lastHeaderClicked)
                    {
                        direction = ListSortDirection.Ascending;
                    }
                    else
                    {
                        if (m_lastDirection == ListSortDirection.Ascending)
                        {
                            direction = ListSortDirection.Descending;
                        }
                        else
                        {
                            direction = ListSortDirection.Ascending;
                        }
                    }
 
                    string header = headerClicked.Column.Header as string;
                    Sort(header, direction);
 
                    if (direction == ListSortDirection.Ascending)
                    {
                        headerClicked.Column.HeaderTemplate =
                          Resources["HeaderTemplateArrowUp"] as DataTemplate;
                    }
                    else
                    {
                        headerClicked.Column.HeaderTemplate =
                          Resources["HeaderTemplateArrowDown"] as DataTemplate;
                    }
 
                    m_lastHeaderClicked = headerClicked;
                    m_lastDirection = direction;
                }
            }
        }
 
void Sort(string sortBy, ListSortDirection direction)
        {
            ICollectionView dataView = CollectionViewSource.GetDefaultView(myListView.ItemsSource);
 
            dataView.SortDescriptions.Clear();
            SortDescription sd = new SortDescription(sortBy, direction);
            dataView.SortDescriptions.Add(sd);
 
            dataView.Refresh();
        }

That's it, we done. The next step is to create control to handle drag and drop of Excel data and convert it into XML data source for our smart grid view. We'll inherit from Canvas control and implement INotifyPropertyChanged in order to notify about changes in datasource and providing an ability to bind data between controls. So, first of all, let's create a couple of DPs to provide interface for dropped object and dropped data.


 



    

    class ExcelDataReader:Canvas,INotifyPropertyChanged
    {
        public ExcelDataReader():base()
        {
            this.AllowDrop = true;
        }
 
        
public static DependencyPropertyKey DroppedObjectPropertyKey = 
            DependencyProperty.RegisterReadOnly("DroppedObject", 
            typeof(ReadOnlyObservableCollection<XmlNode>), 
            typeof(ExcelDataReader),
            new PropertyMetadata(null));
 
 
        public static readonly DependencyProperty DroppedObjectProperty = DroppedObjectPropertyKey.DependencyProperty;
 
        public ReadOnlyObservableCollection<XmlNode> DroppedObject
        {
            get { return (ReadOnlyObservableCollection<XmlNode>)GetValue(DroppedObjectProperty); }
        }
 
        
public static DependencyPropertyKey DroppedDocumentPropertyKey =
    DependencyProperty.RegisterReadOnly("DroppedDocument",
    typeof(XmlDocument),
    typeof(ExcelDataReader),
    new PropertyMetadata(null));
 
        public static readonly DependencyProperty DroppedDocumentProperty = DroppedDocumentPropertyKey.DependencyProperty;
 
        public XmlDocument DroppedDocument
        {
            get { return (XmlDocument)GetValue(DroppedDocumentProperty); }
        }

The next step is to handle preview of drop event. Why preview? In order to leave future developers to add custom logic to drop event of this control


 



protected override void OnPreviewDrop(System.Windows.DragEventArgs e)
        {  
 
            if (e.Data.GetDataPresent(DataFormats.CommaSeparatedValue))
            {
                List<string> strs = new List<string>();
                using (StreamReader sr = new StreamReader((Stream)e.Data.GetData(DataFormats.CommaSeparatedValue)))
                {
                    while (sr.Peek() > 0)
                    {
                        strs.Add(sr.ReadLine());
                    }
                }
 
                SetDroppedObject(strs);
                base.OnDrop(e);
 
            }
            else
            {
                e.Effects = DragDropEffects.None;
            }
 
          base.OnPreviewDrop(e);
 
 
        }

Now the logic. Once I got something I can handle dropped, I'll convert it into well known XML representation in order to be able to provide it as data source for our previous control.


 



        private void SetDroppedObject( List<string> data)
        {
 
            ObservableCollection<XmlNode> nodes = new ObservableCollection<XmlNode>();
            XmlDocument doc = new XmlDocument();
            doc.LoadXml("<root></root>");
            bool fData = MessageBox.Show("Treat first row as title?", "Data dropped", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes;
            if (fData && data.Count < 1)
            {
                throw new NotSupportedException("No description row found in data provided. You should have at least one row to populate control columns");
            }
            int startRow = fData ? 1 : 0;
 
            string[] titles = data[0].Split(',');
 
            for (int i = startRow; i < data.Count; i++)
            {
                XmlElement elem = doc.CreateElement("node");
                string[] items = data[i].Split(',');
 
                for (int j = 0; j < items.Length; j++)
                {
                    string title = fData ? titles[j].Trim() : "data" + j.ToString();
                    XmlElement el = doc.CreateElement(title);
                    XmlText txt = doc.CreateTextNode(items[j]);
                    el.AppendChild(txt);
                    elem.AppendChild(el);
                }
                doc.DocumentElement.AppendChild(elem);
                nodes.Add(elem);
            }
 
            this.SetValue(DroppedDocumentPropertyKey, doc);
            this.SetValue(DroppedObjectPropertyKey, new ReadOnlyObservableCollection<XmlNode>(nodes));
 
            if (PropertyChanged != null)
            { 
                PropertyChanged(this,new PropertyChangedEventArgs("DroppedDocument"));
                PropertyChanged(this, new PropertyChangedEventArgs("DroppedObject"));
            }
        }

Implement INotifyPropertyChanged interface, a little logic to prevent dropping unsupported data and we done. Now, in my application I can do the following


 



<Window.Resources>
    <XmlDataProvider x:Key="Test" Source="XMLFile1.xml" XPath="/root/node"/>
 
 </Window.Resources>
  <StackPanel>
    <local:ExcelDataReader Width="100" Height="100" Background="Yellow" x:Name="excelData"/>
    <local:MyGrid x:Name="myGrid"  Background="Blue" ItemsSource="{Binding Source={StaticResource Test}}"/>
  </StackPanel>

We done. Open an application, create some table in Excel, select in and drop into yellow rectangle. The information will be parsed and all grid columns will be created automatically. Isn't is really cool?


Source code for this article

1 comment:

Chinnu said...

Its really nice code. It helped me a lot. My requirement is that i need to generate XML file at runtime & Set the list view item source as newly generated XML file.. Please let me know how could i fire that OnItemsSourceChanged Event.

Thanks & Best Regards,
Chinnu