MSDN Magazine > Home > Issues > 2005 > January >  Criando um controle de classificação cinco estr...
Criando um controle de classificação cinco estrelas

por Duncan Mackenzie

Para ler todas as matérias da MSDN Magazine, assine a revista no endereço www.neoficio.com.br/msdn

Este artigo discute

Este artigo usa as seguintes tecnologias:

  • Como criar e personalizar um controle

Visual Basic

Download:
AdvancedBasics0501.exe (144KB)

Chapéu
Controle Personalizado

 

Tenho de admitir; a maioria dos meus controles Windows® Forms são uma tentativa de algo que já existe. Desta vez, objeto de meu desejo é o controle de classificação cinco estrelas do Windows Media® Player (veja a Figura 1).


Figura 1 O controle de classificação cinco estrelas

Este controle é notável e oferece uma ótima maneira visual de ver as classificações, mas é a experiência de edição que considero especialmente interessante. Quando o cursor é movido sobre essa coluna, o Windows Media Player realça as estrelas para indicar o valor sobre o qual você está flutuando no momento, fornecendo um ótimo retorno visual. Esse mesmo tipo de interface de usuário é encontrado em vários sites da Web, incluindo o Netflix e o Amazon, e eu queria ter a funcionalidade em meus próprios aplicativos, então decidi criar meu próprio. Eu uso um controle Windows Forms para emular esse elemento de interface de usuário, ao mesmo tempo em que tento personalizá-lo de modo a poder usá-lo em diversas situações.

Primeiros passos

A primeira etapa é criar um novo projeto de Class Library para armazenar o controle e um aplicativo Windows vazio para ser meu projeto de teste. O modelo de projeto Windows Control Library pode parecer mais apropriado, e ele funcionará satisfatoriamente, mas por padrão esse projeto inclui controles de usuário (que são geralmente usados para controles compostos - controles Windows Forms que contêm um ou mais controles), e tudo de que preciso é um arquivo Class vazio. Em seguida, você precisa fazer com que sua nova Class atualmente vazia herde de System.Windows.Forms.Control, o que é facilmente obtido adicionando-se uma única linha após a declaração da classe:

Public Class Ratings
    Inherits System.Windows.Forms.Control

End Class

Se tentar adicionar a instrução Inherits usando apenas o IntelliSense®, você notará um pequeno problema: iniciar seu projeto com um modelo de Class Library não adicionará nenhuma referência à assembly System.Windows.Forms, por isso você precisará adicioná-la manualmente. Neste ponto, sigo em frente e adiciono também uma referência à System.Drawing.dll, já que no final ela será usada por um controle orientado a desenho personalizado.

Deste ponto em diante, eu geralmente sigo estas etapas para o desenvolvimento de todos os meus controles:

  1. Adiciono um construtor padrão para todos os controles de desenho personalizado e configuro todos os estilos de controle necessários para que o controle seja desenhado de forma mais correta e suave possível.

    Public Sub New()
        Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True)
        Me.SetStyle(ControlStyles.DoubleBuffer, True)
        Me.SetStyle(ControlStyles.ResizeRedraw, True)
        Me.SetStyle(ControlStyles.UserPaint, True)
        ... 'inclua aqui qualquer código adicional de inicialização
    End Sub
    
  2. Trabalhando no papel, calcule a lista de propriedades public para as quais você precisará configurar o comportamento e a aparência do controle.

  3. Adicione todas essas propriedades como variáveis de membro privadas (gosto de usar a notação húngara para indicar que elas são variáveis internas, acrescentando a elas o prefixo "m_") e inclua os valores padrão onde apropriado, conforme mostrado na Listagem 1.

  4. Transforme-os em procedimentos property, muitos dos quais bastante objetivos (get value, set value), embora alguns exijam um código adicional sobre o qual falarei em alguns instantes.

  5. Comece a planejar e a escrever o código de desenho personalizado.

  6. Por fim, adiciono todos os novos eventos, como ao clicar ou qualquer evento especial que seja necessário para esse controle específico.

Listagem 1 Variáveis de Membro de Controle

