Search code examples
c#wpfmvvmviewmodelcommunity-toolkit-mvvm

WPF Multidimensional Complex Object Model Binding and Wrapping for MVVM


I am a bit new to MVVM, I have a complex model List<List<SomeClass>>, lets call it a board, I want to bind it to a canvas in WPF, the canvas should act as map of Rectangles each Rectangle should be binded to a bool value in the board that will influence the color of the Rectangle.

I have managed to bind it to the Rectangles but it was static one time binding because there was no notification happening in the Model, now I am stuck trying to wrap it in the ViewModel to keep complete seperation.

Model.cs

public class Cell(int x, int y)
{
    public int X { get; set; } = x;
    public int Y { get; set; } = y;
    public bool IsOn { get; set;} = false;
    // Other properties...
}

public partial class GameBoard(int width, int height) : IEnumerable<List<Cell>>
{
    public int Height { get; private set; } = width;
    public int Width { get; private set; } = height;
    private List<List<Cell>> Board { get; set; } = Enumerable.Range(0, height)
        .Select(y => Enumerable.Range(0, width)
        .Select(x => new Cell(x, y)).ToList())
        .ToList();

    public IEnumerator<List<Cell>> GetEnumerator()
    {
        return Board.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return Board.GetEnumerator();
    }
}

ViewModel.cs

internal partial class BoardViewModel : ObservableObject
{
    [ObservableProperty] int width = 5;
    [ObservableProperty] int height = 5;
    [ObservableProperty] GameBoard board;

    public BoardViewModel()
    {
        Board = new(Width, Height);
    }
}

View.cs

public partial class BoardView : UserControl
{
    private readonly BoardViewModel viewModel;

    public BoardView()
    {
        InitializeComponent();
        viewModel = new BoardViewModel();
        DataContext = viewModel;
    }

    private void UserControl_Loaded(object sender, RoutedEventArgs e)
    {
        // For testing
        CellWidth = 30;
        CellHeight = 30;
        var offset = new Point(
            (int)ActualWidth / 2 - viewModel.Width * (int)(CellWidth + CellOffset) / 2,
            (int)ActualHeight / 2 - viewModel.Height * (int)(CellHeight + CellOffset) / 2);
        for (int y = 0; y < viewModel.Height; y++)
        {
            for (int x = 0; x < viewModel.Width; x++)
            {
                viewModel.Board[x, y] = true;
                var rect = new Rectangle
                {
                    Width = CellWidth,
                    Height = CellHeight,
                    Tag = new Point(x, y),
                };
                var binding = new Binding()
                {
                    Source = viewModel,
                    Path = new PropertyPath($"Board[{y},{x}]"),
                    Converter = new IsOnToFillConverter() // convert from bool to Brush
                };
                rect.MouseUp += Any_Click;
                rect.SetBinding(Shape.FillProperty, binding);
                Canvas.SetTop(rect, offset.Y + y * (rect.Height + CellOffset));
                Canvas.SetLeft(rect, offset.X + x * (rect.Width + CellOffset));
                canvasBoard.Children.Add(rect);
            }
        }
    }

    // For testing
    private void Any_Click(object sender, RoutedEventArgs e)
    {
        var location = (Point)(sender as Rectangle)!.Tag;
        viewModel.Board[location] = !viewModel.Board[location];
    }

    #region Dependency Properties

    public double CellWidth
    {
        get { return (double)GetValue(CellWidthProperty); }
        set { SetValue(CellWidthProperty, value); }
    }

    // Using a DependencyProperty as the backing store for CellWidth.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty CellWidthProperty =
        DependencyProperty.Register(nameof(CellWidth), typeof(double), typeof(BoardView), new PropertyMetadata(0d));


    public double CellHeight
    {
        get { return (double)GetValue(CellHeightProperty); }
        set { SetValue(CellHeightProperty, value); }
    }

    // Using a DependencyProperty as the backing store for CellHeight.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty CellHeightProperty =
        DependencyProperty.Register(nameof(CellHeight), typeof(double), typeof(BoardView), new PropertyMetadata(0d));


    public double CellOffset
    {
        get { return (double)GetValue(CellOffsetProperty); }
        set { SetValue(CellOffsetProperty, value); }
    }

    // Using a DependencyProperty as the backing store for CellOffset.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty CellOffsetProperty =
        DependencyProperty.Register(nameof(CellOffset), typeof(double), typeof(BoardView), new PropertyMetadata(5d));

    #endregion
}

Attempt

public partial class GameBoardViewModel(int width, int height) : ObservableObject
{
    private readonly GameBoard board = new(width, height);

    public bool this[int y, int x]
    {
        get => board[y, x].IsOn;
        set
        {
            OnPropertyChanging($"board[{y},{x}]");
            board[y, x].IsOn = value;
            OnPropertyChanged($"board[{y},{x}]");
        }
    }
    public bool this[Point location]
    {
        get => board[location].IsOn;
        set => this[location.Y, location.X] = value;
    }
}

Is there a way to wrap GameBoard with minimal boilerplate and with preserving model as is?

And can you explain what's happening under the hood when OnPropertyChanged is called with a PropertyPath and what exactly is a PropertyPath?


Solution

  • As suggested in comments by @GerrySchmitz I have used a combination of UniformGrid and ItemsControl. Then Bind the multidimensional list using a simple FlattenListConverter. As the name suggests the converter will flatten the multidimensional list into list of items, this will be fed by ItemsControl to the UniformGrid and will be evenly distributed using the uniformity provided by UniformGrid, as previewed bellow.

    View.xaml

    <UserControl x:Class="Views.BoardView"
                 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                 xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                 xmlns:local="clr-namespace:Views"
                 xmlns:viewmodels="clr-namespace:ViewModels"
                 xmlns:converters="clr-namespace:Converters"
                 xmlns:core="clr-namespace:System;assembly=mscorlib"
                 mc:Ignorable="d"
                 Loaded="UserControl_Loaded"
                 d:DesignHeight="400"
                 d:DesignWidth="400"
                 d:Background="{StaticResource BoardBackground}"
                 d:DataContext="{d:DesignInstance Type=viewmodels:BoardViewModel}">
        <UserControl.Resources>
            <converters:FlattenListConverter x:Key="FlattenListConverter" />
        </UserControl.Resources>
        <Border Grid.Column="1"
                Background="{Binding Background, RelativeSource={RelativeSource Self}}"
                VerticalAlignment="Stretch"
                HorizontalAlignment="Stretch">
            <ItemsControl ItemsSource="{Binding Board, Converter={StaticResource FlattenListConverter}}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <UniformGrid x:Name="uniGridBoard"
                                     Background="Transparent">
                        </UniformGrid>
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
            </ItemsControl>
        </Border>
    </UserControl>
    

    FlattenListConverter.cs

    using BoardLogic;
    using System;
    using System.Collections.ObjectModel;
    using System.Globalization;
    using System.Windows.Data;
    using System.Linq;
    
    namespace Converters;
    
    public class FlattenListConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is not ObservableCollection<ObservableCollection<Cell>> items)
                return null!;
            return items.SelectMany(items => items);
        }
    
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }