Luke's Ridiculously Simple MVVM Table Grid

Friday, February 26, 2010 / Posted by Luke Puplett /

Windows Phone 7 Series extends the panning and scrolling UI metaphors seen in Windows Media Center and apparently in the Zune. This is no surprise as they are consumer entertainment products, or at least two are and the phone has been remarketed.

Anyway, to experiment with this style of interface, which lends itself so well to touch, I realised that I need a kind of table that can slide left and right, scroll up and down, be extended dynamically and do all this from a control-agnostic view model. Welcome to my ridiculously simple custom control.

Reductionism

When thinking about designing a user interface one must adopt a reductionist mentality. Each consituent part of a UI needs breaking down into units of functionality and within each one, there are usually more units of functionality until a reusable control is arrived at. And then there's that control's constituents which are usually a small number of stock WPF controls.

This idea comes quite easily to an object oriented programmer because its pretty much the same thought process as for designing classes of logic.

To be useable from a ViewModel that is not aware of the control, perhaps because the assembly that the view model is in doesn't reference the library of custom controls, it must support data binding using CLR base types.

A table is a bunch of bunches of stuff and at the time of writing, there's only one bunch of stuff class that supports databinding and that's the ObservableCollection<T>.

The Phone 7 interface scrolls sideways between 'pages' of contextual content either by gesture or with a click of a 'column' header - itself in its own slider.

500x_xboxlivephone

Each page scrolls as a normal page, with autohide scrollbars like the iPhone. The simplest logical representation of this logic - remember controls are just logic - is a horizontal StackPanel of ListBox controls.

That harmoniously translates into CLR land as an ObservableCollection of ObservableCollection objects. Nice.

To allow of column headings, I'm going to wedge a KeyValuePair in there, where K is a string and V is the ObservableCollection<object>

Code

Here's the full code the control. I'll explain some of the few remarkable points after. However there is a bug bug in this quick attempt: DataBinding doesn't call the setter and so doesn't set the CollectionChanged handler, which is why it works in my non-bound example.

The solution is to add/remove the handlers for this event in the OnTableSourceChanged handler, but I'm sure most of you can work this out. Is that lazy? Sorry but editting formatted code in the published article is a nightmare.



namespace WpfSimpleTableControl
{
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Windows;
    using System.Windows.Controls;

    /// <summary>
    /// A StackPanel of ListBoxes where each ListBox represents a column of data which can be databound.
    /// </summary>
    /// <remarks>
    /// DataBinding works by mirroring the StackPanel of ListBoxes with an ObservableCollection of ObservableCollections.
    /// </remarks>
    public class SimpleTable : StackPanel
    {                  
        #region Constructors

        static SimpleTable()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(SimpleTable), new FrameworkPropertyMetadata(typeof(SimpleTable)));
        }

        #endregion

        #region Properties

        #region Dependency Properties

        public ObservableCollection<KeyValuePair<string, ObservableCollection<object>>> TableSource
        {
            get { return (ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>)GetValue(TableSourceProperty); }
            set
            {
                var oldValue = this.TableSource;
                if (oldValue != null)
                    oldValue.CollectionChanged -= new System.Collections.Specialized.NotifyCollectionChangedEventHandler(table_CollectionChanged);

                value.CollectionChanged += new System.Collections.Specialized.NotifyCollectionChangedEventHandler(table_CollectionChanged);
                SetValue(TableSourceProperty, value);
            }
        }
        public static readonly DependencyProperty TableSourceProperty = DependencyProperty.Register(
            "TableSource", typeof(ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>), typeof(SimpleTable),
            new PropertyMetadata(
                new PropertyChangedCallback(OnTableSourceChanged)));

        public Style ListBoxStyle
        {
            get { return (Style)GetValue(ListBoxStyleProperty); }
            set { SetValue(ListBoxStyleProperty, value); }
        }        
        public static readonly DependencyProperty ListBoxStyleProperty =
            DependencyProperty.Register("ListBoxStyle", typeof(Style), typeof(SimpleTable), new UIPropertyMetadata(null));
        
        public DataTemplate ItemDataTemplate
        {
            get { return (DataTemplate)GetValue(ItemDataTemplateProperty); }
            set { SetValue(ItemDataTemplateProperty, value); }
        }
        public static readonly DependencyProperty ItemDataTemplateProperty =
            DependencyProperty.Register("ItemDataTemplate", typeof(DataTemplate), typeof(SimpleTable), new UIPropertyMetadata(null));

        #endregion

        #endregion

        #region Methods

        public void ReinitializeTableSource(ObservableCollection<KeyValuePair<string, ObservableCollection<object>>> table)
        {
            // Detach handlers and clear.
            //
            foreach(ListBox l in this.Children)
                l.LostFocus -= new RoutedEventHandler(listBox_LostFocus);
            //
            this.Children.Clear();
            
            if (table == null)
                return;

            foreach (var columnList in table)
            {
                string columnName = columnList.Key;
                
                var listBox = new ListBox();

                listBox.LostFocus += new RoutedEventHandler(listBox_LostFocus);

                if (this.ItemDataTemplate != null)
                    listBox.ItemTemplate = this.ItemDataTemplate;

                if (this.ListBoxStyle != null)
                    listBox.Style = this.ListBoxStyle;

                listBox.ItemsSource = columnList.Value;
                
                this.Children.Add(listBox);
            }
        }

        private void listBox_LostFocus(object sender, RoutedEventArgs e)
        {
            var listBox = (ListBox)sender;
            listBox.SelectedItem = null;
        }

        private void table_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            ReinitializeTableSource((ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>)sender);
        }

        /// <summary>
        /// Reads properties and fires off the state changer using VSM.
        /// </summary>
        /// <param name="useTransitions">Whether to trigger animations between the states.</param>
        private void UpdateStates(bool useTransitions)
        {
            //if (this.IsFocused)
            //    VisualStateManager.GoToState(this, Ticker.FocusedStateName, useTransitions);
            //else
            //    VisualStateManager.GoToState(this, Ticker.UnfocusedStateName, useTransitions);
        }

        private static void OnTableSourceChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
        {
            var newTable = (ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>)args.NewValue;
            var control = (SimpleTable)sender;

            control.ReinitializeTableSource(newTable);
        }
        
        #endregion

        #region Control Overrides

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            // Grab named ControlTemplate assets and stick em in properties.
            //           
            //this.ScrollViewerElement = GetTemplateChild(SimpleTable.ScrollViewerPartName) as ScrollViewer;
            
            this.UpdateStates(false);
        }
        
        #endregion
    }
}

