Como crear un componente similar al PropertyGrid con un DataGridView

Cuando lo que querés hacer requiere cambios
9 de Noviembre de 2022

En un proyecto en el que estoy trabajando en mi tiempo libre, me encontré con un problema.

En mi proyecto, cuento con profiles definidos en archivos JSON. Estos definen propiedades a editar, su tipo, valor predeterminado, y algunos valores más.

Necesitaba poder tener un editor para poder modificar esas múltiples propiedades, al estilo del objeto PropertyGrid de .net, pero a diferencia de este, no necesitaba editar las propiedades de un objeto, sino un editor, sino las propiedades definidas desde el archivo JSON.

En un momento probé el objeto PropertyGrid con el JObject, pero tenía un problema: modificar el JSON haría que el formato de los profiles sea más complejo, además que creaba otros problemas, ya que en el perfil se almacenan varios datos, pero no todos son editables.

En un hilo de StackOverflow alguien preguntaba si era posible configurar un DataGridView para que se comporte como un PropertyGrid. En el pasado he trabajado con proyectos que utilizaban el objeto DataGridView, y había podido personalizarlo para ciertas características especiales de dichos proyectos. Me pregunte si podía lograr con lo que sabía (y con la ayuda de Google por supuesto) convertir la el DataGridView en lo que me necesitaba. Bueno, este es el resultado de mi investigación.

Lo primero

Lo primero que hice tras agregar el objeto en el form, fue configurarlo. Estás propiedades deben quedar así, tanto por código como desde el inspector de propiedades.

// No necesitamos que se puedan agregar filas
dgvProperties.AllowUserToAddRows = false;
// Tampoco eliminar
dgvProperties.AllowUserToDeleteRows = false;
// Opcionalmente, no dejamos que se cambie el ancho de las columnas
dgvProperties.AllowUserToResizeColumns = false;
// Deshabilitamos que se cambie el tamaño de las filas
dgvProperties.AllowUserToResizeRows = false;
// Definimos que el header tenga tamaño automático
dgvProperties.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgvProperties.RowHeadersWidthSizeMode = System.Windows.Forms.DataGridViewRowHeadersWidthSizeMode.DisableResizing;
// Evitamos que el usuario edite los valores directamente en el GridView
dgvProperties.EditMode = System.Windows.Forms.DataGridViewEditMode.EditProgrammatically;
// Ocultamos la columna header de las filas
dgvProperties.RowHeadersVisible = false;
// Definimos que se seleccione la fila entera, no una celda
dgvProperties.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;

El propósito de estos cambios es lograr que el comportamiento sea lo más parecido (en base a mis necesidades) a un PropertyGrid, pero sin la capacidad de editar directamente desde el grid.

Los datos

Ahora el siguiente paso, fue convertir el JSON a un DataSet. Para esto utilizo Newtonsoft JSON.net

El proceso consiste en iterar el objeto, cargar un Dataset con las propiedades definidas en la clave especificada, y luego configurar por código parte del formato de algunas celdas y columnas.

JObject json = JObject.Parse(data);
foreach (var x in json)
{
    string name = x.Key;    
    if (x.Key == key)
    {
        //Cuando encontramos la key, agregamos el array de valores
        JArray value = (JArray)x.Value;

        //Creamos una DataTable
        DataTable dt = new DataTable();
        //Agregamos las columnas
        dt.Columns.Add(newDataColumn("Field", "display"));
        dt.Columns.Add(newDataColumn("name", "name"));
        dt.Columns.Add(newDataColumn("type", "type"));
        dt.Columns.Add(newDataColumn("default", "default"));
        dt.Columns.Add(newDataColumn("Value", "value"));

        foreach (var pp in value)
        {
            //Agregamos una fila 
            DataRow dr = dt.NewRow();
            //La cargamos con los valores
            dr["display"] = pp["display"].ToString();
            dr["type"] = pp["type"].ToString();
            dr["name"] = pp["name"].ToString();
            if (pp["default"] != null)
            {
                _value = pp["default"].ToString();
                dr["default"] = pp["default"].ToString();
            }
            if (pp["value"] != null)
            {
                _value = pp["value"].ToString();
            }
            dr["value"] = _value;
            //Agregamos la fila a la tabla
            dt.Rows.Add(dr);
        }

        //Borramos las columnas del DataGridView
        target.Columns.Clear(); 

        //Asignamos la tabla al DataGridView
        target.DataSource = dt;

        //Ahora cambiamos la visualizacion de las columnas
        //Ocultamos algunas
        target.Columns["name"].Visible = false;
        target.Columns["type"].Visible = false;
        target.Columns["default"].Visible = false;
        //Las visibles las dejamos como Read Only
        target.Columns["display"].ReadOnly = true;
        target.Columns["value"].ReadOnly = true;
        //Deshabilitamos el Sorting en las columnas
        target.Columns["display"].SortMode = DataGridViewColumnSortMode.NotSortable;
        target.Columns["value"].SortMode = DataGridViewColumnSortMode.NotSortable;
        //Definimos el texto de las columnas de encabezado
        target.Columns["display"].HeaderText = "Field";
        target.Columns["value"].HeaderText = "Value";
        //Definimos el ancho de las columnas
        target.Columns["display"].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
        target.Columns["value"].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;

    }
}

