Friday, February 4, 2011

A Pivot-Style Data Grid without the DataGrid

It is fairly common to come across the need for a grid that has dynamic columns and I'm surprised that an open source control hasn't yet been implemented to provide this (or maybe it has ... if so, please share in the comments). I'm not a fan of the DataGrid because it feels very heavy for most of my needs and is also tough to customize when you require very fine control. I almost always go for a Listbox instead. Just check out this article if you don't believe me how powerful they can be.

When the need arose in a recent project to build this type of control I decided to do a spike and see how I could implement it as quickly and easily as possible. While eventually I think it would make sense to build a full custom, templated control for this, the solution using just existing XAML turned out to be relatively straightforward and so I ran with it.

The Problem

The hypothetical problem is that there are user-defined roles and you need a nice grid to allow editing permissions for users. Users can either be in the role, or not. Therefore, you have dynamic rows (users) and columns (roles) to display.

Models

First, you define the models for the users and roles. Because this is a proof of concept application, you can go ahead and generate some test data as well.

The "RoleModel" (pun intended) ends up looking like this, with a nice method to spit out some test roles:

public class RoleModel
{
    private static readonly List<RoleModel> _roleModels
        = new List<RoleModel>
                {
                    new RoleModel {Id = 1, Name = "User"},
                    new RoleModel {Id = 2, Name = "Administrator"},
                    new RoleModel {Id = 3, Name = "Contributor"},
                    new RoleModel {Id = 4, Name = "Operator"},
                    new RoleModel {Id = 5, Name = "Reporter"}
                };
                                                

    public int Id { get; set; }
    public string Name { get; set; }

    public static IEnumerable<RoleModel> RoleModels
    {
        get
        {
            return _roleModels;
        }
    }

