WPF | MVVM y Menú contextual (ContextMenu) en filas de un DataGrid y los problemas del Binding

posterEs bastante común en las aplicaciones que muestran listados de registros, disponer de un menú contextual que extienda las opciones disponibles a realizar sobre cada registro, o simplemente que contenga algunas de ellas que podamos considerar como más usadas para que el usuario no tenga que desplazarse por la pantalla en busca de la opción necesaria.

Para esto, prácticamente todos los controles de WPF disponen de menú contextual, fácilmente gestionable pero que presenta algunos retos cuando estamos trabajando con el patrón MVVM ya que, el Binding se tiene que resolver con el ViewModel que contiene las acciones para que los comandos asociados a las opciones funcionen correctamente, así que vamos a ver paso a paso cómo podemos solventarlo.

Montando el Menú Contextual en nuestro DataGrid

Montar el menú contextual es una labor muy sencilla ya que, con sólo acceder a la propiedad ContextMenu del DataGrid podríamos tenerlo pero, como lo queremos a nivel de registros, debemos asociárselo a cada una de las filas del DataGrid y no al DataGrid en sí mismo tal y como se muestra en el siguiente snippet de código.

<Window x:Class="WPFSamples.DataGridContextMenu.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WPFSamples.DataGridContextMenu"
        mc:Ignorable="d"
        xmlns:viewModels="clr-namespace:WPFSamples.DataGridContextMenu.ViewModels"
        Title="MainWindow" 
        Height="350" 
        Width="525"
        WindowStartupLocation="CenterScreen">
    <Window.DataContext>
        <viewModels:FakeViewModel/>
    </Window.DataContext>
    <Grid>
        <DataGrid x:Name="MyGrid" 
                  ItemsSource="{Binding MyGridModelCollection}"
                  CanUserAddRows="False">
            <DataGrid.Resources>
                <ContextMenu x:Key="MyContextMenu">
                    <MenuItem Header="Test Option"/>
                </ContextMenu>
            </DataGrid.Resources>
            <DataGrid.RowStyle>
                <Style TargetType="{x:Type DataGridRow}">
                    <Setter Property="ContextMenu" Value="{StaticResource MyContextMenu}" />
                </Style>
            </DataGrid.RowStyle>
        </DataGrid>
    </Grid>
</Window>

Como se puede observar en el código XAML, tendremos una colección asignada al DataGrid mediante un Binding a una colección de objetos de nuestro ViewModel. En el próximo artículo os hablaré sobre cómo establecer MVVM en WPF, así que de momento estableceremos el DataContext de nuestra vista de forma directa añadiendo el ViewModel tal y como se puede apreciar antes de comenzar el Grid con la etiqueta “<Window.DataContext>…”. Posteriormente definimos el ContextMenu como un recurso del DataGrid y le asignaremos un valor a la propiedad x:Name que debe ser único en el contexto del DataGrid y que en mi ejempo es “MyContextMenu” para después crear un estilo para las filas del DataGrid mediante la etiqueta “<DataGrid.RowStyle>…” donde establecemos mediante un “<Setter>” la propiedad ContextMenu de las filas y le asignamos el ContextMenu que definimos en los recursos del DataGrid haciendo uso de su Name.

quote-marks1NOTA: Como buena práctica, es importante asignar un valor a la propiedad x:Name de aquellos elementos más relevantes para poder acceder a ellos en Bindings y Storyboards que establezcamos en nuestro código XAML.quote-marks1

De esta forma, al ejecutar la aplicación, ya tendremos disponible un menú contextual en cada uno de los registros del DataGrid tal y como se puede ver en la siguiente imagen aunque de momento no ejecutará accíón alguna.

image

 

Añadiendo acciones a las opciones del menú contextual

