Search code examples
c#wpftextboxcustom-controls

WPF Custom Text Box Control - Focus & Mouse Over


I am creating a Custom Control to create a particular TextBox style for my application: Textbox Style

Here is the resource dictionary which I have created to get the expected layout:

<Style TargetType="{x:Type ctrl:CustomTextBox}">

<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource TextBox_Border_Empty}" />
<Setter Property="Foreground" Value="{DynamicResource TextBox_Label_Empty}" />

<Setter Property="Template">

  <Setter.Value>

    <ControlTemplate TargetType="{x:Type ctrl:CustomTextBox}">

      <Border Background="{TemplateBinding Background}"
              BorderBrush="{TemplateBinding BorderBrush}"
              BorderThickness="1"
              Focusable="False"
              CornerRadius="4"
              Cursor="IBeam"
              Margin="16,8"
              MinHeight="{DynamicResource TextBox_Height}"
              Padding="20,10,3,10"
              x:Name="bdrBorder">

        <Grid>

          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="Auto" />
          </Grid.ColumnDefinitions>

          <StackPanel Background="{TemplateBinding Background}"
                      Grid.Column="0"
                      HorizontalAlignment="Stretch"
                      Orientation="Vertical"
                      VerticalAlignment="Center">

            <Label Background="{TemplateBinding Background}"
                   Content="{TemplateBinding Label}"
                   Focusable="False"
                   FontFamily="{DynamicResource Inter600}"
                   FontSize="12"
                   Foreground="{TemplateBinding Foreground}"
                   IsHitTestVisible="False"
                   Margin="3,0,0,0"
                   Padding="0,3"
                   Visibility="{TemplateBinding LabelVisibility}"
                   x:Name="lblHeading"/>

            <TextBox Background="Transparent"
                     BorderBrush="{TemplateBinding Background}"
                     BorderThickness="0"
                     Cursor="IBeam"
                     Focusable="True"
                     FontFamily="{DynamicResource Inter400}"
                     FontSize="16"
                     Foreground="{DynamicResource Theme_Foreground}"  
                     Margin="0"
                     MaxLength="{TemplateBinding MaxLength}"
                     Text="{TemplateBinding Text}"
                     TextWrapping="Wrap"
                     x:Name="txtContent" />

          </StackPanel>

          <ContentControl Focusable="False"
                          Grid.Column="1"
                          HorizontalAlignment="Center"
                          Margin="5,0"
                          Style="{DynamicResource ErrorIcon_Canvas}"
                          ToolTip="{TemplateBinding ValidationMessage}"
                          VerticalAlignment="Center"
                          x:Name="cvsError" />

          <Button Command="{TemplateBinding ButtonCommand}"
                  Content="{TemplateBinding ButtonLabel}"
                  Cursor="Hand"
                  Grid.Column="2"
                  IsEnabled="True"
                  Margin="5,0"
                  Padding="10,7"
                  Style="{DynamicResource TextBoxWithBorder_Button}"
                  VerticalAlignment="Center"
                  Visibility="{TemplateBinding ButtonVisibility}"
                  x:Name="btnAction"/>

        </Grid>

      </Border>

      <ControlTemplate.Triggers>

        <Trigger Property="HasBorder" Value="False">
          <Setter TargetName="bdrBorder" Property="BorderBrush" Value="Transparent" />
        </Trigger>

        <MultiTrigger>
          <MultiTrigger.Conditions>
            <Condition Property="HasBorder" Value="True"/>
            <Condition Property="IsValid" Value="False" />
          </MultiTrigger.Conditions>
          <Setter TargetName="bdrBorder" Property="BorderBrush" Value="{DynamicResource Theme_ErrorBrush}" />
        </MultiTrigger>

        <Trigger Property="IsValid" Value="False">
          <Setter TargetName="cvsError" Property="Visibility" Value="Visible" />
          <Setter TargetName="lblHeading" Property="Foreground" Value="{DynamicResource Theme_ErrorBrush}" />
        </Trigger>

        <Trigger Property="IsValid" Value="True">
          <Setter TargetName="cvsError" Property="Visibility" Value="Collapsed" />
        </Trigger>

        <MultiTrigger>
          <MultiTrigger.Conditions>
            <Condition Property="ShowText" Value="False" />
            <Condition Property="IsMouseOver" Value="True" />
          </MultiTrigger.Conditions>
          <Setter TargetName="lblHeading" Property="FontSize" Value="12" />
          <Setter TargetName="txtContent" Property="Visibility" Value="Visible" />
        </MultiTrigger>

        <MultiTrigger>
          <MultiTrigger.Conditions>
            <Condition Property="ShowText" Value="False" />
            <Condition Property="IsMouseOver" Value="False" />
          </MultiTrigger.Conditions>
          <Setter TargetName="lblHeading" Property="FontSize" Value="14" />
          <Setter TargetName="txtContent" Property="Visibility" Value="Collapsed" />
        </MultiTrigger>
        
      </ControlTemplate.Triggers>

    </ControlTemplate>

  </Setter.Value>

</Setter>