Si ejecutamos el proyecto, vemos que se ve bastante parecido a lo que queremos.

nsxe2vj30c_fase-1_1200w.jpg

Pero le falta un poco más para que sea lo que necesitamos.

Los "detalles"

A lo que tenemos, vamos a sumarle varios detalles que haran la diferencia. Lo primero, es el botón con tres puntos al final. Esto se logra agregando luna columna al DataGridView tras haber asignado el DataSet al DataGridView. Está columna no estará vinculada a un campo de datos, sino que será un botón que nos permitirá editar el contenido.

DataGridViewButtonColumn column1 = new DataGridViewButtonColumn();
column1.Name = "action";
column1.UseColumnTextForButtonValue = true;
column1.HeaderText = "";
column1.Text = "...";
column1.Width = 30;
target.Columns.Add(column1);

Ahora, tenemos un botón que queda bastante bien y que nos permitirá luego editar el contenido de la celda.

e3ybtwr1gh_fase-2_1200w.jpg

El siguiente paso, es darle color. Para esto, utilizaremos el evento CellPainting. Este método definirá el color con el que se pintara cada celda dependiendo de estás reglas

  • Fila con índice -1 es el encabezado
  • Las filas de una Row con el type definido como section, es una fila de separación entre propiedades
  • La columna con Índice 0 es el nombre de la propiedad

La columna de valor y del botón de acción las dejaremos con el formato predeterminado.

if (e.RowIndex == -1)
{
    //Este es el formato de las celdas de encabezado
    e.Graphics.FillRectangle(Brushes.White, e.CellBounds);
    Color c1 = Color.FromArgb(128, 185, 219, 253);
    Color c2 = Color.FromArgb(128, 93, 115, 142);
    Color c3 = Color.FromArgb(128, 185, 219, 253);
    //Creamos un gradient y pintamos el fondo de la celda
    LinearGradientBrush br = new LinearGradientBrush(e.CellBounds, c1, c3, 90, true);
    ColorBlend cb = new ColorBlend();
    cb.Positions = new[] { 0, (float)0.5, 1 };
    cb.Colors = new[] { c1, c2, c3 };
    br.InterpolationColors = cb;

    e.Graphics.FillRectangle(br, e.CellBounds);
    e.PaintContent(e.ClipBounds);
    e.Handled = true;
}
if ((e.ColumnIndex == 0) && (e.RowIndex >= 0))
{
    //Este es el formato de la primer columna
    //Definimos la transparencia del gradient dependiendo si la fila esta seleccionada o no
    int alpha = ((DataGridView)sender)[e.ColumnIndex, e.RowIndex].Selected ? 128 : 64;
    e.Graphics.FillRectangle(Brushes.White, e.CellBounds);
    Color c1 = Color.FromArgb(alpha, 54, 54, 54);
    Color c2 = Color.FromArgb(alpha, 62, 62, 62);
    Color c3 = Color.FromArgb(alpha, 98, 98, 98);
    //Creamos un gradient y pintamos el fondo de la celda
    LinearGradientBrush br = new LinearGradientBrush(e.CellBounds, c1, c3, 90, true);
    ColorBlend cb = new ColorBlend();
    cb.Positions = new[] { 0, (float)0.5, 1 };
    cb.Colors = new[] { c1, c2, c3 };
    br.InterpolationColors = cb;

    e.Graphics.FillRectangle(br, e.CellBounds);
    e.PaintContent(e.ClipBounds);
    e.Handled = true;
}
if (e.RowIndex >= 0)
{
    //Obtenemos la fila de datos de la celda
    var row = ((DataGridView)sender).Rows[e.RowIndex];
    if (row != null)
    {
        if (row.Cells["type"].Value.ToString() == "section")
        {
            //Este es el formato de la fila de seccion
            int alpha = 255;
            e.Graphics.FillRectangle(Brushes.White, e.CellBounds);
            Color c1 = Color.FromArgb(alpha, 54, 54, 54);
            Color c2 = Color.FromArgb(alpha, 62, 62, 62);
            Color c3 = Color.FromArgb(alpha, 98, 98, 98);
            //Este es el formato de las celdas de encabezado
            LinearGradientBrush br = new LinearGradientBrush(e.CellBounds, c1, c3, 90, true);
            ColorBlend cb = new ColorBlend();
            cb.Positions = new[] { 0, (float)0.5, 1 };
            cb.Colors = new[] { c1, c2, c3 };
            br.InterpolationColors = cb;

            e.Graphics.FillRectangle(br, e.CellBounds);
            e.PaintContent(e.ClipBounds);
            e.Handled = true;
        }
    }
}