En este punto es donde comienza a complicarse la situación porque debemos establecer un contexto para poder hacer un Binding con nuestro ViewModel y así poder ejecutar los comandos que contiene. Buscando información al respecto, encontré este artículo sobre ContextMenu a nivel de filas https://blog.gisspan.com/2012/11/contextmenu-for-wpf-datagrid-on-row.html que ofrece una buena explicación pero da por hecho que cada uno de los registros está asociado a un ViewModel en sí y que, por lo tanto, cada uno de ellos contiene las acciones a ejecutar. Pero en realidad, el caso es más complejo si estamos siguiendo el patrón MVVM de una forma más estricta y que, por lo tanto, disponemos de un ViewModel global para la vista que contiene toda la información que queremos mostrar en pantalla en forma de propiedades además de los comandos que se pueden ejecutar. Es decir, este ViewModel contiene una colección que no necesariamente tiene que ser también de ViewModels y que, de hecho, por rendimiento no debería serlo, además de los comandos que se pueden ejecutar tal y como muestro en el código a continuación.

    public class FakeViewModel : INotifyPropertyChanged
    {
        public List<MyGridModel> MyGridModelCollection
        {
            get; set;
        }


        private DelegateCommand<object> myCommand;
        public ICommand MyCommand => myCommand;


        public FakeViewModel()
        {
            MyGridModelCollection = InitializeCollection();

            this.myCommand = new DelegateCommand<object> (MyCommandMethod);
        }

        private void MyCommandMethod(object parameter)
        {
            var myModel = parameter as MyGridModel;
            MessageBox.Show("You selected a command");
        }

        private List<MyGridModel> InitializeCollection()
        {
            var collection = new List<MyGridModel>
            {
                new MyGridModel { Title = "Item #1", Desc="Desc for item #1 ", Created=System.DateTime.Now },
                    new MyGridModel { Title = "Item #2", Desc="Desc for item #2 ", Created=System.DateTime.Now },
                    new MyGridModel { Title = "Item #3", Desc="Desc for item #3 ", Created=System.DateTime.Now },
                    new MyGridModel { Title = "Item #4", Desc="Desc for item #4 ", Created=System.DateTime.Now },
                    new MyGridModel { Title = "Item #5", Desc="Desc for item #5 ", Created=System.DateTime.Now },
                };
            return collection;
        }

        #region PropertyChanged
        public event PropertyChangedEventHandler PropertyChanged;

        public void RaisePropertyChanged([CallerMemberName]string propertyName = "")
        {
            var Handler = PropertyChanged;
            Handler?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
        #endregion

    }

quote-marks1Es importante tener en cuenta que los ViewModels deberían implementar la interfaz INotifyPropertyChanged para poder comunicar a la vista cualquier cambio que se produzca en las propiedades que están enlazadas con Binding.quote-marks1

Una vez que tenemos nuestro ViewModel, vamos a enlazar nuestros comandos a través de la propiedad Command que tienen los elementos del ContextMenu de la siguiente forma.

<DataGrid.Resources>
  <ContextMenu x:Key="MyContextMenu">
    <MenuItem Header="Test Option"
              Command="{Binding MyCommand}"/>
  </ContextMenu>
</DataGrid.Resources>

Como se puede ver, estamos haciendo un Binding al comando definido en el ViewModel. ¿Funcionará? La respuesta es que No porque el contexto de cada una de las filas es el modelo que tiene que representar, es decir, una instancia de tipo MyGridModel que no contiene el comando, con lo que no encontrará lo que debe ejecutar.

Estableciendo el contexto adecuado

Para que sea posible encontrar el comando a ejecutar en cada una de las opciones del menú contextual, es necesario establecer su contexto real, es decir, el ViewModel global. Hay varios workarounds un poco complejos o mal pensados desde mi punto de vista, así que voy a dar mi versión óptima o la que creo que es más óptima. En primer lugar, le indicaremos al menú contextual que su contexto debe ser el ViewModel de la vista y esto lo haremos estableciendo el mismo que el del propio DataGrid. Esto implica que, al ser el contexto de cada fila el ViewModel completo, debemos pasar como parámetro del comando el elemento actual para que la acción se pueda ejecutar sobre ese elemento en cuestión. Estos cambios de contexto son los que cuesta algo más entender y establecer, pero se ven de forma clara en el código.

<DataGrid.Resources>
  <ContextMenu x:Key="MyContextMenu"
              DataContext="{Binding DataContext, RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}">
    <MenuItem Header="Test Option"
              Command="{Binding MyCommand}"
              CommandParameter="{Binding Path=SelectedItem,
                                        RelativeSource={RelativeSource Mode=FindAncestor,
                                        AncestorType={x:Type DataGrid}}}"/>
  </ContextMenu>
</DataGrid.Resources>

Como podéis ver, el parámetro del comando será el elemento seleccionado del DataGrid ya que, al hacer click con el botón derecho sobre el elemento, éste quedará seleccionado.

Con esta solución, ya podremos ejecutar los comandos necesarios dentro de nuestro ContextMenu y seguir trabajando con nuestro ViewModel respetando las características de MVVM.

Descarga

Si queréis ver el código completo, podéis acceder a mi repositorio de GitHub y descargároslo libremente

watch-github

 

Enjoy coding.