miércoles, 27 de mayo de 2015

Windows Forms. TextBox con sugerencias (III) - Personalizar las sugerencias

Este artículo es continuación de:
Windows Forms. TextBox con sugerencias (I) - Creando el control
Windows Forms. TextBox con sugerencias (II) - Creando el proyecto de pruebas

En los artículos anteriores he creado un control que emula la funcionalidad de mostrar sugerencias del TextBox estándar del Framework de .NET.

En este artículo mostraré cómo podemos modificar el control para que acepte como origen de datos para las sugerencias objetos de cualquier tipo y que el desarrollador pueda personalizar tanto el texto que se genera como sugerencia como el algoritmo para seleccionar las sugerencias a mostrar a partir del texto introducido por el usuario.




Lo primero que voy a hacer es definir las nuevas propiedades necesarias. Voy a modificar la propiedad SuggestDataSource para que acepte cualquier tipo de objeto como elementos del origen de datos, no sólo strings. Y definiré dos nuevas propiedades públicas:

  • MatchElement: permite proporcionar una función que recibe como parámetros un elemento de SuggestDataSource y una cadena con el texto introducido por el usuario. La función devuelve un valor true o false indicando si el elemento representa una sugerencia válida para el texto introducido.
  • TextFromElement: permite proporcionar una función que recibe como parámetro un elemento de SuggestDataSource y devuelve un texto que representa el elemento para mostrar como sugerencia.
