Un controllo con interfaccia simile al ComboBox - 2
a cura di Stefano Castelli (requisiti: nessuno)

Premessa
In questo articolo costruiremo un controllo che erediti da quello appositamente sviluppato nella prima parte e che rimpiazzi il DateTimePicker, con in più diverse caratteristiche, in particolare quella di accettare date nulle.

Prima analisi
In un drammatico sforzo di fantasia ho deciso di chiamare tale controllo DatePick.

Partial
 Class DatePick
   Inherits ComboUI

Una volta ereditato da ComboUI, potrei creare un oggetto MonthCalendar all'interno di DatePick con l'idea di mostrarlo/nasconderlo quando l'utente clicca sul bottone di ComboUI, ma purtroppo non funzionerebbe.

Inanzitutto, per poter visualizzare il calendario dovremmo aumentare le proprietà Height e Width del controllo in modo da includere il calendario. Ma questo non sarebbe di aiuto perché anche i controlli interni _TBox e _Btn sarebbero ridimensionati e il calendario rimarrebbe comunque non visibile.

Ma molto più grave sarebbe il fatto che il controllo verrebbe in parte nascosto, qualora le sue dimensioni fossero tali da andare oltre quelle del contenitore (sia esso un form, un Groupbox ecc.). Per esempio, nella figura seguente, un oggetto ListBox è all'interno di un contenitore GroupBox. Le dimensioni della ListBox sono tali che parte di essa rimane nascosta dal contenitore.

Questo è un comportamento che voglio evitare. Come? Non sto a farla lunga e vado subito alla soluzione. Useremo un Form, che creeremo all'interno di DatePick e sul quale piazzeremo un controllo MonthCalendar. Naturalmente tale form non avrà bordi e barra del titolo. Ma anche dopo avergli assegnato (o tolto) queste caratteristiche dovremo stare attenti perché il form sarà indipendente dal controllo. Per esempio, se viene spostato il form dove si trova il controllo, il form che alloggia il MonthCalendar non si muove. Per cui dovremo fare in modo che non appena il form che contiene il calendario viene disattivato, esso venga anche chiuso.

Inizio sviluppo
Allora, iniziamo con il dichiarare un oggetto di tipo Form ed un oggetto di tipo MonthCalendar nel nostro componente.

  
 Private WithEvents _Form As Form
  Private WithEvents _Cal As MonthCalendar
  Dim _Visible As Boolean

La variabile _Visible naturalmente ci dirà se il form è visibile o no. La preparazione e visualizzazione del form è delegata alla procedura dtButtonClick, richiamata quando l'utente clicca sul pulsante dell'oggetto base ComboUI. Quando questo avviene, viene generato un evento ButtonClick, e la suddetta procedura viene quindi eseguita:

  Private Sub BtnClick(ByVal e As System.EventArgs) Handles Me.ButtonClick
    dtButtonClick()
  End Sub

In questa procedura, il form viene preparato, posizionato e visualizzato.

  Private Sub dtButtonClick()
    Dim Point As Point

    If _Visible = False Then
      _Form = New Form
      _Cal = New MonthCalendar

      With _Form

        .FormBorderStyle = FormBorderStyle.None
        .ShowInTaskbar = False

        .KeyPreview = True ' to catch the ESC key

        .StartPosition = FormStartPosition.Manual
        .ShowIcon = False
        .TopMost = True

        'Trova le coordinate dell'angolo basso a sinistra
        'rispetto al sistema di coordinate dello schermo
        Point = Me.PointToScreen(New Point(0, Me.Height))

        'posiziona _Form nella posizione dello schermo
        'equivalente al punto sopra menzionato
        .Top = Point.Y
        .Left = Point.X


        .Height = _Cal.Height
        .Width = _Cal.Width

        .Controls.Add(_Cal)

        If _OpenCalOnSetDate Then
          If IsDate(Me.Text) AndAlso CDate(Me.Text) <> NULL_DATE Then
            _Cal.SetDate(CDate(Me.Text))
          End If
        End If

        .Show()
      End With

    End If

  End Sub

