jueves, 21 de mayo de 2015

Windows Forms. TextBox con sugerencias (I) - Creando el control

El control TextBox incluido con en el Framework de .NET incorpora la opción de mostrar sugerencias mientras se escribe estableciendo la propiedad AutoCompleteMode al valor Suggest. Sin embargo la posibilidad de controlar los textos sugeridos es muy limitada, se limita a mostrar los textos que comienzan con el texto introducido por el usuario en el TextBox a partir de una colección de cadenas.

Sin embargo, ¿qué pasa si el criterio que queremos utilizar para seleccionar los textos a sugerir es otro? Por ejemplo si queremos implementar un control con sugerencias al estilo de las búsquedas de Google.


En este artículo, y los siguientes, voy a mostrar cómo podemos crear un control TextBox que nos permita elegir el algoritmo a utilizar para mostrar los textos sugeridos.

En este artículo crearé un control que reproduzca el mecanismo de sugerencias de un control TextBox estándar, y en los siguientes mostraré cómo podemos dar al desarrollador la posibilidad de personalizar el algoritmo para seleccionar los textos sugeridos.

Para empezar voy a crear un nuevo proyecto del tipo Biblioteca de Clases llamado SuggestionTextBox.

Crear proyecto SuggestionTextBox

A continuación le he añadido referencias a los ensamblados System.Drawing y System.Windows.Forms que necesitaré en el proyecto.

Agregar referencias al proyecto

Para introducir el código del control he eliminado la clase Class1 que Visual Studio crea por defecto y he añadido una nueva clase TextSuggestion que hereda de TextBox.

Lo primero que haremos será crear dos propiedades para configurar las sugerencias:

  • MaxNumOfSuggestions: que determinará el número máximo de resultados a mostrar
  • SuggestDataSource: que establecerá la colección de cadenas que utilizará el control para las sugerencias

using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace SuggestionTextBox
{
    public class TextSuggestion: TextBox
    {

        #region Propiedades públicas

        [DefaultValue(10)]
        public int MaxNumOfSuggestions { get; set; }

        public IEnumerable<string> SuggestDataSource { get; set; }

        #endregion
    }
}
Imports System.ComponentModel
Imports System.Windows.Forms
Imports System.Drawing

Public Class TextSuggestion
    Inherits TextBox

#Region "Propiedades públicas"

    <DefaultValue(10)> _
    Public Property MaxNumOfSuggestions As Integer

    Public Property SuggestDataSource As IEnumerable(Of String)

#End Region

End Class



Para mostrar los textos sugeridos voy a utilizar un control ListBox. Así que voy a crear dos variables privadas, una para contener el control ListBox y otra que indicará si el control ya ha sido inicializado.
Además crearé 3 métodos para controlar el funcionamiento del ListBox:

  • ShowListBox: que se encargará de inicializar el control ListBox si todavía no lo ha sido y lo muestra.
  • HideListBox: que se encargará de ocultar el control ListBox.
  • UpdateListBox: que actualizará el contenido del ListBox a partir del texto introducido en el TextBox, seleccionando las cadenas de SuggestDataSource que comiencen con el texto introducido.
Por último voy a crear un controlador para el evento Click del ListBox que se encargará de establecer el texto del TextBox con el contenido del elemento seleccionado y ocultará el control ListBox.

El constructor del control se encargará de inicializar la propiedad MaxNumOfSuggestions a su valor por defecto, iniciarlizar la variable _listBox y de asociar el controlador del evento Click al control ListBox.

        #region Variables privadas

        private ListBox _listBox;
        private bool _listBoxAddedToForm = false;

        #endregion

        #region Constructor

        public TextSuggestion(): base() 
        {
            MaxNumOfSuggestions = 10;
            _listBox = new ListBox();
            _listBox.Click += listBox_Click;
        }

        #endregion

        #region ListBox

        private void ShowListBox()
        {
            if (!_listBoxAddedToForm)
            {
                this.TopLevelControl.Controls.Add(_listBox);
                Point controlLocation = this.TopLevelControl.PointToClient(this.Parent.PointToScreen(this.Location));
                _listBox.Left = controlLocation.X;
                _listBox.Top = controlLocation.Y + this.Height;
                _listBox.Font = this.Font;
                _listBox.Width = this.Width;
                _listBox.MinimumSize = new Size(this.Width, _listBox.MinimumSize.Height);
                _listBox.Height = _listBox.ItemHeight * (MaxNumOfSuggestions + 1);
                _listBoxAddedToForm = true;
            }
            _listBox.Visible = true;
            _listBox.BringToFront();
        }

        private void HideListBox() { _listBox.Visible = false; }

        private void UpdateListBox() 
        {
            if (SuggestDataSource != null && !string.IsNullOrEmpty(this.Text))
            {
                IEnumerable<string> result = SuggestDataSource
                    .Where(s => s.StartsWith(this.Text, StringComparison.OrdinalIgnoreCase)
                        && !s.Equals(this.Text, StringComparison.OrdinalIgnoreCase))
                    .OrderBy(s => s)
                    .Take(MaxNumOfSuggestions);
                if (result.Count() > 0)
                {
                    _listBox.DataSource = result.ToList();
                    ShowListBox();
                }
                else
                    HideListBox();
            }
            else
                HideListBox();
        }

        private void listBox_Click(object sender, EventArgs e)
        {
            if (_listBox.SelectedIndex >= 0)
                Text = _listBox.SelectedItem.ToString();
            HideListBox();
        }

        #endregion
