Search code examples
c#wpfmvvm

Handling DataObject.Pasting in TextBox with MVVM in WPF


To the DataObject.Pasting event in the Textbox I want to assign the TextBoxPasting function which is located in the viewmodel (MVVM pattern). Unfortunately the code does not work. I use the library:

xmlns:behaviours="http://schemas.microsoft.com/xaml/behaviors".

View - code:

         <TextBox Text="{Binding StartNumber, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"  FontSize="16" Height="27" Margin="10"/>
            <behaviours:Interaction.Triggers >
                <behaviours:EventTrigger EventName="DataObject.Pasting">
                    <behaviours:InvokeCommandAction x:Name="DataObjectPastingCommand" Command="{Binding DataObjectPastingCommand}" PassEventArgsToCommand="True"/>
                </behaviours:EventTrigger>
            </behaviours:Interaction.Triggers>

ViewModel - code:

public class MechanicViewModel : ViewModelBase, IMechanicViewModel
    {
        private static readonly Regex _regex = new Regex("[^0-9.-]+");

        public MechanicViewModel()
        {
            DataObjectPastingCommand = new DelegateCommand<DataObjectPastingEventArgs>(TextBoxPasting);
        }

        public DelegateCommand<DataObjectPastingEventArgs> DataObjectPastingCommand { get; private set; }

        private static bool IsTextAllowed(string text)
        {
            return !_regex.IsMatch(text);
        }

        private void TextBoxPasting(DataObjectPastingEventArgs e)
        {
            if (e.DataObject.GetDataPresent(typeof(string)))
            {
                string text = (string)e.DataObject.GetData(typeof(string));
                if (!IsTextAllowed(text))
                {
                    e.CancelCommand();
                }
            }
            else
            {
                e.CancelCommand();
            }
        }
    }

