Silverlight: Watermarked control

Mi experiencia con Silverlight no está siendo todo lo placentera que me gustaría. Me estoy encontrado permanentemente en la necesidad de cambiar las cosas que presume por defecto esta tecnología, para adecuarla a mis necesidades.

Una de ellas ha sido la creación de un control Textbox con marca de agua. Buscando un poco por Internet el primer enlace redirige al blog de Tim Heuer, donde tenemos un ejemplo de implementación de este control.

El problema se plantea cuando se quiere utilizar este control para usarlo como control de password. No nos ofrece la posibilidad de enmascarar los caracteres. La solución podría ser algo como esto (no recuerdo si se me ocurrió a mí, lo leí por ahí o adapté algo que hubiera encontrado):

/// <summary>
/// Changes the visual state when the control changes its text.
/// </summary>
/// <param name="sender">Sender of the event.</param>
/// <param name="e">Event arguments.</param>>
private void OnTextChanged(object sender, TextChangedEventArgs e)
{
    if (this.IsPasswordBox == true)
    {
        TextBox textBox = sender as TextBox;
        if (textBox != null)
        {
            string newText = textBox.Text;

            // If we don't have text, clean the plain text back up.
            if (string.IsNullOrEmpty(newText) == true)
            {
                this.Plaintext = string.Empty;
                return;
            }

            // If the new text is shorter than the previous text, we simply cut the previous text
            if (newText.Length < this.Plaintext.Length)
            {
                this.Plaintext = this.Plaintext.Substring(0, newText.Length);
                return;
            }

            // We have an string longer than the previous one. We can have a new character in any place
            // inside the text, so we go along the string to find the new characters (probably only one).
            // This new character will not be the "hidden" character
            StringBuilder newBackup = new StringBuilder();

            // We will use the offset because every time we find a character in the new text that is clear,
            // it is a new character. For example, if the new character is in the 5º position, and the next
            // 6º character is hidden, it would be in the 5º position of the old text. So we use the offset
            // to count in which place the character is in the old text.
            int offset = 0;
            for (int i = 0; i < newText.Length; i++)
            {
                if (newText[i] == 'u25CF')
                {
                    // We add this character from the original plain text to the new one
                    newBackup.Append(this.Plaintext[i + offset]);
                }
                else
                {
                    // We add this character from the new text, because it's one of the new characters
                    newBackup.Append(newText[i]);

                    // We must update the offset
                    offset--;

                    // We must change this character in the new text for the hidden character.
                    newText = newText.Substring(0, i) + 'u25CF' + newText.Substring(i + 1, newText.Length - i - 1);
                }
            }

            // Set the masked text in the control
            textBox.Text = newText;

            // Move the caret to the end of the text
            textBox.SelectionStart = textBox.Text.Length;

            // Update the backup plain text
            this.Plaintext = newBackup.ToString();
        }
    }

    this.ChangeVisualState(true);
}

Por último, quedaría la validación del control. Por desgracia para los que no estamos muy informados sobre cómo funciona Silverlight, el ejemplo de Tim no incluía nada relacionado con la validación del control. Es decir, la validación en sí se realiza con mecanismos ajenos al control, como los Data Annotations. El problema es que, una vez validado el control y encontrado que no es correcta su información, es necesario que pase a un estado “No Válido” y que muestre alguna forma de error.

Después de darle un poco de vueltas y echarle un vistazo a estilos de otros controles por defecto de Silverlight, ésta es la solución que me está funcionando a las mil maravillas (esta vez, sí, cosecha propia 100%).

En primer lugar, el estilo para el control. Me voy a limitar a mostrar las partes directamente relacionadas con la validación, puesto que al final del artículo adjunto tanto el estilo del control como el código del mismo.

