Le avventure in VB.Net di un principiante ex-VB6 - 20
a cura di Oscar Zanin e Diego Cattaruzza (requisiti: Visual Basic Express e SqlServer)

Premessa
Nell'articolo precedente, per poter contenere i tanti controlli necessari alla FrmClienti in una disposizione meno affollata di quella originale di Oscar, Diego li aveva distribuiti su due TabPage in un TabControl, ottenendo questa interfaccia grafica (cliccate sull'immagine per vederla ingrandita):

Lo scopo pratico è stato raggiunto: i controlli sono più ordinati e la form ha una dimensione accettabile.
Ma l'estetica lascia alquanto a desiderare: le 'linguette' hanno un colore che disturba. Sarebbe opportuno che tale colore sia quello dello sfondo della scheda.

Questo articolo descrive l'avventura dell'implementazione di questa caratteristica.

Il progetto di studio
Per non rischiare di fare disastri, ho iniziato un progetto nuovo di tipo Windows Form, WindowsApplication1, ho aggiunto alla Form1 un TabControl1, del quale ho impostato la proprietà Dock a Bottom, impostando dappertutto il BackColor a LightBlue:

Adesso bisogna trovare qualche buona informazione su quella cosa strana che è la modalità OwnerDraw (di cui, prima d'ora, conoscevo poco più della parola) che è, come appunto indica il nome, la modalità per cui spetta al proprietario della grafica (cioè alla Form, ma, in ultima analisi, al programmatore) la responsabilità di disegnare l'aspetto del controllo.

Il TabControl espone la proprietà DrawMode per questo preciso scopo, infatti l'help recita: "indicates whether the user or the system paints the captions" (indica se è l'utente o il sistema a 'dipingere' i titoli, cioè le linguette). Ne ho quindi cambiato l'impostazione da Normal a OwnerDrawFixed.

E fin qua c'ero arrivato per intuito, diciamo, grazie alle belle cose fatte da Giorgio Brausi. L'help mi informa che, con DrawMode impostato a OwnerDrawFixed, il TabControl scatena l'evento DrawItem ogni qualvolta gli serva disegnare una delle sue linguette. E' nel gestore di questo evento che il programmatore deve implementare il codice di disegno.

Nella pagina dedicata all'evento DrawItem c'è un esempio che ho adattato, giusto per capire:

  Private Sub TabControl1_DrawItem(ByVal sender As Object, _
                                   ByVal e As System.Windows.Forms.DrawItemEventArgs) _
                                   Handles TabControl1.DrawItem
    ' devo conoscere il testo della linguetta
    ' quindi tipizzo sender 
    Dim tbc As TabControl = DirectCast(sender, TabControl)
    ' ne ottengo la tabpage corrente per questo evento
    ' con 'indice fornito in argomenti DrawItemEventArgs
    Dim tp As TabPage = tbc.TabPages(e.Index)
    ' ne leggo il titolo
    Dim caption As String = tp.Text
    ' ottengo il gestore grafico del controllo
    Dim g As Graphics = e.Graphics
    ' imposto un colore di fondo
    Dim p As New Pen(Color.Blue)
    ' una font
    Dim font As New Font("Arial", 10.0F)
    ' e un inchiostro
    Dim brush As New SolidBrush(Color.Red)
    ' ottengo il rettangolo che delimita il controllo
    Dim tabArea As Rectangle = TabControl1.GetTabRect(0)
    ' tipizzo questo rettangolo come rettangoloF
    Dim tabTextArea As RectangleF = RectangleF.op_Implicit(TabControl1.GetTabRect(0))
    ' disegno il rettangolo dipingendolo col colore di fondo
    g.DrawRectangle(p, tabArea)
    ' disegno il titolo con la font, l'inchiostro e il rettangoloF
    g.DrawString(caption, font, brush, tabTextArea)
  End Sub

Dico subito che l'esempio è piuttosto strampalato, perché fa uso di un metodo non molto documentato (terz'ultima riga): op_implicit è un operatore che effettua una tipizzazione nel tipo che lo espone (in questo caso RectangleF) dell'oggetto passato in argomento (in questo caso tabArea). Ora, a parte il fastidio di dovermelo digitare perché Intelllisense non lo presenta - ero anzi pressocché sicuro che non potesse funzionare, invece, come Pino dei Palazzi quando impenna, il compilatore... muto! - il risultato è questo:

Pietoso. Ok, magari ho sbagliato qualcosa io, oppure è proprio l'esempio fallace, ma voglio tentare un'altra strada: intanto, non mi piace istanziare tutti quegli oggetti ogni volta che deve essere disegnato uno degli elementi del controllo, quindi introduco delle variabili statiche per istanziarli una volta per tutte, e poi voglio avere un colore diverso per le linguette, a seconda che abbiano o no il focus:

    ' dichiaro le variabili statiche
    Static normalFont, selectedFont As Font
    Static normalColor As Brush, selectedColor As Brush
    Static fmt As StringFormat
    ' la prima volta, istanzio e imposto gli oggetti
    If normalFont Is Nothing Then
      normalFont = New Font("Arial", 8)
      selectedFont = New Font("Arial", 8, FontStyle.Bold)
      normalColor = Brushes.Black
      selectedColor = Brushes.Blue
      fmt = New StringFormat
      fmt.Alignment = StringAlignment.Center
      fmt.LineAlignment = StringAlignment.Center
    End If
    ' ottengo un riferimento al TabControl
    Dim tbc As TabControl = DirectCast(sender, TabControl)
    ' a seconda che la pagina corrente per l'evento 
    ' sia o no selezionata per il TabControl,
    ' scrivo il titolo con le impostazioni predisposte.
    If tbc.SelectedIndex = e.Index Then
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            selectedFont, selectedColor, e.Bounds, fmt)
    Else
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            normalFont, normalColor, e.Bounds, fmt)
    End If

I commenti nel codice dovrebbero essere sufficienti per la comprensione. Rimane solo da dire che decido di usare, tra i tanti overload che offre il metodo DrawString, quello che 'pretende' i margini dell'area di disegno (e.Bounds) e il formato per il disegno della stringa (StringFormat). Con questo risultato:

Già meglio. Per lo meno, ho capito che bisogna lavorare su quel che offre l'oggetto Graphics, e che esso offre molto, ma molto di più di quello che so. Quindi facciamo altre indagini: devo individuare l'area delle linguette, e quella del fondo del TabControl.

    ' dichiaro le variabili statiche
    Static normalFont, selectedFont As Font
    Static normalColor As Brush, selectedColor As Brush
    Static fmt As StringFormat
    ' la prima volta, istanzio e imposto gli oggetti
    If normalFont Is Nothing Then
      normalFont = New Font("Arial", 8)
      selectedFont = New Font("Arial", 8, FontStyle.Bold)
      normalColor = Brushes.Black
      selectedColor = Brushes.Blue
      fmt = New StringFormat
      fmt.Alignment = StringAlignment.Center
      fmt.LineAlignment = StringAlignment.Center
    End If
    ' ottengo un riferimento al TabControl
    Dim tbc As TabControl = DirectCast(sender, TabControl)

    ' ottengo il colore di fondo della scheda corrente
    Dim bc As Color = tbc.TabPages(e.Index).BackColor
    ' e me ne procuro una penna
    Dim p As New Pen(bc)
    'cerco di individuare i valori giusti per i rettangoli
    Dim tbcBackgroundRect As New System.Drawing.RectangleF( _
                          tbc.Left + 1, _
                          tbc.Top + e.Bounds.Y + e.Bounds.Height + 3, _
                          tbc.Width - 3, _
                          tbc.Height - (e.Bounds.Y + e.Bounds.Height + 4))
    Dim tabPageRect As New System.Drawing.RectangleF(e.Bounds.X, e.Bounds.Y, _
                                                     e.Bounds.Width, e.Bounds.Height + 2)
    ' disegno i rettangoli
    e.Graphics.FillRectangle(p.Brush, tbcBackgroundRect)
    e.Graphics.FillRectangle(p.Brush, tabPageRect)

    ' a seconda che la pagina corrente per l'evento 
    ' sia o no selezionata per il TabControl,
    ' scrivo il titolo con le impostazioni predisposte.
    If tbc.SelectedIndex = e.Index Then
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            selectedFont, selectedColor, e.Bounds, fmt)
    Else
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            normalFont, normalColor, e.Bounds, fmt)
    End If