Here is the code for the CustomTextBox (c#):

public class CustomTextBox : ContentControl
{
#region - Border -

public static readonly DependencyProperty HasBorderProperty =
    DependencyProperty.Register(nameof(HasBorder), typeof(bool), typeof(CustomTextBox),
        new PropertyMetadata(true, OnHasBorderChanged));

public bool HasBorder
{
    get => (bool)GetValue(HasBorderProperty);
    set => SetValue(HasBorderProperty, value);
}

private static void OnHasBorderChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.HasBorder = (bool)e.NewValue;
}

#endregion - Border -

#region - Button -

public static readonly DependencyProperty ButtonCommandProperty =
    DependencyProperty.Register(nameof(ButtonCommand), typeof(ICommand), typeof(CustomTextBox),
        new PropertyMetadata(null!, OnButtonCommandChanged));

public ICommand ButtonCommand
{
    get => (ICommand)GetValue(ButtonCommandProperty);
    set => SetValue(ButtonCommandProperty, value);
}

private static void OnButtonCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.ButtonCommand = (ICommand)e.NewValue;
}

public static readonly DependencyProperty ButtonLabelProperty =
    DependencyProperty.Register(nameof(ButtonLabel), typeof(string), typeof(CustomTextBox),
        new PropertyMetadata(string.Empty, OnButtonLabelChanged));

public string ButtonLabel
{
    get => (string)GetValue(ButtonLabelProperty);
    set => SetValue(ButtonLabelProperty, value);
}

private static void OnButtonLabelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
    {
        ctrl.ButtonLabel = (string)e.NewValue;
        ctrl.SetButtonVisibility();
    }
}

public static readonly DependencyProperty ButtonVisibilityProperty =
    DependencyProperty.Register(nameof(ButtonVisibility), typeof(Visibility), typeof(CustomTextBox),
        new PropertyMetadata(Visibility.Collapsed, OnButtonVisibilityChanged));

public Visibility ButtonVisibility
{
    get => (Visibility)GetValue(ButtonVisibilityProperty);
    private set => SetValue(ButtonVisibilityProperty, value);
}

private static void OnButtonVisibilityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.ButtonVisibility = (Visibility)e.NewValue;
}

private void SetButtonVisibility() => ButtonVisibility = string.IsNullOrWhiteSpace(ButtonLabel) ? Visibility.Collapsed : Visibility.Visible;

#endregion - Button -

#region - Label -

public static readonly DependencyProperty LabelProperty =
    DependencyProperty.Register(nameof(Label), typeof(string), typeof(CustomTextBox),
        new PropertyMetadata(string.Empty, OnLabelChanged));

public string Label
{
    get => (string)GetValue(LabelProperty);
    set => SetValue(LabelProperty, value);
}

private static void OnLabelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
    {
        ctrl.Label = (string)e.NewValue;
        ctrl.SetLabelVisibility();
    }
}

public static readonly DependencyProperty LabelVisibilityProperty =
    DependencyProperty.Register(nameof(LabelVisibility), typeof(Visibility), typeof(CustomTextBox),
        new PropertyMetadata(Visibility.Collapsed, OnLabelVisibilityChanged));

public Visibility LabelVisibility
{
    get => (Visibility)GetValue(LabelVisibilityProperty);
    private set => SetValue(LabelVisibilityProperty, value);
}

private static void OnLabelVisibilityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.LabelVisibility = (Visibility)e.NewValue;
}

private void SetLabelVisibility() => LabelVisibility = string.IsNullOrWhiteSpace(Label) ? Visibility.Collapsed : Visibility.Visible;

#endregion

#region - Text -

public static readonly DependencyProperty MaxLengthProperty =
    DependencyProperty.Register(nameof(MaxLength), typeof(int), typeof(CustomTextBox),
        new PropertyMetadata(int.MaxValue, OnMaxLengthChanged));

public int MaxLength
{
    get => (int)GetValue(MaxLengthProperty);
    set => SetValue(MaxLengthProperty, value);
}

private static void OnMaxLengthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.MaxLength = (int)e.NewValue;
}

public static readonly DependencyProperty TextProperty =
    DependencyProperty.Register(nameof(Text), typeof(string), typeof(CustomTextBox),
        new PropertyMetadata(string.Empty, OnTextChanged));

public string Text
{
    get => (string)GetValue(TextProperty);
    set => SetValue(TextProperty, value);
}

private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
    {
        ctrl.Text = (string)e.NewValue;
        ctrl.SetShowText();
    }
}

public static readonly DependencyProperty ShowTextProperty =
    DependencyProperty.Register(nameof(ShowText), typeof(bool), typeof(CustomTextBox),
        new PropertyMetadata(false, OnShowTextChanged));

public bool ShowText
{
    get => (bool)(GetValue(ShowTextProperty));
    private set => SetValue(ShowTextProperty, value);
}

private static void OnShowTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.ShowText = (bool)e.NewValue;
}

private void SetShowText() => ShowText = !string.IsNullOrWhiteSpace(Text);

#endregion

#region - Validation -

public static readonly DependencyProperty IsValidProperty =
    DependencyProperty.Register(nameof(IsValid), typeof(bool), typeof(CustomTextBox),
        new PropertyMetadata(true, OnIsValidChanged));