Private m_FilledImage As Image
Private m_EmptyImage As Image
Private m_HoverImage As Image
Private m_ImageCount As Integer = 5
Private m_TopMargin As Integer = 2
Private m_LeftMargin As Integer = 4
Private m_BottomMargin As Integer = 2
Private m_RightMargin As Integer = 4
Private m_ImageSpacing As Integer = 8
Private m_ImageToDraw As Integer = 1

Private m_SelectedColor As Color = Color.Empty
Private m_HoverColor As Color = Color.Empty
Private m_EmptyColor As Color = Color.Empty
Private m_OutlineColor As Color = Color.Empty

Private m_selectedItem As Integer = 3
Private m_hoverItem As Integer = 1
Private m_hovering As Boolean = False

Private ItemAreas() As Rectangle

Rotinas Property com valores padrão especiais

Quero que algumas de minhas propriedades - aquelas que lidam com cores - sejam valores padrão que reflitam outras propriedades do controle (tais como ForeColor), ao passo que outras reflitam as cores do sistema do usuário. Tome-se como exemplo uma dessas cores, HoverColor, e vamos observar as diferentes maneiras de produzir um valor padrão.

A primeira maneira é uma das mais óbvias, simplesmente defina o valor padrão na declaração da variável (ou no construtor):

Private m_HoverColor As Color = Color.FromKnownColor(KnownColor.Highlight)

Isso funcionará bem na maioria dos casos, mas tem dois problemas. Primeiro, e se o usuário alterar as cores do sistema enquanto o aplicativo estiver em execução? O controle refletirá as cores corretas após reiniciar o programa, mas não antes disso. Segundo, e se o usuário quiser retornar programaticamente a cor de volta para o padrão? Não existe nenhuma maneira real de remover a configuração de cor e fazer com que ela use a cor de sistema apropriada. O usuário poderia certamente defini-la diretamente para o sistema de cores apropriado, mas aí você teria novamente o mesmo problema.

Outra opção é capturar o evento quando as cores do sistema do usuário forem alteradas e mudar os valores de suas propriedades de acordo com a alteração:

Protected Overrides Sub OnSystemColorsChanged( _
        ByVal e As System.EventArgs)
    Me.HoverColor = Color.FromKnownColor(KnownColor.Highlight)
    Me.Invalidate()
End Sub

Essa solução não funcionará realmente a não ser que você tenha alguma maneira de saber se a propriedade está definida para o padrão ou se ela foi definida para uma cor específica pelo desenvolvedor que utiliza o controle. O trabalho de rastrear essas informações certamente não valeria o esforço. Como alternativa, decidi usar um valor padrão null/empty e, em seguida, retornar o padrão apropriado na rotina property propriamente dita, conforme mostrado na Listagem 2.

Listagem 2 Valores padrão de Property

Public Property HoverColor() As Color
    Get
        If m_HoverColor.Equals(Color.Empty) Then
            Return Color.FromKnownColor(KnownColor.Highlight)
        Else
            Return m_HoverColor
        End If
    End Get
    Set(ByVal Value As Color)
        If Not Value.Equals(m_HoverColor) Then
            m_HoverColor = Value
            Me.Invalidate()
        End If
    End Set
End Property

Isso soluciona os problemas que levantei até agora, incluindo a manipulação das alterações nas cores do sistema, sabendo quando se deve retornar o padrão versus quando o usuário o definiu, e permitindo que o usuário redefinisse o valor para o valor padrão (quando myControl.HoverControl = Color.Empty).

Desenhando imagens padrão e personalizadas

No controle que estou criando, decidi permitir duas categorias principais de imagens: padrão e fornecida pelo usuário. O controle inicial suporta apenas duas imagens padrão (círculos e quadrados), mas posteriormente discutirei uma maneira de adicionar a ele imagens personalizadas.

Todo o desenho desse controle é tratado na rotina OnPaint, que sobregravei para incluir meu próprio código de renderização (consulte Listagem 3). Dentro dessa rotina, eu calculo a posição de cada imagem (usando a propriedade ImageCount para determinar o número de imagens que deveriam ser desenhadas) e, em seguida, chamo DrawStandardImage (para desenhar um círculo ou quadrado) ou DrawUserSuppliedImage (para desenhar as imagens fornecidas pelo usuário).