Ho dovuto fare diverse prove per individuare i valori giusti per margini e dimensioni dei rettangoli del TabControl e della TabPage, ma alla fine questo è il risultato:

Quasi... Che cosa manca? a cosa appartiene quel maledetto pezzo grigio? Ho cercato un bel po' nell'help, ma per il momento non ho trovato alcunché di soddisfacente (nemmeno in rete, dove ho scoperto esempi meno efficienti del mio, perdonate l'immodestia). Decido quindi di 'limitare i danni' impostando lo SizeMode a Fixed e re-impostando la dimensione delle schede nella gestione dell'evento Resize della Form:

  Private Sub Form1_SizeChanged(ByVal sender As Object, _
                                ByVal e As System.EventArgs) Handles Me.SizeChanged
    Dim tbc As TabControl = TabControl1
    tbc.ItemSize = New Size((tbc.Width - 4) \ tbc.TabPages.Count, tbc.ItemSize.Height)
  End Sub

Con questo risultato:

Ci sono ancora dei difetti, ma disturbano molto meno di quelli per ovviare ai quali ho cominciato questo studio.
Quindi mi appresto a salvare il codice e ad annotare le impostazioni 'diverse' del TabControl. poi mi viene in mente che vorrò che 'tutti' i TabControl che userò in futuro abbiano questo aspetto (a parte i colori che potrebbero cambiare, anche da scheda a scheda - ecco perché, a proposito, lo rilevo ad ogni scheda, invece di farne una variabile statica). Così mi preparo uno snippet:

<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
<CodeSnippet Format="1.0.0">
  <Header>
    <Title>TabControl_OwnerDraw</Title>
    <Author>Diego</Author>
    <Description>TabControl_DrawItem e Form1_SizeChanged per colorare il background del TabControl
    e per controllare il resize del controllo</Description>
    <SnippetTypes>
      <SnippetType>Expansion</SnippetType>
    </SnippetTypes>
  </Header>
  <Snippet>
    <Declarations>
      <Literal>
        <ID>tabControlName</ID>
        <Default>TabControl1</Default>
      </Literal>
    </Declarations>
    <Code Language="VB">
      <![CDATA[  ''' <summary>
  ''' disegno ownerdraw del tabcontrol
  ''' </summary>
  ''' <remarks>.DrawMode = TabDrawMode.OwnerDrawFixed .SizeMode = TabSizeMode.Fixed</remarks>
    Private Sub $tabControlName$_DrawItem(ByVal sender As Object, _
                                   ByVal e As System.Windows.Forms.DrawItemEventArgs) _
                                   Handles $tabControlName$.DrawItem

    Static normalFont, selectedFont As Font
    Static normalColor As Brush, selectedColor As Brush
    Static fmt As StringFormat
    If normalFont Is Nothing Then
      normalFont = New Font("Arial", 8)
      selectedFont = New Font("Arial", 8, FontStyle.Bold)
      normalColor = Brushes.Black
      selectedColor = Brushes.Blue
      fmt = New StringFormat
      fmt.Alignment = StringAlignment.Center
      fmt.LineAlignment = StringAlignment.Center
    End If

    Dim tbc As TabControl = DirectCast(sender, TabControl)

    Dim bc As Color = tbc.TabPages(e.Index).BackColor
    Dim p As New Pen(bc)

    Dim tbcBackgroundRect As New System.Drawing.RectangleF( _
                          tbc.Left + 1, _
                          tbc.Top + e.Bounds.Y + e.Bounds.Height + 3, _
                          tbc.Width - 3, _
                          tbc.Height - (e.Bounds.Y + e.Bounds.Height + 4))
    Dim tabPageRect As New System.Drawing.RectangleF(e.Bounds.X, e.Bounds.Y, _
                                                     e.Bounds.Width, e.Bounds.Height + 2)

    e.Graphics.FillRectangle(p.Brush, tbcBackgroundRect)
    e.Graphics.FillRectangle(p.Brush, tabPageRect)


    If tbc.SelectedIndex = e.Index Then
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            selectedFont, selectedColor, e.Bounds, fmt)
    Else
      e.Graphics.DrawString(tbc.TabPages(e.Index).Text, _
                            normalFont, normalColor, e.Bounds, fmt)
    End If
  End Sub

  Private Sub Form1_SizeChanged(ByVal sender As Object, _
                                ByVal e As System.EventArgs) Handles Me.SizeChanged
    Dim tbc As TabControl = $tabControlName$
    tbc.ItemSize = New Size((tbc.Width - 4) \ tbc.TabPages.Count, tbc.ItemSize.Height)
  End Sub]]>
    </Code>
  </Snippet>