    public override bool Equals(object obj)
    {
        return obj is RoleModel && (((RoleModel) obj).Id.Equals(Id));
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

Obviously in production the static methods and properties won't exist, but this serves for a simple demonstration right now. Next, create the user. In this model, the user contains a collection of roles to which the user is assigned:

public class UserModel
{

    private static readonly List<UserModel> _userModels
        = new List<UserModel>
                {
                    new UserModel {Id = 1, Name = "John Smith"},
                    new UserModel {Id = 2, Name = "Jane Doe"},
                    new UserModel {Id = 3, Name = "Ken Johnson"},
                    new UserModel {Id = 4, Name = "Sue Daily"},
                    new UserModel {Id = 5, Name = "Fred Simmons"}
                };

    public static List<UserModel> UserModels
    {
        get { return _userModels;  }
    }    

    public UserModel()
    {
        Roles = new List<RoleModel>();                       
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public List<RoleModel> Roles { get; private set; }
        

    public override bool Equals(object obj)
    {
        return obj is UserModel && (((UserModel) obj).Id.Equals(Id));
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

Next there is the intersection of users of roles. This is a model purely to help facilitate the UI by allowing you to get/set a permission. When bound, you can simply parse the results to update the users accordingly - it takes the internal paradigm (a user that just contains the roles it has access to) and translates it to the visual paradigm (an intersection of all users and roles with a permission).

Note the methods to generate these as well - the users are given randomized permissions. This is lazily loaded the first time it is accessed.

public class UserRole
{
    private static readonly Random _random = new Random((int)DateTime.Now.Ticks); 
      
    private static readonly List<UserRole> _userRoles = new List<UserRole>();

    public UserModel User { get; set; }
    public RoleModel Role { get; set; }
    public bool HasPermission { get; set; }

    public long Id
    {
        get
        {
            return (long) User.Id*int.MaxValue + Role.Id;
        }
    }

    private static bool _firstTime = true;

    public static IEnumerable<UserRole> UserRoles
    {
        get
        {
            if (_firstTime)
            {
                foreach (var user in UserModel.UserModels)
                {
                    foreach (var role in RoleModel.RoleModels)
                    {
                        var userRole = new UserRole { User = user, Role = role };

                        if (_random.NextDouble() < 0.5)
                        {
                            user.Roles.Add(role);
                            userRole.HasPermission = true;
                        }
                        _userRoles.Add(userRole);
                    }
                }
                _firstTime = false;
            }

            return _userRoles;
        }
    }

    public override bool Equals(object obj)
    {
        return obj is UserRole && ((UserRole) obj).Id.Equals(Id);
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

Designing the View Model

Next, the data must be hosted in a view model. In this short example, there is no need for a framework - just implement INotifyPropertyChanged and expose a few properties.

public class MainViewModel : INotifyPropertyChanged 
{
    public class UserModelWithPermissions : UserModel
    {            
        public IEnumerable<UserRole> UserRoles
        {
            get
            {
                return from ur in UserRole.UserRoles
                        orderby ur.Role.Name
                        where ur.User.Id == Id
                        select ur;
            }
        }
    }

    public MainViewModel()
    {
        Width = 800;
    }
   
    private double _width;
    public double Width
    {
        get { return _width; }
        set
        {
            if (value <= 0) return;

            _width = value;
            NotifyPropertyChanged("Width");
            NotifyPropertyChanged("ColumnWidth");
        }
    }

    public double ColumnWidth
    {
        get
        {
            var calc = Width/(Roles.Count() + 1);
            return calc > 0 ? calc : 100.0;
        }
    }

    public IEnumerable<RoleModel> Roles
    {
        get
        {
            return (from ur in RoleModel.RoleModels
                    orderby ur.Name
                    select ur).Distinct();
        }
    }

    public IEnumerable<UserModelWithPermissions> Users
    {
        get
        {
            return UserModel.UserModels.Select(user => new UserModelWithPermissions
                                                            {
                                                                Id = user.Id,
                                                                Name = user.Name
                                                            });
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected void NotifyPropertyChanged(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

There's a bit going on here. First, to synchronize the cell widths for the "grid" there is a Width property that for now is hard-coded to 800 and a ColumnWidth that is computed based on the number of roles.
Conceptually, this is what the grid will look like. There are three lists in play here:

Pivot Grid Example

The first list, across, is a column for each role.

The second list, down, is a row for each user.

The third list (and there are many of these) is a list across each row to fill the values for permissions for each user.

So how do you go about exposing this in the view model?

The roles are easy: just pass through the list. In this case, the roles are ordered by the name to be consistent.
The users are more interesting. Remember, users only had a list of roles that the user has permission to, but the grid requires a column for every role and the ability for the user to check it. What to do?

A simple solution is to shape the data for the view. In this case, a local class is defined (local because it is only scoped/used by this view model) called UserModelWithPermissions. It inherits from the user model. It exposes a single collection of the UserRole model (the one with permissions).

In this example, there is a master list available because the data was set up that way, and the user model simply needs to filter and grab the user roles for that specific user (and order them by the name of the role to be consistent with the columns). In a more practical world, the "data" for user roles wouldn't exist, and instead this would be a join between roles and roles to create the UserRole object on the fly. Either way would work. In the second case the list would need to be stored as an actual collection for data-binding.

The View

Now the view model has what's needed. There is also a strategy in place to show this, so it's simply a question of putting the XAML together. Take a look!

<Grid x:Name="LayoutRoot" Background="White" DataContext="{StaticResource MainViewModel}">    
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>        
    <Grid Width="{Binding ColumnWidth}"/>

At this point, the grid has been defined with a header row and a header column, and the rest is filled. Note the grid that is bound to the column width. What's that for? If you'll notice in the figure above, there is always a cell that is empty in the headers - the very first cell. This grid will fill that slot, so the roles can continue to the right and the users to the bottom of it. Next is the header row of roles:

<ItemsControl ItemsSource="{Binding Roles}" Grid.Row="0" Grid.Column="1">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Orientation="Horizontal"/>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid Width="{Binding Source={StaticResource MainViewModel},Path=ColumnWidth}">
                    <TextBlock Text="{Binding Name}"/>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

The items list is oriented horizontally, and ends up containing a list of grids all of the same width (as they are bound to the column width). They simply display the role name. With that row out of the way, next is a list of rows for each user:

<ItemsControl ItemsSource="{Binding Users}" Grid.Row="1" Grid.ColumnSpan="2">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition Width="*"/>
                    </Grid.ColumnDefinitions>
                    <TextBlock Text="{Binding Name}" Width="{Binding Source={StaticResource MainViewModel},Path=ColumnWidth}"/>
                    <ItemsControl Grid.Column="1" ItemsSource="{Binding UserRoles}">
                        <ItemsControl.ItemsPanel>
                            <ItemsPanelTemplate>
                                <StackPanel Orientation="Horizontal"/>
                            </ItemsPanelTemplate>
                        </ItemsControl.ItemsPanel>
                        <ItemsControl.ItemTemplate>
                            <DataTemplate>
                                <Grid Width="{Binding Source={StaticResource MainViewModel},Path=ColumnWidth}">
                                    <CheckBox IsEnabled="False" IsChecked="{Binding HasPermission}"/>
                                </Grid>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </Grid>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</Grid>

You'll notice the users is default list (vertical stack panel) with a user on each row. Each user is actually a grid. The grid cells are again synchronized to be the same width. The first cell is the user's name, followed by a list of cells that represent the permission for each row.

This creates the pivot grid ... but there's something missing. This grid is fixed width (remember the 800 that was set in the view model?) So how can you synchronize the width?

The Synchronization Behavior

The easiest way I could come up with was to use an attached behavior to communicate between the view and the view model. In the view, it could be attached and given an identifier. The view model can then register to receive notifications from that identifier of size.

Take a look. Note this implementation is to show the concept ... a production implementation would use weak references and provide a better mechanism for unhooking the callbacks.

public class SizeSynchronizationBehavior : Behavior<FrameworkElement>
{
    private static readonly Dictionary<string, List<Action<Size>>> _sizeCallbacks 
        = new Dictionary<string, List<Action<Size>>>();

    public static readonly DependencyProperty IdentifierProperty =
        DependencyProperty.Register(
            "Identifier",
            typeof(string),
            typeof(SizeSynchronizationBehavior),
            new PropertyMetadata(string.Empty));

    public string Identifier
    {
        get
        {
            return (string)GetValue(IdentifierProperty);
        }

        set
        {
            SetValue(IdentifierProperty, value);
        }
    }

    public static readonly DependencyProperty TargetWidthProperty =
        DependencyProperty.Register(
            "TargetWidth",
            typeof (double),
            typeof (SizeSynchronizationBehavior),
            new PropertyMetadata(1d));

    public double TargetWidth
    {
        get
        {
            return (double) GetValue(TargetWidthProperty);
        }

        set
        {
            SetValue(TargetWidthProperty, value);
        }
    }

    public static readonly DependencyProperty TargetHeightProperty =
        DependencyProperty.Register(
            "TargetHeight",
            typeof(double),
            typeof(SizeSynchronizationBehavior),
            new PropertyMetadata(1d));

    public double TargetHeight
    {
        get
        {
            return (double)GetValue(TargetHeightProperty);
        }

        set
        {
            SetValue(TargetHeightProperty, value);
        }
    }

    protected override void OnAttached()
    {
        AssociatedObject.SizeChanged += AssociatedObject_SizeChanged;
    }

    protected override void OnDetaching()
    {
        AssociatedObject.SizeChanged -= AssociatedObject_SizeChanged;
    }

    void AssociatedObject_SizeChanged(object sender, SizeChangedEventArgs e)
    {
        TargetWidth = e.NewSize.Width;
        TargetHeight = e.NewSize.Height;

        if (string.IsNullOrEmpty(Identifier)) return;
        if (!_sizeCallbacks.ContainsKey(Identifier)) return;

        // copy to avoid collision if collection modified while iterating
        var callbacks = from c in _sizeCallbacks[Identifier] select c;

        foreach(var callback in callbacks)
        {
            callback(e.NewSize);
        }
    }

    public static void RegisterForCallback(string identifier, Action<Size> callback)
    {
        if (string.IsNullOrEmpty(identifier))
        {
            throw new ArgumentNullException("identifier");
        }

        if (callback == null)
        {
            throw new ArgumentNullException("callback");
        }

        if (!_sizeCallbacks.ContainsKey(identifier))
        {
            lock(((ICollection)_sizeCallbacks).SyncRoot)
            {
                if (!_sizeCallbacks.ContainsKey(identifier))
                {
                    _sizeCallbacks.Add(identifier, new List<Action<Size>>());
                }
            }
        }

        _sizeCallbacks[identifier].Add(callback);
    }
}

The behavior does a few things. First, it attaches to the framework element and listens for size change events. When those are raised, it will set the width and height. Further, it contains a collection of callbacks. When a callback is registered, by passing in a delegate, the size change event will call any registered subscribers and pass the new size.

The behavior is attached like this, on the main grid:

<Grid x:Name="LayoutRoot" Background="White" DataContext="{StaticResource MainViewModel}">    
        <Interactivity:Interaction.Behaviors>
            <PivotGrid:SizeSynchronizationBehavior Identifier="MainGrid"/>
        </Interactivity:Interaction.Behaviors>

Note it is attached and given an identifier. Now, the view model can register for that identifier and update the width appropriately:

public MainViewModel()
{
    SizeSynchronizationBehavior.RegisterForCallback("MainGrid", _MainGridResized);
}

private void _MainGridResized(Size size)
{
    var dispatcher = Deployment.Current.Dispatcher;
    if (dispatcher.CheckAccess())
    {
        Width = size.Width;
    }
    else
    {
        dispatcher.BeginInvoke(() => Width = size.Width);
    }
}

Now the pivot grid will resize with everything else. Download the pivot grid sample here.

Play with the Silverlight application here.

Jeremy Likness

7 comments:

  1. Jeremy, to your question on dynamic columns, I wrote some DataGrid extensions for dynamic columns. I posted 1/2 of it here: http://www.damonpayne.com/post/2009/09/07/TwoWay-binding-to-NameValue-Pairs.aspx . The other half involves XamlReader to create the templates for the column templates. Ping me if you're interested in details.

    ReplyDelete
  2. Is it just me or the grid values are not editable?

    ReplyDelete
  3. Not just you. If you like you can switch the "is enabled" to "false" in the XAML and they'll be editable. Was mostly demoing the grid layout, didn't want the distraction.

    ReplyDelete
  4. Distraction is all I need right now )
    That's very good news... I'll give it a try and let you know...

    Now I need something similar in a grid:
    A master table with pivoted details (thus with variable columns count) that must appear as additional columns (using WCF RIA) ;)

    Anyway, that sample will help me a LOT, thanks a million ;)

    ReplyDelete
  5. Great post! A little tweaking and you could also have some nice building blocks for a project management tool : roles, responsibilities, dependencies (not the properties), etc...

    ReplyDelete
  6. Hello Jeremy,
    I did adapt the sample to use it with WCF RIA Services. That was painfull as I'm a beginner in Silverlight.
    The result is working but not very convincing. I have too many queries loading too many things in bckground. Loading the users, loading the roles and then load each series of user roles...
    In that case a grid would be much more efficient I guess, but as I don't know how to do and because your solution is working I'm gonna stick with it until someone builds another sample ;)
    In case you have some good ideas, I'm looking for a sample doing teh same with remote loaded data...
    Thanks anyway, as a working base your sample helped a lot.
    John.

    ReplyDelete
  7. Awesome example Jeremy. I used this example with bits of your MEF DataTemplateSelector example and created something I can work with.

    I think I emailed you on a Friday, and you had this post up the next day; thanks for the quick response to my email. The reason I am late in sending my appreciation is because I've been fine-tuning my custom listbox with dynamic (n) of columns.

    Your right, the ListBox is much easier to work with and seems more stable when complex customizations are required.

    thanks much,

    Greg

    ReplyDelete