Ambas funciones las almaceno en variables privadas de forma que, en caso de no haberse establecido ninguna función, las propiedades devuelven una función por defecto que establece la funcionalidad que ya teníamos implementada (un elemento es sugerencia de un texto si el texto del elemento empieza con la cadena introducida por el usuario y el texto del elemento es el propio elemento (si es un string) o el valor devuelto por el método ToString.

private Func<object, string, bool> _matchElement;
private Func<object, string> _textFromElement;


.....


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

public Func<object, string ,bool> MatchElement
{
    get
    {
        if (_matchElement == null)
        {
            if (SuggestDataSource.GetType().GetGenericArguments()[0].IsAssignableFrom(typeof(string)))
                return delegate(object element, string text) { 
                    return ((string)element).StartsWith(text, StringComparison.OrdinalIgnoreCase); 
                };
            else
                return delegate(object element, string text) { 
                    return element.ToString().StartsWith(text, StringComparison.OrdinalIgnoreCase); 
                };
        }
        else
            return _matchElement;
    }
    set
    {
        _matchElement = value;
    }
}

public Func<object, string> TextFromElement
{
    get
    {
        if (_textFromElement == null)
        {
            if (SuggestDataSource.GetType().GetGenericArguments()[0].IsAssignableFrom(typeof(string)))
                return delegate(object element) { return (string)element; };
            else
                return delegate(object element) { return element.ToString(); };
        }
        else
            return _textFromElement;
    }
    set
    {
        _textFromElement = value;
    }
}
    Private _matchElement As Func(Of Object, String, Boolean)
    Private _textFromElement As Func(Of Object, String)


.....


    Public Property SuggestDataSource As IEnumerable(Of Object)

    Public Property MatchElement() As Func(Of Object, String, Boolean)
        Get
            If _matchElement Is Nothing Then
                If SuggestDataSource.GetType().GetGenericArguments()(0).IsAssignableFrom(GetType(String)) Then
                    Return Function(element As Object, text As String)
                               Return CType(element, String).StartsWith(text, StringComparison.OrdinalIgnoreCase)
                           End Function
                Else
                    Return Function(element As Object, text As String)
                               Return element.ToString().StartsWith(text, StringComparison.OrdinalIgnoreCase)
                           End Function
                End If
            Else
                Return _matchElement
            End If
        End Get
        Set(ByVal value As Func(Of Object, String, Boolean))
            _matchElement = value
        End Set
    End Property

    Public Property TextFromElement() As Func(Of Object, String)
        Get
            If _textFromElement Is Nothing Then
                If SuggestDataSource.GetType().GetGenericArguments()(0).IsAssignableFrom(GetType(String)) Then
                    Return Function(element As Object)
                               Return CType(element, String)
                           End Function
                Else
                    Return Function(element As Object)
                               Return element.ToString()
                           End Function
                End If
            Else
                Return _textFromElement
            End If
        End Get
        Set(ByVal value As Func(Of Object, String))
            _textFromElement = value
        End Set
    End Property



A continuación simplemente tendremos que cambiar el método UpdateList para que haga el filtrado de resultados utilizando la función MatchElement y muestre el texto a partir de la función TextFromElement.

private void UpdateListBox() 
{
    if (SuggestDataSource != null && !string.IsNullOrEmpty(this.Text))
    {
        IEnumerable<string> result = SuggestDataSource
            .Where(item => MatchElement(item, this.Text)
                && !TextFromElement(item).Equals(this.Text, StringComparison.OrdinalIgnoreCase))
            .Select(item => TextFromElement(item))
            .OrderBy(s => s)
            .Take(MaxNumOfSuggestions);
        if (result.Count() > 0)
        {
            _listBox.DataSource = result.ToList();
            ShowListBox();
        }
        else
            HideListBox();
    }
    else
        HideListBox();
}
    Private Sub UpdateListBox()
        If SuggestDataSource IsNot Nothing AndAlso Not String.IsNullOrEmpty(Me.Text) Then
            Dim result As IEnumerable(Of String) = SuggestDataSource _
                .Where(Function(item) MatchElement(item, Me.Text) _
                    AndAlso Not TextFromElement(item).Equals(Me.Text, StringComparison.OrdinalIgnoreCase)) _
                .Select(Function(item) TextFromElement(item)) _
                .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 UpdateListBox()
        If SuggestDataSource IsNot Nothing AndAlso Not String.IsNullOrEmpty(Me.Text) Then
            Dim result As IEnumerable(Of String) = SuggestDataSource _
                .Where(Function(item) MatchElement(item, Me.Text) _
                    AndAlso Not TextFromElement(item).Equals(Me.Text, StringComparison.OrdinalIgnoreCase)) _
                .Select(Function(item) TextFromElement(item)) _
                .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

Para probar el control voy a modificar el formulario de prueba para que, cuando el usuario teclee un texto en el control, se muestren como sugerencias los productos que contengan las palabras tecleadas bien en el nombre, bien en el campo Color; independientemente del orden en que se introduzcan las palabras. Además en el texto sugerido se mostrará el nombre del producto junto con el color entre paréntesis, si tiene.

Para ello me voy a crear dos funciones:

  • SuggestionMatch: que recibe como parámetro un DataRow de la tabla Products y el texto introducido por el usuario. La función comprueba si todas las palabras introducidas por el usuario aparecen en el nombre o en el color y devuelve el resultado de la comprobación.
  • TextForSuggestion: recibe como parámetro un DataRow de la tabla Products y devuelve una cadena con el nombre del producto y el color entre paréntesis, si tiene.

private bool SuggestionMatch(object row, string userText)
{
    if (string.IsNullOrEmpty(userText)) return false;

    bool match = true;
    string[] words = userText.Split(' ');
    DataRow datarow = (DataRow)row;
    foreach (string word in words)
    {
        if (!datarow["Name"].ToString().ToUpper().Contains(word.ToUpper())
            && (datarow["Color"]==DBNull.Value 
            || !datarow["Color"].ToString().ToUpper().Contains(word.ToUpper())))
        {
            match = false;
            break;
        }
    }
    return match;
}

private string TextForSuggestion(object row)
{
    DataRow datarow = (DataRow)row;
    string color = "";
    if (datarow["Color"] != DBNull.Value)
        color = string.Format(" ({0})", datarow["Color"].ToString());
    return string.Format("{0}{1}", datarow["Name"].ToString(), color);
}
    Private Function SuggestionMatch(row As Object, userText As String)
        If String.IsNullOrEmpty(userText) Then Return False

        Dim match As Boolean = True
        Dim words As String() = userText.Split(" "c)
        Dim datarow As DataRow = CType(row, DataRow)
        For Each word As String In words
            If Not datarow("Name").ToString().ToUpper().Contains(word.ToUpper()) _
                AndAlso (datarow("Color") Is DBNull.Value _
                OrElse Not datarow("Color").ToString().ToUpper().Contains(word.ToUpper())) Then
                match = False
                Exit For
            End If
        Next
        Return match
    End Function

    Function TextForSuggestion(row As Object)
        Dim datarow As DataRow = CType(row, DataRow)
        Dim color As String = ""
        If (datarow("Color") IsNot DBNull.Value) Then
            color = String.Format(" ({0})", datarow("Color").ToString())
        End If
        Return String.Format("{0}{1}", datarow("Name").ToString(), color)
    End Function

Ahora simplemente tendremos que asignarle como origen de datos para sugerencias a nuestro control la colección de DataRows de la tabla Products. He indicarle, a través de las propiedades MatchElement y TextFromElement que debe utilizar estas dos funciones para obtener respectivamente las sugerencias a mostrar y el texto exacto a utilizar.

private void Form1_Load(object sender, EventArgs e)
{
    DataTable dtProductos = new DataTable("Products");
    dtProductos.ReadXml(Path.Combine(Application.StartupPath, @"Datos\Products.xml"));
    textSuggestion1.SuggestDataSource = dtProductos.Rows.Cast<DataRow>();
    textSuggestion1.MatchElement = SuggestionMatch;
    textSuggestion1.TextFromElement = TextForSuggestion;
}
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        Dim dtProductos As DataTable = New DataTable("Products")
        dtProductos.ReadXml(Path.Combine(Application.StartupPath, "Datos\Products.xml"))
        TextSuggestion1.SuggestDataSource = dtProductos.Rows.Cast(Of DataRow)()
        TextSuggestion1.MatchElement = AddressOf SuggestionMatch
        TextSuggestion1.TextFromElement = AddressOf TextForSuggestion
    End Sub

Y ya podemos arrancar la aplicación de prueba y comprobar el resultado.

Resultado final

También podríamos ir aún más allá para hacer que la función MatchElement, en lugar de devolver un valor booleano, devuelva un valor numérico indicando el grado de coincidencia del texto con el elemento. De esta forma podríamos ordenar el resultado de las sugerencias por este valor y mostrar primero la que mayor grado de coincidencia tenga con el texto introducido.

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

TextBox con sugerencias tipo Google. Ejemplos MSDN.

1 comentario:

  1. Muchas Gracias excelente aporte me a servido demasiado para el proyecto que actualmente estoy desarrollando espero algún día poder generar aportes tan buenos como los que proporcionas.

    ResponderEliminar