banner de las colaboraciones

 

El Control PropertyGrid

Entrega 2 de 2

30 de Abril 2003
Cipriano Valdezate Sayalero

Link a la entrega 1

 

Indice

 

.

Representación gráfica de propiedades

La entrega anterior de esta serie de dos artículos la terminamos desplegando y agrupando en varias filas las propiedades de un objeto personalizado, a la vez que la cabecera de grupo mostraba una representación textual del valor de la propiedad desplegada. Sólo faltaba un pequeño detalle para alcanzar la perfección: así como la propiedad Color muestra un rectangulito pintado del color que el valor de la propiedad representa, también nuestro ColorOjosEx quería uno y se nos quedó muy triste sin él. Vamos a dárselo.

Todo lo que tenemos que hacer es añadir a la clase editora EditorColorOjosEx los dos procedimientos siguientes:

    Public Overloads Overrides Function GetPaintValueSupported(ByVal context As ITypeDescriptorContext) As Boolean
        Return True' True si utilizamos el rectangulito, False si no
    End Function

    Public Overloads Overrides Sub PaintValue(ByVal e As PaintValueEventArgs)
        e.Graphics.FillRectangle(New SolidBrush(CType(e.Context.Instance, MiAmigo).ColorOjosEx.Color), e.Bounds)
    End Sub

GetPaintValueSupported determina si utilizamos la pequeña área gráfica o no. PaintValue es quien pinta. Su argumento e contiene toda la información necesaria:

Así pues, todo consiste en obtener el objeto Graphics (e.Graphics) sobre el que pintaremos, obtener el color leyendo la propiedad ColorOjosEx.Color del objeto actual MiAmigo que nos proporciona e.context.Instance, (CType(e.Context.Instance, MiAmigo).ColorOjosEx.Color) y derramarlo hasta los límites marcados por e.Bounds. Aquí tenemos el resultado:

El rectangulito pintado

Igual que pintamos el rectangulito podemos mostrar imágenes en él. Es más, podemos desplegar un combo cuyos ítems incluyan una imagen. Todo lo que tenemos que hacer es, igual que con cualquier propiedad que se edite vía combo, escribir una clase heredera de StringConverter que genere la lista de valores, y, para mostrar las imágenes, escribir un editor derivado de UITypeEditor que incluya la función GetPaintValueSupported = True y el método PaintValue. Es en este método donde asignaremos a cada item del Combo obtenido de e.Value una imagen.

Vamos a clasificar nuestros grados de amistad administrados por la propiedad "Escala" en tres grupos, y a cada grupo le asignaremos una imagen. La única variación que introduciremos en nuestra clase "MiAmigo" será el tipo de la propiedad "Escala": no será una enumeración, sino un tipo "básico" como "String" para poder crear un Combo mediante una clase heredera de Stringconverter:

Public Class EditorGradoAmistad : Inherits UITypeEditor
    Public Overloads Overrides Function GetPaintValueSupported(ByVal context As ITypeDescriptorContext) As Boolean
        Return True
    End Function

    Public Overloads Overrides Sub PaintValue(ByVal e As PaintValueEventArgs)

        Dim bms As String
        Select Case e.Value 'Asignamos imágenes a grupos de valores
            Case Amistades(0), Amistades(1), Amistades(2)
                bms = "best2"
            Case Amistades(3), Amistades(4), Amistades(5)
                bms = "ok"
            Case Amistades(6), Amistades(7)
                bms = "bad2"
            Case Else : bms = "ok"

        End Select

        Dim bmp As Bitmap = New Bitmap(Application.StartupPath & "\" & bms & ".bmp")
        e.Graphics.DrawImage(bmp, e.Bounds) 'Dibujamos la imagen
        bmp.Dispose()
    End Sub
End Class
Public Class ComboAmistades : Inherits StringConverter

    Public Sub New()
        MyBase.New()
    End Sub

    Public Overloads Overrides Function GetStandardValuesSupported(ByVal context As ITypeDescriptorContext) As Boolean
        'True = Se despliega la lista
        'False = No se despliega la lista y el ususario debe escribir un valor
        Return True
    End Function
    Public Overloads Overrides Function GetStandardValues(ByVal context As ITypeDescriptorContext) As StandardValuesCollection
        Return New StandardValuesCollection(Amistades)
    End Function
    Public Overloads Overrides Function GetStandardValuesExclusive(ByVal context As ITypeDescriptorContext) As Boolean
        'True = el combo no admite más items que los de la lista
        'False = el combo admite un item que no esté en la lista
        Return True
    End Function

End Class
Public Module AmistadesArray
    Public Amistades As Array = New String() _
    {"Bueno", "Gran Amigo", "Para Siempre", "Amigote", "Cercano", "Conocido", "Interesado", "Enemigo"}

End Module

    <Category("Amistad"), Description("Nivel de amistad"), _
    Editor(GetType(EditorGradoAmistad), GetType(UITypeEditor)), _
    TypeConverter(GetType(ComboAmistades))> _
    Public Property Escala() As String
        Get
            Return _Escala
        End Get
        Set(ByVal Value As String)
            _Escala = Value
        End Set
    End Property
Dibujitos en los items del combo

Edición de colecciones

Si el lector ha leído la primera entrega y lo que llevamos de ésta, le alegrará saber que implementar la edición de una colección no requiere código que no hayamos aprendido ya a elaborar.

El editor de colecciones de VSNET se compone de un ListBox y un PropertyGrid. El ListBox almacena los elementos del array (empleo los términos "colección" y "array" indistintamente, en realidad me refiero a cualquier tipo que implemente la intefaz IEnumerable) y el PropertyGrid muestra y permite editar las propiedades del elemento seleccionado en el ListBox. Es decir: la edición sigue teniendo lugar en un PropertyGrid. Recomiendo, por tanto, la estrategia siguiente: implementar primero la edición de la propiedad sin los dos paréntesis (corchetes en C#) en su tipo, y, cuando hayamos conseguido la personalización deseada, añadírselos. VSNET se encargará del resto.

A modo de ejemplo crearemos un muy simple álbum de fotos. Escribiremos una clase que contenga la imagen, el pie de foto, el lugar y la fecha; instanciaremos y editaremos objetos de esta clase y los guardaremos en un array que hará de álbum.

Esta es la propiedad Fotos de nuestra clase MiAmigo:

      'Es conveniente inicializar el array aunque sea con un objeto vacío.
      Private _Fotos() As Fotografía = {New Fotografía()}
	  
      <Category("Rasgos físicos"), Description("Elige una foto")> _
      Public Property Fotos() As Fotografía()
        Get
            Return _Fotos
        End Get
        Set(ByVal Value As Fotografía())
            _Fotos = Value
        End Set
    End Property

Ésta la nueva clase Fotografía:

<TypeConverter(GetType(FotografíaConverter)), _
DefaultProperty("Instantánea")> _
Public Class Fotografía
    Private im As Image
    Private pie, lu, na As String
    Private da As Date

    Public Property Instantánea() As Image
        Get
            Return im
        End Get
        Set(ByVal Value As Image)
            im = Value
        End Set
    End Property

    Public Property PieDeFoto() As String
        Get
            Return pie
        End Get
        Set(ByVal Value As String)
            pie = Value
        End Set
    End Property

    Public Property Fecha() As Date
        Get
            Return da
        End Get
        Set(ByVal Value As Date)
            da = Value
        End Set
    End Property

    Public Property Lugar() As String
        Get
            Return lu
        End Get
        Set(ByVal Value As String)
            lu = Value
        End Set
    End Property

    <Editor(GetType(BuscadorArchivo), GetType(UITypeEditor))> _
    Public Property Narración() As String
        Get
            Return na
        End Get
        Set(ByVal Value As String)
            na = Value
        End Set
    End Property
End Class

Y ésta su clase convertidora:

Public Class FotografíaConverter : Inherits StringConverter
    Public Overloads Overrides Function CanConvertFrom(ByVal context As ITypeDescriptorContext, _
        ByVal sourceType As Type) As Boolean
        If sourceType Is GetType(String) Then
            Return True
        End If
        Return MyBase.CanConvertFrom(context, sourceType)
    End Function
    Public Overloads Overrides Function CanConvertTo(ByVal context As ITypeDescriptorContext, _
    ByVal destinationType As Type) As Boolean
        If destinationType Is GetType(Fotografía) Then
            Return True
        End If
        Return MyBase.CanConvertFrom(context, destinationType)
    End Function

    Public Overloads Overrides Function ConvertTo(ByVal context As ITypeDescriptorContext, _
        ByVal culture As CultureInfo, ByVal value As Object, ByVal destinationType As Type) As Object
        If destinationType Is GetType(String) AndAlso TypeOf value Is Fotografía Then
            'Esta función obtiene el objeto Fotografía 
            'editado en el PropertyGrid del editor de colecciones

            'Lo almacenamos en una variable pública
            Bild = value

            'Y escribimos en la línea del ListBox el pie de foto para distinguirlo
            Return Bild.PieDeFoto
        End If
        Return MyBase.ConvertTo(context, culture, value, destinationType)
    End Function

    Public Overloads Overrides Function ConvertFrom(ByVal context As ITypeDescriptorContext, _
    ByVal culture As CultureInfo, ByVal value As Object) As Object
        If TypeOf value Is String Then
            'Esta función se ejecuta cuando seleccionamos un objeto en el ListBox
            'y lo muestra el PropertyGrid del editor de colecciones

            'Simplemente recuperamos el objeto 
            'que antes habíamos metido en la variable pública
            Return Bild
        End If
        Return MyBase.ConvertFrom(context, culture, value)
    End Function

End Class

Public Module Bilder
    Public Bild As Fotografía
End Module

En definitiva, nada nuevo salvo que, si el tipo de la propiedad es una colección de tipos "básicos" como por ejemplo "Image" o "Point", entonces no es necesario, al menos para que el editor de colecciones funcione, ningún convertidor; en cambio, si se trata de un tipo complejo o personalizado, como es nuestro caso, entonces sí es necesaria una clase convertidora y además el atributo no se aplica a la propiedad sino al tipo: el atributo TypeConverter que contiene como parámetro FotografíaConverter no se aplica a la propiedad Fotos, sino a la clase Fotografía.

Es recomendable inicializar la propiedad cuyo tipo es una colección aunque sea con un objeto nuevo y sin editar, como hemos hecho nosotros. Si no el editor de colecciones puede no funcionar correctamente.

Al probar este código hemos descubierto que por cada item añadido a la colección el PropertyGrid ha añadido una fila identificada con el índice del item, e inmediatamente nos ha dominado un malsano deseo: ¿y si desplegáramos en el mismo PropertyGrid las propiedades de cada ítem de la colección? ¿y si, completamente enlodados en la lujuria, exigiéramos poder editar las propiedades de los ítems ahí mismo, sin tener que llamar al editor de colecciones? ¿y si, respondo yo, tuviéramos todo esto en nuestra pantalla con sólo modificar una línea de código? Pues todo se reduciría a derivar la clase convertidora no de StringConverter, sino de ExpandableObjectConverter:

Public Class FotografíaConverter : Inherits ExpandableObjectConverter
	'Todo lo demás permanece igual

Hágase la luz:

Una colección totalmente desplegada

Saciemos nuestra ambición: podemos modificar los ítems en el mismo PropertyGrid pero no podemos añadir ni eliminar ítems sin el editor de colecciones, así que añadiremos dos opciones al menú: Añadir Foto y Eliminar Foto, e implementaremos la adición y eliminación de fotos:

    Private Sub Añadir_Foto_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MenuItem13.Click
        If Not TypeOf PropertyGrid1.SelectedObject Is MiAmigo Then Exit Sub
        On Error GoTo ExitSub

        '"Sacamos" el objeto del PropertyGrid
        Dim Tronco As MiAmigo = PropertyGrid1.SelectedObject

        '"Sacamos" el array de fotos del objeto
        Dim Retratos As Fotografía() = Tronco.Fotos

        'Hacemos hueco para un ítem más
        ReDim Preserve Retratos(Retratos.GetUpperBound(0) + 1)

        'Le añadimos un ítem nuevo
        Retratos(Retratos.GetUpperBound(0)) = New Fotografía()

        '"Metemos" el array en el objeto
        Tronco.Fotos = Retratos

        '"Metemos" el objeto en el PropertyGrid
        PropertyGrid1.SelectedObject = Tronco

    ExitSub:
    End Sub

    Private Sub Elimina_Foto_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MenuItem14.Click
        If Not TypeOf PropertyGrid1.SelectedObject Is MiAmigo Then Exit Sub
        On Error GoTo ExitSub

        Dim i, j As Short

        '"Sacamos" el objeto del PropertyGrid
        Dim Tronco As MiAmigo = PropertyGrid1.SelectedObject

        '"Sacamos" el array de fotos del objeto
        Dim Retratos As Fotografía() = Tronco.Fotos

        'Metemos todos los ítems del array en una fila
        'excepto el que queremos eliminar
        'que es el seleccionado por el usuario
        Dim q As New Queue()
        For i = 0 To Retratos.GetUpperBound(0)
            If Not (PropertyGrid1.SelectedGridItem.Label = String.Format("[{0}]", i) _
            OrElse PropertyGrid1.SelectedGridItem.Parent.Label = String.Format("[{0}]", i)) _
            Then q.Enqueue(Retratos(i))
        Next

        'Convertimos la fila en un array tipo Object
        Dim Nuevos As Object() = q.ToArray

        'Convertimos el array tipo Object en tipo Fotografía
        Dim SinEse(Nuevos.GetUpperBound(0)) As Fotografía
        For i = 0 To Nuevos.GetUpperBound(0)
            SinEse(i) = CType(Nuevos(i), Fotografía)
        Next

        'Metemos el nuevo array en el objeto 
        Tronco.Fotos = SinEse

        'Metemos el objeto en el PropertyGrid
        PropertyGrid1.SelectedObject = Tronco

    ExitSub:
    End Sub

Sólo hay una cosa que se puede hacer en el editor de colecciones y no (todavía) en el mismo PropertyGrid: Cambiar el orden de los items. Cedo al lector el reto de su implementación.


Otros editores personalizados

Cuadros de diálogo comunes de Windows

A continuación copio la clase que genera el editor de la propiedad "Narración". Su razón de ser es el empleo de un cuadro de diálogo OpenFileDialog para editar un tipo String. Esta clase es igualita a la que utilizamos para mostrar el cuadro de diálogo hecho por nosotros que editaba el color de ojos, solo que esta vez la invocación va dirigida a un cuadro de diálogo común de Windows:

Public Class BuscadorArchivo : Inherits UITypeEditor

    Public Overloads Overrides Function EditValue(ByVal context As _
    System.ComponentModel.ITypeDescriptorContext, _
    ByVal provider As System.IServiceProvider, ByVal value As Object) As Object
       'Este procedimiento llama al cuadro de diálogo
       'OpenFileDialog y devuelve la ruta del archivo seleccionado
       'respetando siempre el tipo String de la propiedad
        Dim openf As Windows.Forms.OpenFileDialog = New Windows.Forms.OpenFileDialog()
        With openf
            .Filter = "Archivos de texto¦*.txt;*.rtf;*.doc"
            .ShowReadOnly = False
            .CheckFileExists = True
        End With
        Dim r As Windows.Forms.DialogResult = openf.ShowDialog
        If r = DialogResult.OK Then Return openf.FileName

    End Function

    Public Overloads Overrides Function GetEditStyle(ByVal context As _
           System.ComponentModel.ITypeDescriptorContext) As _
           System.Drawing.Design.UITypeEditorEditStyle
        Return UITypeEditorEditStyle.Modal
    End Function

    Public Overridable Overloads Function GetPaintValueSupported() As Boolean
        Return True
    End Function
End Class

Más PropertyGrids

El mejor editor de una propiedad cuyo tipo es una clase creada por nosotros es ... otro PropertyGrid!. que a la vez puede recurrir, para editar una propiedad de tipo complejo, a otro PropertyGrid y así sucesivamente y sin menoscabo de ninguna de las potencialidades que acabamos de aprender. No hay, por tanto, tipo complejo que se le resista a un PropertyGrid. El lector ya se imaginará dónde está el truco: invocación al cuadro de diálogo que contiene el PropertyGrid dentro del método EditValue de la clase derivada de UITypeEditor. En el programa "Amigo" encontrará un ejemplo.


Recategorización y ocultación de propiedades: PropertyTabs

Un PropertyTab es un CheckBox con apariencia de botón añadido al PropertyGrid que permite seleccionar las propiedades que serán visibles (ocultación) y redefinir el valor de su atributo Category(Attribute) (recategorización), de forma que esta ocultación y recategoriación ocurren sólo mientras se mantene pulsado. Si añadimos varios PropertyTabs facilitaremos al usuario la búsqueda y edición de propiedades por "supergrupos" o le permitiremos ordenarlas por criterios de nuestra elección.

Para cada botón que añadamos hemos de escribir una clase derivada de PropertyTab. La función básica de esta clase será crear un array que contendrá los descriptores de las propiedades o PropertyDescriptor que se mostrarán al pulsar el botón. Un PropertyDescriptor no es una propiedad, sino un objeto que contiene información acerca de una propiedad. Podemos crear un PropertyDescriptor nuevo a partir de una determinada propiedad entregando a la función compartida TypeDescriptor.CreateProperty los siguientes argumentos:

  1. El tipo del objeto que muestra el PropertyGrid
  2. Un PropertyDescriptor de la propiedad de la cual copia toda la información
  3. Una lista de atributos opcional. Si el PropertyDescriptor del punto anterior ya contiene algún atributo de la lista, entonces los valores se actualizan.

La sintaxis es la siguiente:

Dim PropertyDescriptorNuevo As PropertyDescriptor = TypeDescriptor.CreateProperty(Tipo del objeto del PropertyGrid, PropertyDescriptor de la propiedad "vieja", ParamArray de atributos)

A continuación copio una clase que ocultará todas las propiedades que no pertenezcan a la categoría "Rasgos físicos":

Public Class RasgosSelector : Inherits PropertyTab

    Private Const Atributo As String = "Rasgos físicos"

    Public Overloads Overrides Function GetProperties(ByVal component As Object, ByVal attributes() As System.Attribute) _
    As System.ComponentModel.PropertyDescriptorCollection
        'component contiene el SelectedObject del PropertyGrid
        'attributes es un ParamArray de atributos
        'que actúa como un filtro de propiedades
		
        Dim ProsMiAmigo As PropertyDescriptorCollection

        If attributes Is Nothing Then
            'Mete en el array un PropertyDescriptor por cada propiedad
            ProsMiAmigo = TypeDescriptor.GetProperties(component)
        Else
            'Mete en el array un PropertyDescriptor por cada propiedad
            'que esté marcada con los atributos contenidos en "attributes"
            ProsMiAmigo = TypeDescriptor.GetProperties(component, attributes)
        End If

        Dim ProsArray As New ArrayList()
        Dim i As Integer
        For i = 0 To ProsMiAmigo.Count - 1

            If ProsMiAmigo(i).Category = Atributo Then
                'Seleccionamos los descriptores cuyas propiedades
                'estén marcadas con el atributo "Rasgos físicos"

                'Entregamos:

                'el tipo metido en el PropertyGrid GetType(MiAmigo) = ProsMiAmigo(i).ComponentType

                'El descripor de la propiedad (ProsMiAmigo(i))
                'para que el nuevo descriptor copie toda la información que no modificamos

                'Y un nuevo atributo, que solo redefine el atributo especificado
                'o sea, en este caso, CategoryAttribute
                'los demás atributos, si los hubiere, no varían

                'Metemos cada PropertyDescriptor nuevo en el ArrayList
                ProsArray.Add(TypeDescriptor.CreateProperty(ProsMiAmigo(i).ComponentType, _
                ProsMiAmigo(i), New CategoryAttribute(Atributo)))

            End If
        Next

        'Convertimos el ArrayList en un Array de tipo PropertyDescriptor
        Dim ReturnArray() As PropertyDescriptor = ProsArray.ToArray(GetType(PropertyDescriptor))

        'No devolvemos exactamente un Array sino una PropertyDescriptorCollection
        Return New PropertyDescriptorCollection(ReturnArray)
    End Function
	
    Public Overloads Overrides Function GetProperties(ByVal component As Object) As System.ComponentModel.PropertyDescriptorCollection
        Return Me.GetProperties(component, Nothing) 'Sin filtro
    End Function

    Public Overrides ReadOnly Property TabName() As String
        Get
            Return Atributo 'Este es el tooltip
        End Get
    End Property

    Public Overrides ReadOnly Property Bitmap() As System.Drawing.Bitmap
        Get 'La imagen del botón
            Return New Bitmap(Application.StartupPath & "\CLOUD-01.bmp")
        End Get
    End Property

End Class

Sólo queda entregarle nuestro nuevo PropertyTab al PropertyGrid:

PropertyGrid1.PropertyTabs.AddTabType(GetType(RasgosSelector), _
System.ComponentModel.PropertyTabScope.Component)

Podemos asociar un tipo a un PropertyTab marcándolo con el atributo PropertyTab(Attribute) y entregándole el tipo de una clase derivada de PropertyTab como la que acabamos de crear:

<DefaultProperty("Nombre"), PropertyTab(GetType(RasgosSelector))> Public Class MiAmigo

Un tipo admite sólo un atributo PropertyTab, lo cual no quiere decir que no puedan aparecer varios ProeprtyTabs a la vez en el PropertyGrid. La gestión de la visibilidad de los PropertyTabs se encarga de ello.

La enumeración PropertyTabScope determina el ámbito o visibilidad del PropertyTab. Sus valores son los siguientes:

Así pues, un PropertyTab asignado a un determinado tipo, es decir, visible a nivel de Component, puede convivir con otros PropertyTabs con visibilidad más amplia, como, por ejemplo, Document.

El código y la imagen siguientes muestran las propiedades categorizadas por tipo (sólo copio la función GetProperties porque los demás miembros, salvo la ruta de la imagen, no varían):

    Public Overloads Overrides Function GetProperties(ByVal component As Object, ByVal attributes() As System.Attribute) _
    As System.ComponentModel.PropertyDescriptorCollection

        Dim ProsMiAmigo As PropertyDescriptorCollection

        If attributes Is Nothing Then
            ProsMiAmigo = TypeDescriptor.GetProperties(component)
        Else
            ProsMiAmigo = TypeDescriptor.GetProperties(component, attributes)
        End If

        Dim ProsArray(ProsMiAmigo.Count - 1) As PropertyDescriptor
        Dim i As Integer
        For i = 0 To ProsArray.GetUpperBound(0)

            ProsArray(i) = TypeDescriptor.CreateProperty(ProsMiAmigo(i).ComponentType, _
            ProsMiAmigo(i), New CategoryAttribute(ProsMiAmigo(i).PropertyType.ToString))

        Next
        Return New PropertyDescriptorCollection(ProsArray)
    End Function
Propiedades agrupadas según su tipo

Los + y - de este PropertyGrid son engañosos. Al menos a mí no se me despliegan las propiedades tras recategorizarlas. Si alguien sabe cómo se hace le agradecería que me lo dijera.


Conclusión

Todavía queda mucho que decir: el despliegue de propiedades recategorizadas que espero que alguien me lo enseñe algún día, el enlace a datos, la apariencia del control, los eventos ... y temas más avanzados como la creación dinámica de tipos por medio de CodeDom y su edición en el PropertyGrid ... sin embargo creo que con lo que yo he contado nos hemos hecho el lector y yo con una buena base para explorar las grandes posibilidades de este control que tan elegante es y tanto trabajo nos ahorra.


Adjunto dos programas de ejemplo:

El programa "Amigo" contiene todo el código explicado en esta entrega, y el Programa "Controles" simula el editor de propiedades del IDE de VBNET, con combo y todo.

Amigo2 Amigo2.zip 504 KB Controles2 Controles2.zip 41.5 KB

 


la Luna del Guille o... el Guille que está en la Luna... tanto monta...