Ivan Stoev
Ivan Stoev

Reputation: 205769

Cross tabular data binding in WPF

This is inspired from the following issue Rendering a generated table with TableLayoutPanel taking too long to finish. There are other SO posts regarding WPF tabular data, but I don't think they cover this case (although How to display real tabular data with WPF? is closer). The issue is interesting because both rows and columns are dynamic, and the view should not only display the data initially, but also react on add/remove (both rows and columns) and updates. I'll present the WF way (because I have experience there) and would like to see and compare it to the WPF way(s).

First, here is the sample model to be used in both cases:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
namespace Models
{
    abstract class Entity
    {
        public readonly int Id;
        protected Entity(int id) { Id = id; }
    }
    class EntitySet<T> : IReadOnlyCollection<T> where T : Entity
    {
        Dictionary<int, T> items = new Dictionary<int, T>();
        public int Count { get { return items.Count; } }
        public IEnumerator<T> GetEnumerator() { return items.Values.GetEnumerator(); }
        IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
        public void Add(T item) { items.Add(item.Id, item); }
        public bool Remove(int id) { return items.Remove(id); }
    }
    class Player : Entity
    {
        public string Name;
        public Player(int id) : base(id) { }
    }
    class Game : Entity
    {
        public string Name;
        public Game(int id) : base(id) { }
    }
    class ScoreBoard
    {
        EntitySet<Player> players = new EntitySet<Player>();
        EntitySet<Game> games = new EntitySet<Game>();
        Dictionary<int, Dictionary<int, int>> gameScores = new Dictionary<int, Dictionary<int, int>>();
        public ScoreBoard() { Load(); }
        public IReadOnlyCollection<Player> Players { get { return players; } }
        public IReadOnlyCollection<Game> Games { get { return games; } }
        public int GetScore(Player player, Game game)
        {
            Dictionary<int, int> playerScores;
            int score;
            return gameScores.TryGetValue(game.Id, out playerScores) && playerScores.TryGetValue(player.Id, out score) ? score : 0;
        }
        public event EventHandler<ScoreBoardChangeEventArgs> Changed;
        #region Test
        private void Load()
        {
            for (int i = 0; i < 20; i++) AddNewPlayer();
            for (int i = 0; i < 10; i++) AddNewGame();
            foreach (var game in games)
                foreach (var player in players)
                    if (RandomBool()) SetScore(player, game, random.Next(1000));
        }
        public void StartUpdate()
        {
            var syncContext = SynchronizationContext.Current;
            var updateThread = new Thread(() =>
            {
                while (true) { Thread.Sleep(100); Update(syncContext); }
            });
            updateThread.IsBackground = true;
            updateThread.Start();
        }
        private void Update(SynchronizationContext syncContext)
        {
            var addedPlayers = new List<Player>();
            var removedPlayers = new List<Player>();
            var addedGames = new List<Game>();
            var removedGames = new List<Game>();
            var changedScores = new List<ScoreKey>();
            // Removes
            if (RandomBool())
                foreach (var player in players)
                    if (RandomBool()) { removedPlayers.Add(player); if (removedPlayers.Count == 10) break; }
            if (RandomBool())
                foreach (var game in games)
                    if (RandomBool()) { removedGames.Add(game); if (removedGames.Count == 5) break; }
            foreach (var game in removedGames)
                games.Remove(game.Id);
            foreach (var player in removedPlayers)
            {
                players.Remove(player.Id);
                foreach (var item in gameScores)
                    item.Value.Remove(player.Id);
            }
            // Updates
            foreach (var game in games)
            {
                foreach (var player in players)
                {
                    if (!RandomBool()) continue;
                    int oldScore = GetScore(player, game);
                    int newScore = Math.Min(oldScore + random.Next(100), 1000000);
                    if (oldScore == newScore) continue;
                    SetScore(player, game, newScore);
                    changedScores.Add(new ScoreKey { Player = player, Game = game });
                }
            }
            // Additions
            if (RandomBool())
                for (int i = 0, count = random.Next(10); i < count; i++)
                    addedPlayers.Add(AddNewPlayer());
            if (RandomBool())
                for (int i = 0, count = random.Next(5); i < count; i++)
                    addedGames.Add(AddNewGame());
            foreach (var game in addedGames)
                foreach (var player in addedPlayers)
                    SetScore(player, game, random.Next(1000));
            // Notify
            var handler = Changed;
            if (handler != null && (long)addedGames.Count + removedGames.Count + addedPlayers.Count + removedPlayers.Count + changedScores.Count > 0)
            {
                var e = new ScoreBoardChangeEventArgs { AddedPlayers = addedPlayers, RemovedPlayers = removedPlayers, AddedGames = addedGames, RemovedGames = removedGames, ChangedScores = changedScores };
                syncContext.Send(_ => handler(this, e), null);
            }
        }
        Random random = new Random();
        int playerId, gameId;
        bool RandomBool() { return (random.Next() % 5) == 0; }
        Player AddNewPlayer()
        {
            int id = ++playerId;
            var item = new Player(id) { Name = "P" + id };
            players.Add(item);
            return item;
        }
        Game AddNewGame()
        {
            int id = ++gameId;
            var item = new Game(id) { Name = "G" + id };
            games.Add(item);
            return item;
        }
        void SetScore(Player player, Game game, int score)
        {
            Dictionary<int, int> playerScores;
            if (!gameScores.TryGetValue(game.Id, out playerScores))
                gameScores.Add(game.Id, playerScores = new Dictionary<int, int>());
            playerScores[player.Id] = score;
        }
        #endregion
    }
    struct ScoreKey
    {
        public Player Player;
        public Game Game;
    }
    class ScoreBoardChangeEventArgs
    {
        public IReadOnlyList<Player> AddedPlayers, RemovedPlayers;
        public IReadOnlyList<Game> AddedGames, RemovedGames;
        public IReadOnlyList<ScoreKey> ChangedScores;
        public long Count { get { return (long)AddedPlayers.Count + RemovedPlayers.Count + AddedGames.Count + RemovedGames.Count + ChangedScores.Count; } }
    }
}  