Listagem 3 Renderizando o controle

Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs)
    e.Graphics.Clear(Me.BackColor)
    Dim imageWidth, imageHeight As Integer
    imageWidth = (Me.Width-(LeftMargin + RightMargin + ( _
        Me.m_ImageSpacing * (Me.m_ImageCount-1)))) \ Me.m_ImageCount
    imageHeight = (Me.Height-(TopMargin + BottomMargin))

    Dim start As New Point(Me.LeftMargin, Me.TopMargin)

    For i As Integer = 0 To Me.ImageCount-1
        Me.ItemAreas(i).X = start.X-Me.ImageSpacing \ 2
        Me.ItemAreas(i).Y = start.Y
        Me.ItemAreas(i).Width = imageWidth + Me.ImageSpacing \ 2
        Me.ItemAreas(i).Height = imageHeight

        If Me.ImageToDraw = UserSuppliedImage Then
            DrawUserSuppliedImage(e.Graphics, _
                start.X, start.Y, imageWidth, imageHeight, i)
        Else
            DrawStandardImage(e.Graphics, Me.ImageToDraw, _
                start.X, start.Y, imageWidth, imageHeight, i)
        End If
        start.X += imageWidth + Me.ImageSpacing
    Next
    MyBase.OnPaint(e)
End Sub


Protected Overridable Sub DrawUserSuppliedImage( _
        ByVal g As Graphics, _
        ByVal x As Integer, ByVal y As Integer, _
        ByVal w As Integer, ByVal h As Integer, _
        ByVal currentPos As Integer)

    Dim img As Image
    If m_hovering And m_hoverItem > currentPos Then
        img = Me.HoverImage
    ElseIf Not m_hovering And m_selectedItem > currentPos Then
        img = Me.FilledImage
    Else
        img = Me.EmptyImage
    End If

    If Not img Is Nothing Then
        g.DrawImage(img, x, y, w, h)
    Else
        Me.DrawStandardImage(g, Me.Circle, x, y, w, h, currentPos)
    End If
End Sub

Protected Overridable Sub DrawStandardImage( _
        ByVal g As Graphics, ByVal ImageType As Integer, _
        ByVal x As Integer, ByVal y As Integer, _
        ByVal w As Integer, ByVal h As Integer, _
        ByVal currentPos As Integer)

    Dim fillBrush As Brush
    Dim outlinePen As Pen = New Pen(Me.OutlineColor, 1)

    If m_hovering And m_hoverItem > currentPos Then
        fillBrush = New SolidBrush(Me.HoverColor)
    ElseIf Not m_hovering And m_selectedItem > currentPos Then
        fillBrush = New SolidBrush(Me.SelectedColor)
    Else
        fillBrush = New SolidBrush(Me.EmptyColor)
    End If

    Select Case ImageType
        Case Me.Square
            g.FillRectangle(fillBrush, x, y, w, h)
            g.DrawRectangle(outlinePen, x, y, w, h)
        Case Me.Circle
            g.FillEllipse(fillBrush, x, y, w, h)
            g.DrawEllipse(outlinePen, x, y, w, h)
    End Select
End Sub

Essas rotinas não são as mais eficientes (eu sempre redesenho o controle todo, por exemplo, em vez de invalidar apenas as regiões afetadas pela atualização específica), mas elas dão conta de desenhar as imagens apropriadas (ou as imagens coloridas apropriadamente, no caso das opções padrão) sempre que necessário. No restante do código do controle, sempre que ocorrer uma mudança ou estado que resultaria em uma mudança da aparência do controle, aciona-se um redesenho completo por meio do Me.Invalidate. A rotina de substituição de OnMouseMove é um exemplo desse tipo de código:

Protected Overrides Sub OnMouseMove(ByVal e As MouseEventArgs)
    For i As Integer = 0 To Me.ImageCount-1
        If Me.ItemAreas(i).Contains(e.X, e.Y) Then
            Me.m_hoverItem = i + 1
            Me.Invalidate()
            Exit For
        End If
    Next
    MyBase.OnMouseMove(e)
End Sub

Manipulando e chamado eventos

Neste ponto, o controle é funcional, principalmente devido à maravilhosa funcionalidade obtida pela herança de System.Windows.Forms.Control. Essa relação de herança dá a seu controle vários recursos, incluindo um evento Click e a capacidade de ser arrastado para a superfície de design de um Windows Form. No entanto, são necessários mais do que somente esses recursos padrão, por isso adicionarei um novo código e Event em algumas áreas-chave (consulte a Listagem 4).