Starting at the top, it has no parts. This is because it derives from StackPanel and so it is just itself. It's just a panel.

Moving down the code, there are three DependencyProperty members. The first is the DataTable property which is typed as ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>, the second is ListBoxStyle which accepts a Style object which allows such things as removing the default black border around each element and then there's ItemDataTemplate which is a DataTemplate to apply to each of the listboxes.

It's likely that each ListBox will need its own different DataTemplate but it would be quite trivial to add a property that takes a DataTemplateSelector which can use your own logic to work out which DataTemplate to apply to which items.

The next interesting thing is the ReinitializeTableSource method. This kicks out all the old ListBox instances in the panel (itself) and then adds new ones. It copies over the ItemDataTemplate and ListBoxStyle to each new ListBox and also hooks up the data binding to each collection that represents column data.

It also sets an event handler for the lostfocus event which simply removes the selection, otherwise each independant listbox will have its own selection highlight. There might be situations when this is useful, but not today.

Further down there's the handler for the main outer collection CollectionChanged event which can only really occur when columns are added or removed and triggers the whole thing to be reconfigured.

OnTableSourceChanged handles changes to the TableSource dependency property which could itself be bound, this also trigger a complete reconfig.

The rest is left over from the parts and states model and can support the VisualStateManager paradigm.

Basic Model

The most simple use of the control looks like this.

SimpleTable UI

The two buttons manipulate a window level private collection of collections which is communicated via the magic of data binding to the table on screen.

The XAML for the window is here:

<Window x:Class="WpfSimpleTableControl.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfSimpleTableControl"
Title="MainWindow" Height="350" Width="525">
<Grid>
<StackPanel>
<local:SimpleTable x:Name="SimpleTable" Margin="20" Orientation="Horizontal" Height="200">
<local:SimpleTable.ListBoxStyle>
<Style TargetType="{x:Type ListBox}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Width" Value="150" />
</Style>
</local:SimpleTable.ListBoxStyle>
</local:SimpleTable>
<Button Width="150" Click="Button_Click" Margin="2">Add to column 2</Button>
<Button Width="150" Click="Button_Click" Margin="2">Add new column</Button>
</StackPanel>
</Grid>
</Window>

And the codebehind, which does not use MVVM for this simple demo, goes like this.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Collections.ObjectModel;

namespace WpfSimpleTableControl
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private ObservableCollection<KeyValuePair<string, ObservableCollection<object>>> _table 
            = new ObservableCollection<KeyValuePair<string, ObservableCollection<object>>>();

        public MainWindow()
        {
            this.Initialized += new EventHandler(MainWindow_Initialized);

            InitializeComponent();
        }

        void MainWindow_Initialized(object sender, EventArgs e)
        {
            var list = new ObservableCollection<object>();
            list.Add("Hello");
            list.Add("...world!");
            _table.Add(new KeyValuePair<string, ObservableCollection<object>>("Column1", list));

            list = new ObservableCollection<object>();
            list.Add("Latika!");            
            _table.Add(new KeyValuePair<string, ObservableCollection<object>>("Column2", list));


            this.SimpleTable.TableSource = _table;

            //this.SimpleTable.Background = Brushes.Gray;
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            var b = (Button)sender;

            switch (b.Content.ToString())
            {
                case "Add to column 2":
                    _table[1].Value.Add(DateTime.Now.Millisecond + b.Content.ToString());
                    break;

                case "Add new column":
                    _table.Add(new KeyValuePair<string,ObservableCollection<object>>("New Column"new ObservableCollection<object>()));
                    _table.Last().Value.Add("Default item.");
                    break;
            }
        }
    }
}

And with MVVM would really just be a binding in the XAML to a ObservableCollection<KeyValuePair<string, ObservableCollection<object>>> typed property on the ViewModel class.

Because this was designed with MVVM in mind, the clicking of items is not considered in the panel. Usually, the DataTemplate for the item binds a Command to a property on the item's ViewModel and so actions go direct.

I will return when I have made it look nice, added a DataTemplate and probably after discovering some minor nuance which completely trounces the whole thing's feasiblity and makes me look a tit.

Hopefully not though, maybe sometimes WPF can be simple. Maybe.

Labels: , , , , ,

0 comments:

Post a Comment