The class in interest is StoreBoard. Basically it has Players and Games lists, GetScore function by (player, game), and multipurpose batch change notification. I want it to be presented in a tabular format with rows being players, columns - games, and their intersection - scores. Also all the updating should be done in a structured way (using some sort of data binding).

WF specific solution:

the view model: IList will handle the row part, ITypedList with custom PropertyDescriptors - column part, and IBindingList.ListChanged event - all modifications.

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
namespace WfViewModels
{
    using Models;

    class ScoreBoardItemViewModel : CustomTypeDescriptor
    {
        ScoreBoardViewModel container;
        protected ScoreBoard source { get { return container.source; } }
        Player player;
        Dictionary<int, int> playerScores;
        public ScoreBoardItemViewModel(ScoreBoardViewModel container, Player player)
        {
            this.container = container;
            this.player = player;
            playerScores = new Dictionary<int, int>(source.Games.Count);
            foreach (var game in source.Games) AddScore(game);
        }
        public Player Player { get { return player; } }
        public int GetScore(Game game) { int value; return playerScores.TryGetValue(game.Id, out value) ? value : 0; }
        internal void AddScore(Game game) { playerScores.Add(game.Id, source.GetScore(player, game)); }
        internal bool RemoveScore(Game game) { return playerScores.Remove(game.Id); }
        internal bool UpdateScore(Game game)
        {
            int oldScore = GetScore(game), newScore = source.GetScore(player, game);
            if (oldScore == newScore) return false;
            playerScores[game.Id] = newScore;
            return true;
        }
        public override PropertyDescriptorCollection GetProperties()
        {
            return container.properties;
        }
    }
    class ScoreBoardViewModel : BindingList<ScoreBoardItemViewModel>, ITypedList
    {
        internal ScoreBoard source;
        internal PropertyDescriptorCollection properties;
        public ScoreBoardViewModel(ScoreBoard source)
        {
            this.source = source;
            properties = new PropertyDescriptorCollection(
                new[] { CreateProperty("PlayerName", item => item.Player.Name, "Player") }
                .Concat(source.Games.Select(CreateScoreProperty))
                .ToArray()
            );
            source.Changed += OnSourceChanged;
        }
        public void Load()
        {
            Items.Clear();
            foreach (var player in source.Players)
                Items.Add(new ScoreBoardItemViewModel(this, player));
            ResetBindings();
        }
        void OnSourceChanged(object sender, ScoreBoardChangeEventArgs e)
        {
            var count = e.Count;
            if (count == 0) return;
            RaiseListChangedEvents = count < 2;
            foreach (var player in e.RemovedPlayers) OnRemoved(player);
            foreach (var game in e.RemovedGames) OnRemoved(game);
            foreach (var game in e.AddedGames) OnAdded(game);
            foreach (var player in e.AddedPlayers) OnAdded(player);
            foreach (var group in e.ChangedScores.GroupBy(item => item.Player))
            {
                int index = IndexOf(group.Key);
                if (index < 0) continue;
                bool changed = false;
                foreach (var item in group) changed |= Items[index].UpdateScore(item.Game);
                if (changed) ResetItem(index);
            }
            if (RaiseListChangedEvents) return;
            RaiseListChangedEvents = true;
            if (e.AddedGames.Count + e.RemovedGames.Count > 0)
                OnListChanged(new ListChangedEventArgs(ListChangedType.PropertyDescriptorChanged, null));
            if ((long)e.AddedPlayers.Count + e.RemovedPlayers.Count + e.ChangedScores.Count > 0)
                ResetBindings();
        }
        void OnAdded(Player player)
        {
            if (IndexOf(player) >= 0) return;
            Add(new ScoreBoardItemViewModel(this, player));
        }
        void OnRemoved(Player player)
        {
            int index = IndexOf(player);
            if (index < 0) return;
            RemoveAt(index);
        }
        void OnAdded(Game game)
        {
            if (IndexOf(game) >= 0) return;
            var property = CreateScoreProperty(game);
            properties.Add(property);
            foreach (var item in Items)
                item.AddScore(game);
            if (RaiseListChangedEvents)
                OnListChanged(new ListChangedEventArgs(ListChangedType.PropertyDescriptorAdded, property));
        }
        void OnRemoved(Game game)
        {
            int index = IndexOf(game);
            if (index < 0) return;
            var property = properties[index];
            properties.RemoveAt(index);
            foreach (var item in Items)
                item.RemoveScore(game);
            if (RaiseListChangedEvents)
                OnListChanged(new ListChangedEventArgs(ListChangedType.PropertyDescriptorDeleted, property));
        }
        int IndexOf(Player player)
        {
            for (int i = 0; i < Count; i++)
                if (this[i].Player == player) return i;
            return -1;
        }
        int IndexOf(Game game)
        {
            var propertyName = ScorePropertyName(game);
            for (int i = properties.Count - 1; i >= 0; i--)
                if (properties[i].Name == propertyName) return i;
            return -1;
        }
        string ITypedList.GetListName(PropertyDescriptor[] listAccessors) { return null; }
        PropertyDescriptorCollection ITypedList.GetItemProperties(PropertyDescriptor[] listAccessors) { return properties; }
        static string ScorePropertyName(Game game) { return "Game_" + game.Id; }
        static PropertyDescriptor CreateScoreProperty(Game game) { return CreateProperty(ScorePropertyName(game), item => item.GetScore(game), game.Name); }
        static PropertyDescriptor CreateProperty<T>(string name, Func<ScoreBoardItemViewModel, T> getValue, string displayName = null)
        {
            return new ScorePropertyDescriptor<T>(name, getValue, displayName);
        }
        class ScorePropertyDescriptor<T> : PropertyDescriptor
        {
            string displayName;
            Func<ScoreBoardItemViewModel, T> getValue;
            public ScorePropertyDescriptor(string name, Func<ScoreBoardItemViewModel, T> getValue, string displayName = null) : base(name, null)
            {
                this.getValue = getValue;
                this.displayName = displayName ?? name;
            }
            public override string DisplayName { get { return displayName; } }
            public override Type ComponentType { get { return typeof(ScoreBoardItemViewModel); } }
            public override bool IsReadOnly { get { return true; } }
            public override Type PropertyType { get { return typeof(T); } }
            public override bool CanResetValue(object component) { return false; }
            public override object GetValue(object component) { return getValue((ScoreBoardItemViewModel)component); }
            public override void ResetValue(object component) { throw new NotSupportedException(); }
            public override void SetValue(object component, object value) { throw new NotSupportedException(); }
            public override bool ShouldSerializeValue(object component) { return false; }
        }
    }
}