public bool IsValid
{
    get => (bool)GetValue(IsValidProperty);
    set => SetValue(IsValidProperty, value);
}

private static void OnIsValidChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
        ctrl.IsValid = (bool)e.NewValue;
}

public static readonly DependencyProperty ValidationMessageProperty =
    DependencyProperty.Register(nameof(ValidationMessage), typeof(string), typeof(CustomTextBox),
        new PropertyMetadata(string.Empty, OnValidationMessageChanged));

public string ValidationMessage
{
    get => (string)(GetValue(ValidationMessageProperty));
    set => SetValue(ValidationMessageProperty, value);
}

private static void OnValidationMessageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (d is CustomTextBox ctrl)
    {
        ctrl.ValidationMessage = (string)e.NewValue;
        ctrl.SetIsValid();
    }
}

private void SetIsValid() => IsValid = string.IsNullOrWhiteSpace(ValidationMessage);

#endregion - Validation -

}

I have a couple of issues with this code and I am looking for help resolving:

  1. When I mouse over the TextBox (txtContent) I do not see the IBeam, instead the mouse is set to the default Windows Arrow.

  2. My CustomTextBox I have had to make it implement a ContentControl rather than a TextBox, this is because I need to fire an event when Text is changed, but using a TextBox it seems that event will not fire, if anyone can give me advice on that I would be very happy too. The reason for this is when the TextBox.Text is empty, I want to hide the Texbox element (unless the control has focus or the mouse is over)

  3. When I click anywhere within the Border (bdrBorder) I want to set focus to the TextBox (txtContent) but the only way I can set focus is to click on the actual TextBox, is there a way to achieve this?

I have tried a number of different approaches, but am unable to achieve these things


Solution

    1. I have tested your code because what you described is not the default behavior. Unless you override the mouse cursor explicitly or disable hit testing for the TextBox the cursor must switch to the IBeam (this is handled by the TextBox internally). This said, I was not able to reproduce this issue. Cursor toggles correctly.
      However, instead I observed that you are only able to input text as long as the mouse is over the control (and therefore the control is expanded). As soon as the mouse drifts away while typing, the control collapsed to hide the TextBox and keyboard focus is also lost.
      To fix it I suggest to improve the MultiTrigger that toggles the Visibility of the TextBox by adding the condition IsKeyboardFocusWithin == false:
    <MultiTrigger>
      <MultiTrigger.Conditions>
        <Condition Property="ShowText"
                   Value="False" />
        <Condition Property="IsMouseOver"
                   Value="False" />
        <Condition Property="IsKeyboardFocusWithin"
                   Value="False" />
      </MultiTrigger.Conditions>
      <Setter TargetName="lblHeading"
              Property="FontSize"
              Value="14" />
      <Setter TargetName="txtContent"
              Property="Visibility"
              Value="Collapsed" />
    </MultiTrigger>
    
    1. In fact, extending the TextBox is the better way to go in my opinion. It eliminates all the delegation between the CustomTextBox and its internal TextBox. You find the default TextBox Style here at Microsoft Docs: TextBox ControlTemplate Example. You can add your controls to it. Then in the static constructor of your CustomTextBox you can override the metadata for the TextBox.TextProperty. Here you can register a Text dependency property changed callback. And you can configure the TextProperty to bind using the UpdateTrigger.PropertyChanged trigger by default (the native default is UpdateTrigger.LostFocus). Now, the registered callback is called every time the Text property changes:
    class CustomTextBox : TextBox
      static CustomTextBox()
      {
        TextProperty.OverrideMetadata(
          typeof(CustomTextBox),
          new FrameworkPropertyMetadata(
            string.Empty,
            FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.Journal,
            OnTextPropertyChanged,
            CoerceText, // set 'null' if don#T want to coerce the input
            true, // IsAnimationProhibited
            UpdateSourceTrigger.PropertyChanged // Override the DefaultUpdateSourceTrigger LostFocus
        ));
    
        // TODO::Create the default Style with target type CustomTextBox 
        // in the Generic.xaml file. Here you can add your label controls etc.
        DefaultStyleKeyProperty.OverrideMetadata(typeof(CustomTextBox), new FrameworkPropertyMetadata(typeof(CustomTextBox)));
      }
    
      private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyxChangedAgs d)
      {
        // TODO::Raise custom events etc.
      }
    }
    
    1. Extending TextBox solves the issue. However, for your current solution you can override the PreviewLeftMouseButtonUp event to move the focus explicitly:
    class CustomTextBox : ContentControl
    {  
      private TextBox PART_TextBox { get; set; }
    
      public override void OnApplyTemplate()
      {
        base.OnApplyTemplate();
    
        this.PART_TextBox = GetTemplateChild("txtContent") as TextBox;
      }
    
      protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
      {
        base.OnPreviewMouseLeftButtonUp(e);
    
        this.PART_TextBox?.Focus();
      }
    }
    

    Some remarks: Your ButtonCommand should be a RoutedCommand.

    All classes that extend Control support to display validation errors by default.