Listagem 4 SelectedItem e SelectedItemChanged

Public Event SelectedItemChanged As EventHandler

Protected Overridable Sub OnSelectedItemChanged()
    RaiseEvent SelectedItemChanged(Me, EventArgs.Empty)
End Sub

Public Property SelectedItem() As Integer
    Get
        Return m_selectedItem
    End Get
    Set(ByVal Value As Integer)
        If Value >= 0 And Value <= Me.ImageCount + 1 Then
            m_selectedItem = Value
            OnSelectedItemChanged()
        Else
            Value = 0
        End If
    End Set
End Property

Esse novo Event, SelectedItemChanged, é mais do que uma conveniência; ele também tem o ótimo efeito colateral de melhorar o desempenho do data binding. Se o código de data binding do Windows Forms vir um evento com um nome que segue o padrão de <bound property name>Changed e com uma assinatura definida como System.EventHandler, ele usará esse evento como uma notificação de alteração na propriedade bound. Monitorar esse evento é muito menos trabalhoso do que pesquisar as alterações na propriedade, e o resultado final é um data binding mais eficiente.

As únicas outras rotinas que preciso adicionar ao meu controle são as substituições das rotinas OnMouseEnter e OnMouseLeave para assegurar que eu esteja exibindo corretamente o controle quando o usuário pairar sobre ele. Conforme mostrado na Listagem 5, também preciso substituir a rotina OnClick para que possa atualizar corretamente o item atualmente selecionado quando o usuário escolhe um novo valor de classificação.

Neste ponto, eu poderia adicionar muitos "cosméticos", tais como atributos para especificar um bitmap de toolbox e categorizar minhas propriedades, mas o controle é basicamente finalizado e funciona bem. O próximo truque, porém, seria permitir que outro desenvolvedor ampliasse meu trabalho de modo que este suportasse outras formas.

Listagem 5 Substituição de OnClick

Design para herança

A herança só funcionará quando as classes forem projetadas para permitir essa ação. Ok, talvez eu esteja exagerando um pouco. A herança funcionará sempre (desde que a classe da qual você esteja herdando não esteja marcada como NotInheritable), mas ao se considerar a possibilidade de futuras heranças durante o design de uma classe base, será geralmente mais fácil para os outros usuários adicionar posteriormente a funcionalidade que desejam. Para projetar uma classe que possa ser facilmente herdada, a primeira etapa é determinar como os outros desenvolvedores desejarão expandi-la. Você não pode prever tudo que os outros desenvolvedores desejarão fazer, mas pode certamente supor algumas das modificações mais óbvias. Com isso em mente, você pode agora analisar seu código em relação a problemas organizacionais, acessibilidade e facilidade de uso.

Observe se o seu código é dividido em funções que encapsulam melhor em áreas nas quais alguém possa querer estender a classe ou se os outros usuários precisarão reescrever dezenas de códigos não-relacionados apenas para adicionar algo novo. Repare também se você definiu os modificadores de acesso (Public, Private, Protected) em suas variáveis e rotinas de forma apropriada. Lembre-se de que a sua meta é tornar a experiência do usuário o mais estável possível.

Existem outras preocupações que você deverá ter ao projetar suas classes para a herança, mas essas foram as primeiras em que pensei quando observei esse exemplo em particular. Discutirei as alterações que fiz em minha classe "base" (Ratings) por ordem, a fim de torná-la mais fácil de estender.

Organização do código

Com o intuito de organizar o meu código de forma mais eficaz (embora eu talvez tivesse feito isso também para mais clareza), não coloquei o código do desenho e da forma da imagem diretamente no OnPaint. Ao deixá-las como suas próprias rotinas, um desenvolvedor poderá substituir uma dessas rotinas sem precisar se preocupar com todos os posicionamentos dos itens e configurações gráficas que ocorrem no OnPaint. Também tive cuidado de marcar as duas rotinas de desenho (junto com a maioria das minhas rotinas nessa classe) como Overridable porque elas se assemelhavam a um provável alvo para a extensão.

Acessibilidade