#Region "Variables privadas"

    Private _listBox As ListBox
    Private _listBoxAddedToForm As Boolean = False

#End Region

#Region "Constructor"

    Public Sub New()
        MaxNumOfSuggestions = 10
        _listBox = New ListBox()
        AddHandler _listBox.Click, AddressOf listBox_Click
    End Sub

#End Region

#Region "ListBox"

    Private Sub ShowListBox()
        If Not _listBoxAddedToForm Then
            Me.TopLevelControl.Controls.Add(_listBox)
            Dim controlLocation As Point _
                = Me.TopLevelControl.PointToClient(Me.Parent.PointToScreen(Me.Location))
            _listBox.Left = controlLocation.X
            _listBox.Top = controlLocation.Y + Me.Height
            _listBox.Font = Me.Font
            _listBox.Width = Me.Width
            _listBox.MinimumSize = New Size(Me.Width, _listBox.MinimumSize.Height)
            _listBox.Height = _listBox.ItemHeight * (MaxNumOfSuggestions + 1)
            _listBoxAddedToForm = True
        End If
        _listBox.Visible = True
        _listBox.BringToFront()
    End Sub

    Private Sub HideListBox()
        _listBox.Visible = False
    End Sub

    Private Sub UpdateListBox()
        If SuggestDataSource IsNot Nothing AndAlso Not String.IsNullOrEmpty(Me.Text) Then
            Dim result As IEnumerable(Of String) = SuggestDataSource _
                .Where(Function(s) s.StartsWith(Me.Text, StringComparison.OrdinalIgnoreCase) _
                    AndAlso Not s.Equals(Me.Text, StringComparison.OrdinalIgnoreCase)) _
                .OrderBy(Function(s) s) _
                .Take(MaxNumOfSuggestions)
            If result.Count() > 0 Then
                _listBox.DataSource = result.ToList()
                ShowListBox()
            Else
                HideListBox()
            End If
        Else
            HideListBox()
        End If
    End Sub

    Private Sub listBox_Click(sender As Object, e As EventArgs)
        If _listBox.SelectedIndex >= 0 Then
            Text = _listBox.SelectedItem.ToString()
        End If
        HideListBox()
    End Sub

#End Region

La parte más interesante de este código se encuentra en la inicialización del control ListBox. Lo que hago es añadir el ListBox al control especificado por la propiedad TopLevelControl (generalmente el formulario), de esta forma al mostrarse el ListBox no se verá limitado por el control contenedor del TextBox. A continuación establezco la posición del ListBox en la esquina inferior izquierda del TextBox y su ancho igual al ancho del TextBox. Cuando se muestra el ListBox llamo al método BringToFront para que se muestre por encima del resto de controles.