Solution

  • Your question explicitly asks for an MVVM compliant way to handle attached UI events (or UI events in general) in the application view model. There is none. Per definition, the view model must not participate in UI logic. There is no reason for the view model to ever handle UI events. All the event handling must take place in the view.

    The following UML diagram shows that the view model has to be view agnostic. That's a crucial requirement of the MVVM design pattern.

    enter image description here

    View agnostic of course means that the view model is not allowed to actively participate in UI logic.

    While it is technically possible to handle UI events in the application view model, MVVM forbids that and requires that such events are handled in the application view (see the UML diagram). If you want to implement MVVM correctly because you don't want to allow the UI to bleed into the application, then you must handle such events in the view (in C# aka code-behind aka partial classes).

    Copy & Paste are pure view operations:

    1. They involve the user.
    2. They exchange data between two UI elements
    3. The event args of a UI event is a RoutedEventArgs object.
    4. The RoutedEventArgs exposes the source UI elements (via the sender parameter of the delegate, via the Source and OriginalSource properties of the event args).
    5. The RoutedEventArgs enables the handler to directly participate in UI logic (e.g. by marking an event as handled or by cancelling the ongoing UI operation etc.).
    6. Routed events are always events that relate to UI interaction or UI behavior in context of UI logic.
    7. In general, why would the view model be interested in UI events if there is nothing like a UI or a u ser from the point of view of the view model?

    These are all hints that the event should not be handled in the view model. Routed events are always events that relate to UI interaction or UI behavior or render logic related - they are declared and raised by UI objects.
    The view model must not care about any UI elements e.g. when and how they're changing their size. The Button.Click event is the only event that should trigger an operation on the view model. Such elements usually implement ICommandSource to eliminate the event subscription. However, there is nothing wrong with handling the Click event in code-behind and delegating the operation to the view model from there.

    Passing all that UI objects to the view model (via the event args, e.g., the RoutedEventArgs.Source) is also a clear MVVM violation.
    Participating in UI logic (copy & paste) is another MVVM violation. You must either handle the Pasting event in the code-behind of a control or implement property validation by letting your view model class implement INotifyDataErrorInfo.

    From an UX point of view it is never a good idea to swallow a user action.
    You must always give feedback so that the user can learn that his action is not supported. If you can't prevent it in the first place e.g. by disabling interaction which is usually visualized by grayed out elements, then you must provide error information that explains why the action is not allowed and how to fix it. Data validation is the best solution.

    For example, when you fill in a registration form in a web application and enter/paste invalid data to the input field, your action is not silently swallowed.
    Instead, you get a red box around the input field and an error message. INotifyDataErrorInfo does exactly the same. And it does not violate MVVM.

    The generally recommended way to achieve your task of validating the user input is to implement INotifyDataErrorInfo. See How to add validation to view model properties or how to implement INotifyDataErrorInfo.

    A less elegant but still a valid MVVM solution is to implement the event handling of the DataObject.Pasting attached event along with the data validation in the view and cancelling the command from code-behind. Note that this solution can violate several UI design rules. You must at least provide proper error feedback to the user in case the pasted content is not accepted by the application.

    For your particular case, you should consider implementing a NumericTextBox.
    This is also an elegant solution where the input validation is completely handled by the input field. A control can provide data validation by implementing Binding validation e.g. defining a ValidationRule. You will get a convenient way to show e.g. a red box (customizable) and an error message to the user.

    When we categorize a validation in syntactical/formal (e.g., is the input numeric?) and semantical (e.g., is the entered person's age valid?) data validation, then we can implement the syntactical data validation at UI level in the input control. Semantical data validation, where we have to validate the input based on business rules must be implemented in the view model and model where such rules are known or defined to guarantee data integrity. Therefore, validating the input to enforce numeric input is a good candidate for being implemented in the view by the input control.

    An example NumericTextBox that also handles pasted content:

    NumericValidationRule.cs
    The ValidationRule used by the NumericTextBox to validate the input. The rule is also passed to the binding engine in order to enable the visual validation error feedback that is built into the framework.

    public class NumericValidationRule : ValidationRule
    {
      private readonly string nonNumericErrorMessage = "Only numeric input allowed.";
      private readonly string malformedInputErrorMessage = "Input is malformed.";
      private readonly string decimalSeperatorInputErrorMessage = "Only a single decimal seperator allowed.";
      private TextBox Source { get; }
    
      public NumericValidationRule(TextBox source) => this.Source = source;
    
      public override ValidationResult Validate(object value, CultureInfo cultureInfo)
      {
        ArgumentNullException.ThrowIfNull(cultureInfo, nameof(cultureInfo));
    
        if (value is not string textValue
          || string.IsNullOrWhiteSpace(textValue))
        {
          return new ValidationResult(false, this.nonNumericErrorMessage);
        }
    
        if (IsInputNumeric(textValue, cultureInfo))
        {
          return ValidationResult.ValidResult;
        }
    
        // Input was can still be a valid special character
        // like '-', '+' or the decimal seperator of the current culture
        ValidationResult validationResult = HandleSpecialNonNumericCharacter(textValue, cultureInfo);
    
        return validationResult;
      }
    
      private bool IsInputNumeric(string input, IFormatProvider culture) =>
        double.TryParse(input, NumberStyles.Number, culture, out _);
    
      private ValidationResult HandleSpecialNonNumericCharacter(string input, CultureInfo culture)
      {
        ValidationResult validationResult;
    
        switch (input)
        {
          // Negative sign is not the first character
          case var _ when input.LastIndexOf(culture.NumberFormat.NegativeSign, StringComparison.OrdinalIgnoreCase) != 0:
            validationResult = new ValidationResult(false, this.malformedInputErrorMessage);
            break;
    
          // Positivre sign is not the first character
          case var _ when input.LastIndexOf(culture.NumberFormat.PositiveSign, StringComparison.OrdinalIgnoreCase) != 0:
            validationResult = new ValidationResult(false, this.malformedInputErrorMessage);
            break;
    
          // Allow single decimal separator
          case var _ when input.Equals(culture.NumberFormat.NumberDecimalSeparator, StringComparison.OrdinalIgnoreCase):
          {
            bool isSingleSeperator = !this.Source.Text.Contains(culture.NumberFormat.NumberDecimalSeparator, StringComparison.CurrentCultureIgnoreCase);
            validationResult = isSingleSeperator ? ValidationResult.ValidResult : new ValidationResult(false, this.decimalSeperatorInputErrorMessage);
            break;
          }
          default:
            validationResult = new ValidationResult(false, this.nonNumericErrorMessage);
            break;
        }
    
        return validationResult;
      }
    }
    

    NumericTextBox.cs

    class NumericTextBox : TextBox
    {
      private ValidationRule NumericInputValidationRule { get; set; }
    
      static NumericTextBox()
        => DefaultStyleKeyProperty.OverrideMetadata(typeof(NumericTextBox), new FrameworkPropertyMetadata(typeof(NumericTextBox)));
    
      public NumericTextBox()
      {
        this.NumericInputValidationRule = new NumericValidationRule(this);
        DataObject.AddPastingHandler(this, OnContentPasting);
      }
    
      private void OnContentPasting(object sender, DataObjectPastingEventArgs e)
      {
        if (!e.DataObject.GetDataPresent(DataFormats.Text))
        {
          e.CancelCommand();
          ShowErrorFeedback("Only numeric content supported.");
    
          return;
        }
    
        string pastedtext = (string)e.DataObject.GetData(DataFormats.Text);
        CultureInfo culture = CultureInfo.CurrentCulture;
        ValidationResult validationResult = ValidateText(pastedtext, culture);
        if (!validationResult.IsValid)
        {
          e.CancelCommand();
        }
      }
    
      #region Overrides of TextBoxBase
    
      /// <inheritdoc />
      protected override void OnTextInput(TextCompositionEventArgs e)
      { 
        CultureInfo culture = CultureInfo.CurrentCulture;
        string currentTextInput = e.Text;
    
        // Remove any negative sign if '+' was pressed
        // or prepend a negative sign if '-' was pressed
        if (TryHandleNumericSign(currentTextInput, culture))
        {
          e.Handled = true;
          return;
        }
    
        ValidationResult validationResult = ValidateText(currentTextInput, culture);
        e.Handled = !validationResult.IsValid;
        if (validationResult.IsValid)
        {
          base.OnTextInput(e);
        }
      }
    
      #endregion Overrides of TextBoxBase
    
      private ValidationResult ValidateText(string currentTextInput, CultureInfo culture)
      {
        ValidationResult validationResult = this.NumericInputValidationRule.Validate(currentTextInput, culture);
    
        if (validationResult.IsValid)
        {
          HideErrorFeedback();
        }
        else
        {
          ShowErrorFeedback(validationResult.ErrorContent);
        }
    
        return validationResult;
      }
    
      private bool TryHandleNumericSign(string input, CultureInfo culture)
      {
        int oldCaretPosition = this.CaretIndex;
    
        // Remove any negative sign if '+' pressed
        if (input.Equals(culture.NumberFormat.PositiveSign, StringComparison.OrdinalIgnoreCase))
        {
          if (this.Text.StartsWith(culture.NumberFormat.NegativeSign, StringComparison.OrdinalIgnoreCase))
          {
            this.Text = this.Text.Remove(0, 1);
    
            // Move the caret to the original input position
            this.CaretIndex = oldCaretPosition - 1;
          }
    
          return true;
        }
        // Prepend the negative sign if '-' pressed
        else if (input.Equals(culture.NumberFormat.NegativeSign, StringComparison.OrdinalIgnoreCase))
        {
          if (!this.Text.StartsWith(culture.NumberFormat.NegativeSign, StringComparison.OrdinalIgnoreCase))
          {
            this.Text = this.Text.Insert(0, culture.NumberFormat.NegativeSign);
    
            // Move the caret to the original input position
            this.CaretIndex = oldCaretPosition + 1; 
          }
    
          return true;
        }
    
        return false;
      }
    
      private void HideErrorFeedback()
      {
        BindingExpression textPropertyBindingExpression = GetBindingExpression(TextProperty);
        bool hasTextPropertyBinding = textPropertyBindingExpression is not null;
        if (hasTextPropertyBinding)
        {
          Validation.ClearInvalid(textPropertyBindingExpression);
        }
      }
    
      private void ShowErrorFeedback(object errorContent)
      {
        BindingExpression textPropertyBindingExpression = GetBindingExpression(TextProperty);
        bool hasTextPropertyBinding = textPropertyBindingExpression is not null;
        if (hasTextPropertyBinding)
        {
          // Show the error feedbck by triggering the binding engine
          // to show the Validation.ErrorTemplate
          Validation.MarkInvalid(
            textPropertyBindingExpression,
            new ValidationError(
              this.NumericInputValidationRule,
              textPropertyBindingExpression,
              errorContent, // The error message
              null));
        }
      }
    }
    

    Generic.xaml

    <Style TargetType="local:NumericTextBox">
      <Style.Resources>
    
        <!-- The visual error feedback -->
        <ControlTemplate x:Key="ValidationErrorTemplate1">
          <StackPanel>
            <Border BorderBrush="Red"
                    BorderThickness="1"
                    HorizontalAlignment="Left">
    
              <!-- Placeholder for the NumericTextBox itself -->
              <AdornedElementPlaceholder x:Name="AdornedElement" />
            </Border>
    
            <Border Background="White"
                    BorderBrush="Red"
                    Padding="4"
                    BorderThickness="1"
                    HorizontalAlignment="Left">
              <ItemsControl ItemsSource="{Binding}"
                            HorizontalAlignment="Left">
                <ItemsControl.ItemTemplate>
                  <DataTemplate>
                    <TextBlock Text="{Binding ErrorContent}"
                               Foreground="Red" />
                  </DataTemplate>
                </ItemsControl.ItemTemplate>
              </ItemsControl>
            </Border>
          </StackPanel>
        </ControlTemplate>
      </Style.Resources>
    
      <Setter Property="Validation.ErrorTemplate"
              Value="{StaticResource ValidationErrorTemplate1}" />
      <Setter Property="BorderBrush"
              Value="{x:Static SystemColors.ActiveBorderBrush}" />
      <Setter Property="BorderThickness"
              Value="1" />
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="local:NumericTextBox">
            <Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    Padding="{TemplateBinding Padding}">
    
              <ScrollViewer Margin="0"
                            x:Name="PART_ContentHost" />
            </Border>
    
            <ControlTemplate.Triggers>
              <Trigger Property="IsEnabled"
                       Value="False">
                <Setter Property="Background"
                        Value="{x:Static SystemColors.ControlLightBrush}" />
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>