Vi sono poi le procedure che gestiscono la visibilità/invisibilità del form. La più importante è FrmDeactivate, eseguita quando il form viene deattivato, dove esso viene rimosso.

  Private Sub BtnGotFocus(ByVal e As System.EventArgs) Handles Me.ButtonGotFocus
    Try

      If _Form.Visible Then
        _Visible = True
      Else
        _Visible = False
      End If

    Catch ex As Exception
      _Visible = False
    End Try
  End Sub

  Private Sub BtnMouseUp(ByVal e As System.Windows.Forms.MouseEventArgs) Handles Me.ButtonMouseUp
    _Visible = Not _Visible
  End Sub

  Private Sub FrmKeyPress(ByVal sender As Object, _
                          ByVal e As System.Windows.Forms.KeyPressEventArgs) _
                          Handles _Form.KeyPress

    If e.KeyChar = Chr(Keys.Escape) Then
      _Form.Dispose()
      Application.DoEvents()
      _Visible = False
    End If
  End Sub

  Private Sub FrmDeactivate(ByVal sender As Object, _
                            ByVal e As System.EventArgs) _
                            Handles _Form.Deactivate
    _Visible = False

    _Form.Dispose()
    Application.DoEvents()
  End Sub

Proprietà Specifiche
Per rendere il controllo più flessibile e facilitarne l'uso, si aggiungono alcune proprietà che determineranno alcuni aspetti del comportamento del controllo:

    With _Form

      '...

      If _OpenCalOnSetDate Then
        If IsDate(Me.Text) AndAlso CDate(Me.Text) <> NULL_DATE Then
          _Cal.SetDate(CDate(Me.Text))
        End If
      End If
 
      .Show()
    End With

NULL_DATE è una costante che contiene il valore da considerare nullo per le date, cioè il valore minimo (DateTime.MinValue):

  Private Const NULL_DATE As DateTime = #12:00:00 AM# 'DateTime.MinValue

Trasferimento della data selezionata.
Quando l'utente seleziona una data dal MonthCalendar, questa viene trasferita nella proprietà Text di ComboUI. Per determinare quando la data viene selezionata, esiste il comodo evento MonthCalendar.DateSelected, che viene generato ogni volta che l'utente clicca su un numero che rappresenta uno dei giorni.

E questo andrebbe benissimo se volessimo che la data venga trasferita con un singolo click del mouse. Ma se vogliamo che il trasferimento avvenga quando l'utente fa doppio click dobbiamo usare una tecnica un po' più raffinata, perché sfortunatamente l'oggetto MonthCalendar è sprovvisto dell'evento MouseDoubleClick.
Dobbiamo quindi emulare l'evento MouseDoubleClick, e dobbiamo farlo quando l'utente clicca su uno dei numeri.

Iniziamo allora a vedere come determinare in quale zona l'utente ha cliccato.
L'oggetto MonthCalendar ha un metodo chiamato HitTest che (cito MSDN) "restituisce una classe MonthCalendar.HitTestInfo contenente informazioni sulla parte di un controllo di calendario mensile che si trova nella coordinata xy specificata."
La classe HitTestInfo a sua volta restituisce la proprietà HitArea (di tipo enumerativo HitArea) che contiene informazioni su quale parte del calendario è stata cliccata (di nuovo, cito MSDN su HitArea):

Nome membro Descrizione
CalendarBackground Il punto specificato fa parte dello sfondo del calendario.
Date Il punto specificato si trova su una data all'interno del calendario. La proprietà Time dell'oggetto MonthCalendar.HitTestInfo è impostata sulla data in corrispondenza del punto specificato.
DayOfWeek Il punto specificato si trova sull'abbreviazione di un giorno, ad esempio "Fri". La proprietà Time dell'oggetto MonthCalendar.HitTestInfo è impostata su 1° gennaio 0001.
NextMonthButton Il punto specificato si trova sul pulsante presente nell'angolo superiore destro del controllo. Se l'utente fa clic su questo punto, viene eseguito lo scorrimento del calendario mensile, in modo da visualizzare il mese o l'insieme di mesi successivo.
NextMonthDate Il punto specificato si trova su una data del mese successivo, visualizzato parzialmente all'inizio del mese corrente. Se l'utente fa clic su questo punto, viene eseguito lo scorrimento del calendario mensile, in modo da visualizzare il mese o l'insieme di mesi successivo.
Nowhere Il punto specificato non si trova sul controllo calendario mensile o si trova su una parte inattiva del controllo.
PrevMonthButton Il punto specificato si trova sul pulsante presente nell'angolo superiore sinistro del controllo. Se l'utente fa clic su questo punto, viene eseguito lo scorrimento del calendario mensile, in modo da visualizzare il mese o l'insieme di mesi precedente.
PrevMonthDate Il punto specificato si trova su una data del mese precedente, visualizzato parzialmente all'inizio del mese corrente. Se l'utente fa clic su questo punto, viene eseguito lo scorrimento del calendario mensile, in modo da visualizzare il mese o l'insieme di mesi precedente.
TitleBackground Il punto specificato si trova sullo sfondo del titolo di un mese.
TitleMonth Il punto specificato si trova nella barra del titolo di un mese, sul nome di un mese.
TitleYear Il punto specificato si trova nella barra del titolo di un mese, sul valore dell'anno.
TodayLink Il punto specificato si trova sul collegamento alla data odierna, nella parte inferiore del controllo calendario mensile.
WeekNumbers Il punto specificato si trova su un numero di settimana. Questa situazione si verifica solo se la proprietà ShowWeekNumbers dell'oggetto MonthCalendar è attivata. La proprietà Time dell'oggetto MonthCalendar.HitTestInfo è impostata sulla data corrispondente nella prima colonna a sinistra.