Side note: in the code above can be seen one of the WF databinding flaws - we are stuck with a singe item list change notifications, which is ineffective if there are a lot of changes to apply, or brute force Reset notification which cannot be handled effectively by any list data presenter.

the view:

using System;
using System.Drawing;
using System.Windows.Forms;
namespace Views
{
    using Models;
    using ViewModels;
    class ScoreBoardView : Form
    {
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new ScoreBoardView { WindowState = FormWindowState.Maximized });
        }
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            var source = new ScoreBoard();
            viewModel = new ScoreBoardViewModel(source);
            InitView();
            viewModel.Load();
            source.StartUpdate();
        }
        ScoreBoardViewModel viewModel;
        DataGridView view;
        void InitView()
        {
            view = new DataGridView { Dock = DockStyle.Fill, Parent = this };
            view.Font = new Font("Microsoft Sans Serif", 25, FontStyle.Bold);
            view.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
            view.MultiSelect = false;
            view.CellBorderStyle = DataGridViewCellBorderStyle.None;
            view.ForeColor = Color.Black;
            view.AllowUserToAddRows = view.AllowUserToDeleteRows = view.AllowUserToOrderColumns = view.AllowUserToResizeRows = false;
            view.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells;
            view.RowHeadersVisible = false;
            view.EnableHeadersVisualStyles = false;
            var style = view.DefaultCellStyle;
            style.SelectionForeColor = style.SelectionBackColor = Color.Empty;
            style = view.ColumnHeadersDefaultCellStyle;
            style.SelectionForeColor = style.SelectionBackColor = Color.Empty;
            style.BackColor = Color.Navy;
            style.ForeColor = Color.White;
            style = view.RowHeadersDefaultCellStyle;
            style.SelectionForeColor = style.SelectionBackColor = Color.Empty;
            style = view.RowsDefaultCellStyle;
            style.SelectionForeColor = style.ForeColor = Color.Black;
            style.SelectionBackColor = style.BackColor = Color.AliceBlue;
            style = view.AlternatingRowsDefaultCellStyle;
            style.SelectionForeColor = style.ForeColor = Color.Black;
            style.SelectionBackColor = style.BackColor = Color.LightSteelBlue;
            view.ColumnAdded += OnViewColumnAdded;
            view.DataSource = viewModel;
            view.AutoResizeColumnHeadersHeight();
            view.RowTemplate.MinimumHeight = view.ColumnHeadersHeight;
        }
        private void OnViewColumnAdded(object sender, DataGridViewColumnEventArgs e)
        {
            var column = e.Column;
            if (column.ValueType == typeof(int))
            {
                var style = column.DefaultCellStyle;
                style.Alignment = DataGridViewContentAlignment.MiddleRight;
                style.Format = "n0";
            }
        }
    }
}  