<VisualStateManager.VisualStateGroups>
    <VisualStateGroup x:Name="ValidationStates">
        <VisualState x:Name="Valid"/>
        <VisualState x:Name="InvalidUnfocused">
            <Storyboard>
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ValidationErrorElement" Storyboard.TargetProperty="Visibility">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <Visibility>Visible</Visibility>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
        </VisualState>
        <VisualState x:Name="InvalidFocused">
            <Storyboard>
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ValidationErrorElement" Storyboard.TargetProperty="Visibility">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <Visibility>Visible</Visibility>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="validationTooltip" Storyboard.TargetProperty="IsOpen">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <System:Boolean>True</System:Boolean>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
        </VisualState>
    </VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Border x:Name="ValidationErrorElement"
        Visibility="Collapsed"
        BorderBrush="#FFDB000C"
        BorderThickness="1"
        >
    <ToolTipService.ToolTip>
        <ToolTip x:Name="validationTooltip" Template="{StaticResource CommonValidationToolTipTemplate}"
            DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}}" Placement="Right"
            PlacementTarget="{Binding RelativeSource={RelativeSource TemplatedParent}}">
            <ToolTip.Triggers>
                <EventTrigger RoutedEvent="Canvas.Loaded">
                    <BeginStoryboard>
                        <Storyboard>
                            <ObjectAnimationUsingKeyFrames Storyboard.TargetName="validationTooltip" Storyboard.TargetProperty="IsHitTestVisible">
                                <DiscreteObjectKeyFrame KeyTime="0">
                                    <DiscreteObjectKeyFrame.Value>
                                        <System:Boolean>true</System:Boolean>
                                    </DiscreteObjectKeyFrame.Value>
                                </DiscreteObjectKeyFrame>
                            </ObjectAnimationUsingKeyFrames>
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </ToolTip.Triggers>
        </ToolTip>
    </ToolTipService.ToolTip>
    <Grid Height="12" HorizontalAlignment="Right"
          Margin="1,-4,-4,0" VerticalAlignment="Top"
          Width="12" Background="Transparent">
        <Path Fill="#FFDC000C" Margin="1,3,0,0" Data="M 1,0 L6,0 A 2,2 90 0 1 8,2 L8,7 z"/>
        <Path Fill="#ffffff" Margin="1,3,0,0" Data="M 0,0 L2,0 L 8,6 L8,8"/>
    </Grid>
</Border>

A destacar especialmente que hay dos partes claramente diferenciadas en el estilo:

  • La parte en la que se definen el grupo de estados “ValidationStates”, formado por los estados “Valid”, “InvalidUnfocused” y “InvalidFocused”. Con estos tres cubriríamos todas las posibilidades relacionadas con el estado del control y su validación.
  • La otra parte relevante del estilo es la definición de un elemento “ValidationErrorElement”, que junto al tooltip nos permite marcar, en este caso en rojo, el control que ha fallado y mostrar un mensaje con información del error.

La segunda parte de este truco consiste en definir las transacciones correspondientes entre los estados del control así como dispararlas ante eventos concretos que acontezcan en el control. En este caso, es necesario definir una propiedad “isErroneous” para indicar cuándo el control ha pasado al estado erróneo. Esta propiedad la controlaremos conectándonos al evento BindingValidationError, tal como se muestra en el siguiente extracto del código del control:

/// <summary>
/// Indicates if the control is in an erroneous state.
/// </summary>
private bool isErroneous;

/// <summary>
/// Initializes a new instance of the <see cref="WatermarkedTextBox"/> class.
/// </summary>
public WatermarkedTextBox()
{
    this.BindingValidationError += new EventHandler<ValidationErrorEventArgs>(WatermarkedTextBox_BindingValidationError);
}

El código que vamos a ligar al evento es tan sencillo como lo que podemos ver a continuación:

/// <summary>
/// Configures the properties of the control to indicate it has an error.
/// </summary>
/// <param name="sender">Sender of the event.</param>
/// <param name="e">Event arguments.</param>
private void WatermarkedTextBox_BindingValidationError(object sender, ValidationErrorEventArgs e)
{
    this.isErroneous = e.Action == ValidationErrorEventAction.Added ? true : false;
    this.ChangeVisualState(true);
}

Controlando la acción que acompaña como argumento al evento, podremos saber si la función ha saltado porque el control ha entrado o ha salido del estado erróneo. Toda la lógica, y por tanto lo relevante, queda relegado al método ChangeVisualState. Este método controlará las transacciones entre los distintos estados en los que puede estar el control. Un extracto de este método, específico para la parte de validación de errores, en el siguiente fragmento:

/// <summary>
/// Change to the correct visual state for the textbox.
/// </summary>
/// <param name="useTransitions">
/// true to use transitions when updating the visual state, false to
/// snap directly to the new visual state.
/// </param>
private void ChangeVisualState(bool useTransitions)
{

    // Update the ValidationStates group
    if (this.hasFocus && this.isErroneous)
    {
        VisualStateHelper.GoToState(this, useTransitions, VisualStateHelper.InvalidFocused, VisualStateHelper.InvalidUnfocused);
    }
    else if (this.isErroneous)
    {
        VisualStateHelper.GoToState(this, useTransitions, VisualStateHelper.InvalidUnfocused);
    }
    else
    {
        VisualStateHelper.GoToState(this, useTransitions, VisualStateHelper.StateValid);
    }
}

Cómo se puede ver, dependiendo de si el control tiene foco o no, se pasa a los distintos estados inválidos en caso de existir errores. Caso de no haberlos, se pasa a un estado de validez que desactiva los adornos de estilo informativos.

El resultado es un control que se comporta igual que los textbox por defecto de Silverlight, a pesar de ser totalmente personalizado. En cualquier caso, cambiar el aspecto que mostrará al entrar en estado erróneo es tan sencillo como modificar el estilo.

Ejemplo

Descargas:

PD: Perdonad los comentarios en inglés en el código, pero es para un proyecto de la empresa en la que trabajo y la pereza me impide ponerme a traducirlo todo :)