A continuación voy a añadir dos controladores para los eventos de teclado:

  • En el evento KeyDown controlo la pulsación de las teclas "Arriba" y "Abajo" para modificar el elemento seleccionado en el ListBox. También controla la pulsación de la tecla "Enter" para que, si existe un elemento del ListBox seleccionado lo establezca como texto del control.
  • En el evento KeyUp control si se ha modificado el texto y, si es así, actualizo los textos sugeridos a través del método UpdateListBox.

        #region Entrada de teclado

        private void this_KeyDown(object sender, KeyEventArgs e)
        {
            switch (e.KeyCode)
            {
                case Keys.Down:
                    {
                        if ((_listBox.Visible) && (_listBox.SelectedIndex < _listBox.Items.Count - 1))
                            _listBox.SelectedIndex++;
                        e.SuppressKeyPress = true;
                        break;
                    }
                case Keys.Up:
                    {
                        if (_listBox.Visible && _listBox.SelectedIndex >= 0)
                            _listBox.SelectedIndex--;
                        e.SuppressKeyPress = true;
                        break;
                    }
                case Keys.Enter:
                    {
                        if (_listBox.Visible)
                        {
                            if (_listBox.SelectedIndex >= 0)
                            {
                                Text = _listBox.SelectedItem.ToString();
                                SelectAll();
                            }
                            HideListBox();
                            e.SuppressKeyPress = true;
                        }
                        break;
                    }
            }
        }

        string _lastText;
        private void this_KeyUp(object sender, KeyEventArgs e)
        {
            if (this.Text != _lastText)
            {
                UpdateListBox();
                _lastText = this.Text;
            }
        }

        #endregion
#Region "Entrada de teclado"

    Private Sub this_KeyDown(sender As Object, e As KeyEventArgs)
        Select Case e.KeyCode
            Case Keys.Down
                If _listBox.Visible AndAlso _listBox.SelectedIndex < _listBox.Items.Count - 1 Then
                    _listBox.SelectedIndex += 1
                End If
                e.SuppressKeyPress = True
            Case Keys.Up
                If _listBox.Visible AndAlso _listBox.SelectedIndex >= 0 Then
                    _listBox.SelectedIndex -= 1
                End If
                e.SuppressKeyPress = True
            Case Keys.Enter
                If _listBox.Visible Then
                    If _listBox.SelectedIndex >= 0 Then
                        Text = _listBox.SelectedItem.ToString()
                        SelectAll()
                    End If
                    HideListBox()
                    e.SuppressKeyPress = True
                End If
        End Select
    End Sub

    Dim _lastText As String
    Private Sub this_KeyUp(sender As Object, e As KeyEventArgs)
        If Me.Text <> _lastText Then
            UpdateListBox()
            _lastText = Me.Text
        End If
    End Sub

#End Region

Por último voy a controlar el evento LostFocus para que no se dispare cada vez que el foco pase al control ListBox, y para ocultar el ListBox cuando efectivamente se produzca. También modificaré el constructor para asociar los controladores de eventos recién creados.

        #region Constructor

        public TextSuggestion(): base() 
        {
            MaxNumOfSuggestions = 10;
            _listBox = new ListBox();
            KeyDown += this_KeyDown;
            KeyUp += this_KeyUp;
            LostFocus += this_LostFocus;
            _listBox.Click += listBox_Click;
        }

        #endregion
        
        #region LostFocus

        protected override void OnLostFocus(EventArgs e)
        {
            if (!_listBox.ContainsFocus)
                base.OnLostFocus(e);
        }

        private void this_LostFocus(object sender, EventArgs e)
        {
            HideListBox();
        }

        #endregion        
#Region "Constructor"

    Public Sub New()
        MaxNumOfSuggestions = 10
        _listBox = New ListBox()
        AddHandler KeyDown, AddressOf this_KeyDown
        AddHandler KeyUp, AddressOf this_KeyUp
        AddHandler LostFocus, AddressOf this_LostFocus
        AddHandler _listBox.Click, AddressOf listBox_Click
    End Sub

#End Region

#Region "LostFocus"

    Protected Overrides Sub OnLostFocus(e As EventArgs)
        If Not _listBox.ContainsFocus Then
            MyBase.OnLostFocus(e)
        End If
    End Sub

    Private Sub this_LostFocus(sender As Object, e As EventArgs)
        HideListBox()
    End Sub

#End Region

Ya tenemos la funcionalidad básica del control. En las próximas entradas crearemos un proyecto sobre el que poder probar el control y le añadiremos la posibilidad de poder personalizar el método de selección de sugerencias:

El código completo de este artículo y los dos siguientes, tanto en C# como en Visual Basic, está disponible en:

TextBox con sugerencias tipo Google. Ejemplos MSDN.

2 comentarios:

  1. Asier, muchas gracias por tu aporte.

    Tengo una pregunta: ¿cómo puedo extraer el id del registro seleccionado?

    Juan Carlos

    ResponderEliminar
    Respuestas
    1. Me imagino que ya paso mucho tiempo, pero en cuanto lo solucione yo, que tambien estoy trabajando en eso, publicare la respuesta

      Eliminar