And that's it.

Looking forward for the WPF way. And please note that this question is not for "which is better" comparison between WF and WPF - I'm really interested in WPF solution(s) of the problem.

EDIT: In fact, I was wrong. My "view model" is not WF specific. I've updated it with a cosmetic change (using ICustomTypeDescriptor) and now it's usable in both WF and WPF.

Upvotes: 4

Views: 489

Answers (1)

Fede
Fede

Reputation: 44048

So, your solution is extremely convoluted and resorts to hacks such as using reflection, which doesn't really surprise me since winforms is a very outdated technology and requires such hacks for everything.

WPF is a modern UI framework and does not need any of that.

This is a very naive solution that I put together in 15 minutes. Notice that it has absolutely zero performance considerations (since I'm basically throwing away and recreating all the rows and columns constantly) and yet the UI remains totally responsive while running.

First of all some base support for DataBinding:

public abstract class PropertyChangedBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
    }
}

Notice that this class requires nothing more than literally Ctrl+Enter since ReSharper puts that boilerplate in place automatically.

Then, using the same Model classes you provided, I put together this ViewModel:

public class ViewModel : PropertyChangedBase
{
    private readonly ScoreBoard board;

    public ObservableCollection<string> Columns { get; private set; }

    public ObservableCollection<Game> Games { get; private set; } 

    public ObservableCollection<RowViewModel> Rows { get; private set; } 

    public ViewModel(ScoreBoard board)
    {
        this.board = board;
        this.board.Changed += OnBoardChanged;

        UpdateColumns(this.board.Games.Select(x => x.Name));
        UpdateRows(this.board.Players, this.board.Games);

        this.board.StartUpdate();
    }