Para tornar meu código acessível de alguma maneira, tornei minhas duas rotinas de desenho Protected, em vez de Private, permitindo que elas fossem usadas por classes herdadas de minha classe mas que ainda ficassem ocultas da interface public. Também marquei várias rotinas adicionais, incluindo OnSelectedItemChanged, como Protected, permitindo que uma classe-filha as chamasse quando necessário.

Facilidade de uso

O mais nebuloso de meus três pontos, a facilidade de uso, trata da criação de uma versão estendida de sua classe que seja tão fácil de usar quanto a classe base. Claro, você não controla o que o desenvolvedor fará com as classes herdadas, por isso não pode garantir se ela será realmente fácil de usar, mas pode tentar antecipar as probabilidades. No caso de minha classe, eu havia criado originalmente a propriedade ImageType como um Enum (com UserDefined, Square e Circle incluído), o que levou a um código como este:

sr.ImageToDraw = ImageType.Circle

Quando tento imaginar essa situação como uma situação de herança, em que a classe-filha adicionou um novo ImageType, ocorre um problema. O enum não pode ser estendido, então você obtém isto:

sr.ImageToDraw = 3 'some number not in our original enum

Isso seria um problema em termos de strong typing (tipificação forte) (embora você até possa fazê-lo funcionar porque os Enums são geralmente tipos Int32 em sua essência) e não ficaria tão atraente. Para contornar esse problema, abandonei o Enum e defini minhas ImageTypes como constantes públicas em minha classe de controle, fazendo com que o código ficasse assim:

sr.ImageToDraw = Ratings.Circle

E, no caso da classe-filha com o novo tipo da forma, assim:

sr.ImageToDraw = myNewClass.NewShape

A classe Triangles, um exemplo de herança

Devido ao trabalho que fiz até aqui, fui capaz de herdar de meu controle e de estendê-lo com um novo tipo de imagem em apenas alguns minutos de codificação. A Figura 2 mostra o resultado final - uma classe que suporta um novo tipo de forma (triângulo).


Figura 2 MTS

O único código de que eu precisava era o da substituição de DrawStandardImage e uma nova constante, conforme mostrado na Listagem 6.

O desenvolvimento de controles, seja para Windows ou para Web, é uma ótima maneira de criar segmentos de código reutilizáveis, mas se quer permitir que outros desenvolvedores criem em cima de seu trabalho, é melhor você planejá-lo cuidadosamente para a herança.

Listagem 6 Adicionando uma nova forma Ratings

Public Class Triangles
    Inherits Ratings

    Public Const Triangle = 3

    Protected Overrides Sub DrawStandardImage( _
            ByVal g As System.Drawing.Graphics, _
            ByVal ImageType As Integer, _
            ByVal x As Integer, ByVal y As Integer, _
            ByVal w As Integer, ByVal h As Integer, _
            ByVal currentPos As Integer)

        Select Case ImageType
            Case Triangles.Triangle
                Dim fillBrush As Brush
                Dim outlinePen As Pen = New Pen(Me.OutlineColor, 1)

                If IsHovering AndAlso HoverItem > currentPos Then
                    fillBrush = New SolidBrush(Me.HoverColor)
                ElseIf Not IsHovering AndAlso _
                        SelectedItem > currentPos Then
                    fillBrush = New SolidBrush(Me.SelectedColor)
                Else
                    fillBrush = New SolidBrush(Me.EmptyColor)
                End If

                Dim pts(2) As PointF

                pts(0).X = (x + (w / 2))
                pts(0).Y = y
                pts(1).X = x + w
                pts(1).Y = y + h
                pts(2).X = x
                pts(2).Y = y + h

                g.FillPolygon(fillBrush, pts)
                g.DrawPolygon(outlinePen, pts)

            Case Else
                MyBase.DrawStandardImage(g, ImageType, _
                    x, y, w, h, currentPos)
        End Select
    End Sub
End Class

Conclusão

O produto final é um simples controle de classificação, mas ele será realmente útil se você puder usá-lo como uma coluna dentro de um DataGrid.

Duncan Mackenzie (basics@microsoft.com) é estrategista de conteúdo da MSDN para Visual Basic e C# e autor da coluna Coding 4 Fun da MSDN online. Para entrar em contato com Duncan, visite seu blog em weblogs.asp.net/duncanma ou acesse o site pessoal do autor em www.duncanmackenzie.net.

Page view tracker