Creeremo allora una variabile globale di tipo HitArea il cui valore verrà assegnato nell'evento Mousedown del calendario.

  Private _HitArea As MonthCalendar.HitArea

  Private Sub CalMouseDown(ByVal sender As Object, _
                           ByVal e As System.Windows.Forms.MouseEventArgs) _
                           Handles _Cal.MouseDown

    Dim hti As MonthCalendar.HitTestInfo = _Cal.HitTest(e.X, e.Y)
    _HitArea = hti.HitArea
  End Sub

Useremo poi la variabile _HitArea nell'evento MouseUp, perché è lì che controlleremo se si è verificato un DoubleClick.
L'idea per questa verifica è semplice: si intercetta l'evento MouseUp e se si riscontra che esso accade entro un certo intervallo da un precedente evento MouseUp significa che l'utente ha fatto DoubleClick.

Per determinare l'intervallo di tempo, useremo la proprietà Tick dell'oggetto Now. Tick restituisce il numero di intervalli della durata di 10 microsecondi trascorsi dal 1 Gennaio 0001. Ci sono 10000 Tick in un millisecondo, quindi si tratta di una misura di tempo molto precisa.

Il numero massimo di millisecondi che possono trascorrere tra due click per poter essere considerati come un doubleclick è ritrovabile nella proprietà DoubleClickTime dell'oggetto SystemInformation.
Basterà allora controllare che questo numero moltiplicato per 10000 sia maggiore del numero di Tick trascorsi tra due click consecutivi. Detto ciò, il seguente codice dovrebbe essere facilmente comprensibile:

  Private Sub CalMouseUp(ByVal sender As Object, _
                         ByVal e As System.Windows.Forms.MouseEventArgs) _
                         Handles _Cal.MouseUp

    If _HitArea = MonthCalendar.HitArea.Date Then

      If e.Button = MouseButtons.Left Then
        If Not _LastClickRaisedDoubleClick _
          AndAlso Now.Ticks - _LastClickTime <= SystemInformation.DoubleClickTime * 10000 Then

          If _TransferOnDblClick Then
            TransferData()
          End If

          _LastClickRaisedDoubleClick = True
        Else
          _LastClickRaisedDoubleClick = False
        End If
        _LastClickTime = Now.Ticks
      End If

      If Not _TransferOnDblClick Then
        TransferData()
      End If
    End If
  End Sub

Un'ultima nota sulla Sub TransferData. La data selezionata viene visualizzata usando il formato "dd-MMM-yyyy". La ragione per questa scelta è dovuta al fatto che devo fare contente persone abituate ad avere date in formato mm/dd/yyyy e persone abituate ad avere date in formato dd/mm/yyyy. Con il formato "dd-MMM-yyyy" sembra che siano tutte contente.

  Private Sub TransferData()

    Try
      Me.Text = _Cal.SelectionEnd.ToString("dd-MMM-yyyy")

    Catch ex As Exception

    End Try

    If _CloseCalendarAfterTransfer Then
      _Form.Dispose()
      Application.DoEvents()
      _Visible = Not _Visible
    End If
  End Sub

Verifica della data nulla
Nel form che uso per testare il controllo, intercetto l'evento TextChanged per verificare se la data restituita dal DatePick è nulla:

  Private Sub DatePick1_TextChanged(ByVal sender As Object, _
                                    ByVal e As System.EventArgs) Handles DatePick1.TextChanged

    If DatePick1.Value = DateTime.MinValue Then
      TextBox1.Text = "data nulla"
    Else
      TextBox1.Text = "data cambiata"
    End If
  End Sub

Conclusione
Adesso ho un DateTimePicker in cui l'utente può svuotare la casella e ottenere quindi una data nulla.
Il codice sorgente allegato a questo articolo è scaricabile dall'area download.
Per chiarimenti, critiche, suggerimenti, scrivete all'autore.