</CodeSnippet>
</CodeSnippets>

Portiamo il tutto su PrimiPassi
Aperto il progetto e l'editor sul codice della FrmClienti, pongo il cursore nella Region dei metodi di evento e inserisco lo snippet con CTRL+K+X, seguendo l'albero degli snippet proposti fino a quello appena creato.

  Private Sub FrmClienti_SizeChanged(ByVal sender As Object, _
                                ByVal e As System.EventArgs) Handles Me.SizeChanged

L'unica modifica da fare è 'estetica': la parola Form1 nella firma per l'evento SizeChanged della form.

Altri perfezionamenti
C'è ancora qualche ritocco da fare, alla FrmClienti (è così tanto 'piena' che Diego s'è scordato più di qualcosa e Oscar gli ha fatto la lista dei peccati e delle relative penitenze :o)).

Innanzitutto, già che stiamo parlando dell'interfaccia della form, sono da allargare e quindi riposizionare i pulsanti sotto le griglie. Ecco un'occasione per apprezzare la Layout toolbar di Visual Studio, anche per il successivo punto.

Poi bisogna rimettere in ordine l'ordine di tabulazione (che dev'essere quello 'naturale', un po' sconvolto dopo i vari rifacimenti di Diego).

Nel codice, è stata aggiunta la riga:

    If mOperazione = TipoOperazione.Nessuna Then Exit Sub

ai metodi BtnCancrigaS_Click e BtnCancrigaR_Click per evitare che l'utente possa cancellare righe senza essere in fase di inserimento o di modifica record.

Nel metodo PopolaCombo, cambia il testo del comando:

    cmd.CommandText = "SELECT DISTINCT Stato FROM Stati ORDER BY Stato"

per evitare di visualizzare doppioni.

Nel metodo CmbBanca_Leave mancava la clausola Handles:

  Private Sub CmbBanca_Leave(ByVal sender As System.Object, _
                             ByVal e As System.EventArgs) Handles CmbBanca.Leave

Ed erano errati gli indici, nella valorizzazione finale:

        'Valorizzo i campi dell'agenzia, dell'ABI e del CAB
        TxtAgenzia.Text = campiChiave(0)
        TxtABI.Text = campiChiave(1)
        TxtCAB.Text = campiChiave(2)

Infine, è saltato fuori un codice scorretto in una delle prime classi sviluppate (abbastanza strano, per la meticolosità di Diego :o), così imparate a non considerarlo più un guru, un genio et similia): il metodo PulisciComboBox della classe SvuotaControlli del progetto APP.UI 'svuotava' il controllo con String.Empty. Invece il codice deve essere questo:

  Public Sub PulisciComboBox(ByVal ctl As Control)
    Dim Combo As ComboBox = TryCast(ctl, ComboBox)

    If Combo IsNot Nothing Then
      Combo.SelectedIndex = -1
    ElseIf ctl.Controls.Count > 0 Then
      For k As Integer = 0 To ctl.Controls.Count - 1
        'Ricorsione
        PulisciComboBox(ctl.Controls(k))
      Next k
    End If
  End Sub

Conclusione
In questo articolo si è illustrata una tecnica di implementazione della modalità OwnerDraw per il TabControl della FrmClienti, alla quale sono state apportate altre modifiche, anche nel codice, che ovviano a una serie di piccoli difetti riscontrati da Oscar.

Il codice di PrimiPassi sviluppato fino a questo momento è come al solito disponibile in area download.
Anche per questa puntata, Diego mette a disposizione nel suo blog un post cui scrivere critiche, suggerimenti, richieste di chiarimento.