Ya vemos todo mucho mejor, pero aún hay algunos detalles que tenemos que mejorar.

cpl91tn06x_fase-3_1200w.jpg

El primero es que el botón aparece en las filas de encabezado. Y el segundo es que las filas de encabezado se ven en negro sobre un fondo oscuro, lo que dificulta la lectura.

Para el primer problema, vamos a crear un estilo de celda que aplicaremos a las celdas de encabezado de sección. Lo que hace este estilo es agregar un padding para ocultar el botón.

DataGridViewCellStyle dgvNoButtonStyle = new DataGridViewCellStyle();
dgvNoButtonStyle.Padding = new System.Windows.Forms.Padding(75, 0, 0, 0);

¿Como lo asignamos? Bien, vamos a recorrer las celdas y a asignar el estilo que necesitamos en cada celda.

//Recorremos todas las celdas
for (int i = 0; i < dt.Rows.Count; i++)
{
    //La primer columna siempre en negrita
    target[0, i].Style.Font = new Font(target.DefaultCellStyle.Font.Name, target.DefaultCellStyle.Font.Size, FontStyle.Bold);
    if (dt.Rows[i]["type"].ToString() == "section")
    {
        //Si es una sección, definimos el color blanco tanto para estado normal como para estado seleccionado
        target[0, i].Style.ForeColor = Color.White;
        target[0, i].Style.SelectionForeColor = Color.White;
        //Y en la columna del boton, asignamos el estilo para ocultar el boton
        target[5, i].Style = dgvNoButtonStyle;
    }
}

Y ya estaríamos, ¿No?

2lx64ervsj_fase-4_1200w.jpg

Visualmente sí. ¿Y cómo implementamos el editor?

Para eso utilizamos el evento CellClick. En este evento, invocaremos el código del editor, solo si la celda en la que se hizo click es la del botón.

private void cellEdit(int rowIndex)
{
    //Obtenemos el valor de la fila seleccionada
    var row = dgvProperties.Rows[rowIndex];
    // Creamos el formulario para editar la celda
    frmEditor edt = new frmEditor();
    edt.value = row.Cells["value"].Value.ToString();
    if (edt.ShowDialog() == DialogResult.OK)
    {
        //Actualizamos el valor
        row.Cells["value"].Value = edt.value;
    }
}

private void dgvProperties_CellClick(object sender, DataGridViewCellEventArgs e)
{
    if (e.ColumnIndex == 5)
    {
        //Solo editamos si la columna es la del boton
        cellEdit(e.RowIndex);
    }
}

private void dgvProperties_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
{
    // Bonus track: agregamos la edición del valor si se hace doble click
    cellEdit(e.RowIndex);
}

Y listo. De esta forma podemos crear un editor de propiedades bastante flexible utilizando el DataGridView. Y sobre esto, se pueden agregar más funcionalidades, como diferentes editores, edición inline de algunos tipos de datos, o incluso, convertirlo en un componente para mayor facilidad. Pero eso será en otro momento.

Puedes descargar el código completo en Github

Importante: Si tu proyecto usa .net 4.7/4.8, es probable que tengas un parpadeo al pintar el componente. Esto se debe a que en algunas versiones de .net el control DataGridView no tiene activa la propiedad DoubleBuffered de forma predeterminada. Eso se soluciona, agregando este código en el On Load del form (o luego de la inicialización del componente si lo creas por código)

typeof(DataGridView).InvokeMember(
    "DoubleBuffered",
    BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty,
    null,
    dgvProperties,
    new object[] { true });


Categorizado en: Programación Windows 
Como crear un componente similar al PropertyGrid con un DataGridView