    private void OnBoardChanged(object sender, ScoreBoardChangeEventArgs e)
    {
        var games = 
            this.board.Games
                      .Except(e.RemovedGames)
                      .Concat(e.AddedGames)
                      .ToList();

        this.UpdateColumns(games.Select(x => x.Name));

        var players =
            this.board.Players
                      .Except(e.RemovedPlayers)
                      .Concat(e.AddedPlayers)
                      .ToList();

        this.UpdateRows(players, games);
    }

    private void UpdateColumns(IEnumerable<string> columns)
    {
        this.Columns = new ObservableCollection<string>(columns);
        this.Columns.Insert(0, "Player");

        this.OnPropertyChanged("Columns");
    }

    private void UpdateRows(IEnumerable<Player> players, IEnumerable<Game> games)
    {
        var rows =
            from p in players
            let scores =
                from g in games
                select this.board.GetScore(p, g)
            let row = 
                new RowViewModel
                {
                    Player = p.Name,
                    Scores = new ObservableCollection<int>(scores)
                }
            select row;

        this.Rows = new ObservableCollection<RowViewModel>(rows);
        this.OnPropertyChanged("Rows");
    }
}

public class RowViewModel
{
    public string Player { get; set; }

    public ObservableCollection<int> Scores { get; set; }
}

Then some XAML:

<Window x:Class="WpfApplication31.Window3"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Window3" Height="300" Width="300">
    <Window.Resources>
        <Style TargetType="ItemsControl" x:Key="Horizontal">
            <Setter Property="ItemsPanel">
                <Setter.Value>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Horizontal"/>
                    </ItemsPanelTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <Style TargetType="ListBoxItem">
            <Setter Property="Padding" Value="0"/>
        </Style>

        <DataTemplate x:Key="CellTemplate">
            <Border BorderBrush="Black" BorderThickness="1" Padding="5" Width="60">
                <TextBlock Text="{Binding}"
                           VerticalAlignment="Center"
                           HorizontalAlignment="Center"/>
            </Border>
        </DataTemplate>
    </Window.Resources>

    <DockPanel>
        <ItemsControl ItemsSource="{Binding Columns}"
                      Style="{StaticResource Horizontal}"
                      Margin="3,0,0,0"
                      ItemTemplate="{StaticResource CellTemplate}"
                      DockPanel.Dock="Top"/>

        <ListBox ItemsSource="{Binding Rows}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <ContentPresenter Content="{Binding Player}"
                                          ContentTemplate="{StaticResource CellTemplate}"/>

                        <ItemsControl ItemsSource="{Binding Scores}"
                                  Style="{StaticResource Horizontal}"
                                  ItemTemplate="{StaticResource CellTemplate}"/>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </DockPanel>
</Window>

Notice that, while this looks like a lot of XAML, I'm not using the built-in DataGrid or any other built-in control, but rather putting it together myself using nested ItemsControls.

Finally, the Window's code behind, which simply instantiates the VM and sets the DataContext:

public partial class Window3 : Window
{
    public Window3()
    {
        InitializeComponent();

        var board = new ScoreBoard();
        this.DataContext = new ViewModel(board);
    }
}

Result:

enter image description here

  • The first ItemsControl shows the Columns collection (the column names) on top.
  • The ListBox shows the Rows, each row containing a single cell for the player name, and then an horizontal ItemsControl for the numeric cells. Notice that in contrast to the winforms' counterpart, the WPF ListBox is actually useful.
  • Notice that my solution supports row selection like a standard DataGrid would, except that because I'm throwing away and recreating the entire dataset constantly, the selection is not maintained throughout. I could add a SelectedRow property in the VM to fix this.
  • Notice that my totally naive example with no optimizations whatsoever is more than capable of dealing with your 100 ms update cycle. If data was larger performance would surely start to degrade, and a better solution would be required, such as actually deleting what needs to be deleted and adding what needs to be added. Notice that even with a more complex solution I still wouldn't need to use reflection or any other hacks.
  • Also notice that my ViewModel code is much shorter (95 LOC versus 154 of yours) and I did not resort to deleting all blank lines to make it look shorter.

Upvotes: 4

Related Questions