-1

I have a simple wpf Popup control. I want to show the popup when user enters wrong age in the textbox.

my code snippets

<TextBox Name="TxtAge"  LostFocus="TxtAge_OnLostFocus" ></TextBox>
<Popup Name="InvalidAgePopup" IsOpen="{Binding IsAgeInvalid, Mode=OneWay}"/>

Code behind

private void TxtAge_OnLostFocus(object sender, RoutedEventArgs e)
{
        var text = TxtAge.Text;
        double value = 0;
        if (Double.TryParse(text, out value))
        {
            vm.IsAgeInvalid = false;
        }
        else
        {
            vm.IsAgeInvalid = true;
        }
}

ViewModel

public class AgeViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private bool _isAgeInvalid;
    public bool IsAgeInvalid
    {
        get { return _isAgeInvalid; }
        set
        {
            _isAgeInvalid = value;
            OnPropertyChanged();
        }
    }
}

IsAgeInvalid property is set when textbox is entered with invalid age. At that time I want to show the popup. And I don't want to set IsAgeInvalid = false when popup control is closed. To achieve this, I set Mode=OneWay IsOpen="{Binding IsAgeInvalid, Mode=OneWay}

When I entered the wrong data, the popup is showing fine. And my binding object is getting cleared when I closed the popup. The below screenshots are from snoop tool.

First time the binding object is there. enter image description here

After the popup close, the binding object is cleared. enter image description here

The binding parts works fine in TwoWay mode and I do not want the IsAgeInvalid property to be set to false as the IsOpen is set to false. I have tried setting UpdateSourceTriger and few other ways, still the binding object is getting cleared after the popup is closed.

Learner
  • 1,286
  • 6
  • 34
  • 57
  • have you the possibility to share a minimal sample? to reproduce the problem – Frenchy Apr 11 '23 at 18:06
  • The first thing I did when I read this was check the WPF source code to see if dismissing the popup stupidly sets `IsOpen` or uses `SetCurrentValue` - and it does use `SetCurrentValue`. So dismissal should NOT clear the binding. Very odd and unsure why it would be doing so. Assuming it's a bug in WPF though, there is a workaround I will post as an answer, which might seem obvious or might not be what you want to do but it should get around the problem. – Emperor Eto Apr 11 '23 at 19:59
  • @learner did my answer help at all? – Emperor Eto Apr 15 '23 at 21:48
  • When yo are already validating in the view then why don't you directly toggle the Popup from there? Setting IsAgeInvalid on the view model from the view doesn't make sense. Just show the Popup when the age validation fails. Just out of curiosity: why don't you bind the TextBox to the view model and validate there? – BionicCode Apr 16 '23 at 20:31
  • Why are you showing the Popup in the first place? Did you know you can implement view model property validation and trigger the UI to automatically show a Popup that shows the validation error message? Your flow currently breaks MVVM that's why you are struggling. You kind of want to trigger the Popup from your view model. Property validation would solve your issue in an elegant way. – BionicCode Apr 16 '23 at 20:53
  • @learner how does the Popup get closed? Is it just dismissed when it loses focus or are you closing it manually somewhere else that's not shown? And do you want it to stay open until the validation error has been fixed and only close then? – Emperor Eto Apr 22 '23 at 14:10

2 Answers2

1

You have some issues with your code. There is no reason why you wouldn't bind the TextBox to your view model class. It doesn't make sense to validate the input in the view just to set a property on the view model to indicate a failed validation. Such a property must be read-only to ensure that it cannot be randomly set. If you chose to validate in your view's code-behind then simply toggle the Popup from there.
However, I recommend to implement INotifyDataErrorInfo (see below).

Explanation of your observation

Based on your statements

"And my binding object is getting cleared when I closed the popup."

and

"I do not want the IsAgeInvalid property to be set to false as the IsOpen is set to false"

I conclude that you don't close the Popup by setting AgeViewModel.IsAgeInvalid to false. Instead you set Popup.IsOpen directly to close the Popup.
The important point is that if the Binding.Mode is set to BindingMode.OneWay and the dependency property is set directly (locally), the new value clears/overwrites the previous value and value source, which in your case was a Binding object that had IsAgeInvalid as source property defined.
That's why you observe the Binding being deleted when closing the Popup (your way).
Setting a dependency property locally will always clear the previous value and value source (Binding and local values have the same precedence).

Because IsOpen binds to IsAgeInvalid, you would have to set it via the binding (which is what you explicitly don't want to do).

Solution 1

Set the Popup.IsOpen property using the DependencyObject.SetCurrentValue method.
This allows to assign a new value locally without clearing the value source (the Binding):

this.Popup.SetCurrentValue(Popup.IsOpenProperty, false);

Solution 2 (recommended)

The correct solution that additionally fixes the design issues would be:

  1. Let your view model class AgeViewModel implement INotifyDataErrorInfo to enable property validation.
  2. Override the default validation error feedback. When the validation fails, WPF will automatically draw a red border around the binding target (the TextBox in your case).
    You can customize the error feedback very easily by defining a ControlTemplate which you assign to the attached Validation.ErrorTemplate property.

The following example uses solution 2) "Data validation using lambda expressions and delegates" from How to add validation to view model properties or how to implement INotifyDataErrorInfo. Only the property UserInput was renamed to Age and the method IsUserInputValid was renamed to IsAgevalid to make it address your given scenario.

AgeViewModel.cs

class AgeViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
  // This property is validated using a lambda expression
  private string age;
  public string Age
  { 
    get => this.age; 
    set 
    {       
      // Use Method Group
      if (IsPropertyValid(value, IsAgeValid))
      {
        this.age = value; 
        OnPropertyChanged();
      }
    }
  }

  // Constructor
  public AgeViewModel()
  {
    this.Errors = new Dictionary<string, IList<object>>();
  }

  // The validation method for the UserInput property
  private (bool IsValid, IEnumerable<object> ErrorMessages) IsAgeValid(string value)
  {
    return double.TryParse(value, out _) 
      ? (true, Enumerable.Empty<object>()) 
      : (false, new[] { "Age must be numeric." });
  }

/***** Implementation of INotifyDataErrorInfo *****/

  // Example uses System.ValueTuple
  public bool IsPropertyValid<TValue>(
    TValue value, 
    Func<TValue, (bool IsValid, IEnumerable<object> ErrorMessages)> validationDelegate, 
    [CallerMemberName] string propertyName = null)  
  {  
    // Clear previous errors of the current property to be validated 
    _ = ClearErrors(propertyName); 

    // Validate using the delegate
    (bool IsValid, IEnumerable<object> ErrorMessages) validationResult = validationDelegate?.Invoke(value) ?? (true, Enumerable.Empty<object>());

    if (!validationResult.IsValid)
    {
      AddErrorRange(propertyName, validationResult.ErrorMessages);
    } 

    return validationResult.IsValid;
  }  

  // Adds the specified errors to the errors collection if it is not 
  // already present, inserting it in the first position if 'isWarning' is 
  // false. Raises the ErrorsChanged event if the Errors collection changes. 
  // A property can have multiple errors.
  private void AddErrorRange(string propertyName, IEnumerable<object> newErrors, bool isWarning = false)
  {
    if (!newErrors.Any())
    {
      return;
    }

    if (!this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors))
    {
      propertyErrors = new List<object>();
      this.Errors.Add(propertyName, propertyErrors);
    }

    if (isWarning)
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Add(error);
      }
    }
    else
    {
      foreach (object error in newErrors)
      {
        propertyErrors.Insert(0, error);
      }
    }

    OnErrorsChanged(propertyName);
  }

  // Removes all errors of the specified property. 
  // Raises the ErrorsChanged event if the Errors collection changes. 
  public bool ClearErrors(string propertyName)
  {
    this.ValidatedAttributedProperties.Remove(propertyName);
    if (this.Errors.Remove(propertyName))
    { 
      OnErrorsChanged(propertyName); 
      return true;
    }
    return false;
  }

  // Optional method to check if a particular property has validation errors
  public bool PropertyHasErrors(string propertyName) => this.Errors.TryGetValue(propertyName, out IList<object> propertyErrors) && propertyErrors.Any();

  #region INotifyDataErrorInfo implementation

  // The WPF binding engine will listen to this event
  public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

  // This implementation of GetErrors returns all errors of the specified property. 
  // If the argument is 'null' instead of the property's name, 
  // then the method will return all errors of all properties.
  // This method is called by the WPF binding engine when ErrorsChanged event was raised and HasErrors return true
  public System.Collections.IEnumerable GetErrors(string propertyName) 
    => string.IsNullOrWhiteSpace(propertyName) 
      ? this.Errors.SelectMany(entry => entry.Value) 
      : this.Errors.TryGetValue(propertyName, out IList<object> errors) 
        ? (IEnumerable<object>)errors 
        : new List<object>();

  // Returns 'true' if the view model has any invalid property
  public bool HasErrors => this.Errors.Any(); 

  #endregion

  #region INotifyPropertyChanged implementation

  public event PropertyChangedEventHandler PropertyChanged;

  #endregion

  protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)        
  {
    this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }

  protected virtual void OnErrorsChanged(string propertyName)
  {
    this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
  }

  // Maps a property name to a list of errors that belong to this property
  private Dictionary<string, IList<object>> Errors { get; }
}

MainWindow.xaml
The error messages that were generated in the AgeViewModel are automatically wrapped into a ValidationError object by the binding engine.
We can get our message by referencing the ValidationError.ErrorContent property.

<Window>
  <Window.Resources>
      <ControlTemplate x:Key="ValidationErrorTemplate">
      <StackPanel>
        <Border BorderBrush="Red"
                BorderThickness="1"
                HorizontalAlignment="Left">

          <!-- Placeholder for the TextBox itself -->
          <AdornedElementPlaceholder x:Name="AdornedElement" />
        </Border>

        <!-- Your Popup goes here.
             The Popup will close automatically 
             once the error template is disabled by the WPF binding engine.
             Because this content of the ControlTemplate is already part of a Popup,
             you could skip your Popup and only add the TextBlock 
             or whatever you want to customize the look -->
        <Popup IsOpen="True">

          <!-- The error message from the view model is automatically 
               wrapped into a System.Windows.Controls.ValidationError object.
               Our view moel returns a collection of messages. 
               So we bind to the first message -->
          <TextBlock Text="{Binding [0].ErrorContent}" 
                     Foreground="Red" />
        </Popup>
      </StackPanel>
    </ControlTemplate>
  </Window.Resources>

  <!-- Enable validation and override the error template 
       using the attached property 'Validation.ErrorTemplate' -->
  <TextBox Text="{Binding Age, ValidatesOnNotifyDataErrors=True}"
           Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}" />
</Window>
BionicCode
  • 1
  • 4
  • 28
  • 44
0

I tried but can't reproduce the problem based solely on the code you posted. I suggest you post a complete but minimum reproducible example.

That said, whether there's a bug in your code or something quirky about WPF Popup (and goodness knows it has its quirks) that you've stumbled upon, here's an alternative approach that would still allow you to use TwoWay binding on the popup without tying the validation state to the popup's open state:

ViewModel:

    private bool _isAgeInvalid;
    public bool IsAgeInvalid
    {
        get 
        {
            return _isAgeInvalid; 
        }
        set
        {
            _isAgeInvalid = value;
            this.IsAgeValidationPopupOpen = valid;
            OnPropertyChanged();
        }
    }

    private bool _isAgeValidationPopupOpen;
    public bool IsAgeValidationPopupOpen
    {
        get => _isAgeValidationPopupOpen;
        set
        {
            _isAgeValidationPopupOpen = value;
            OnPropertyChanged();
        }
    }

XAML:

<Popup Name="InvalidAgePopup" IsOpen="{Binding IsAgeValidationPopupOpen, Mode=TwoWay}"/>

Even though the popup's dismissal should not be destroying your binding, at least with this method you have one variable always tracking with the popup open state and another always tracking with the age validity state, so arguably it's a better representation of your UI state anyway.

If you indeed are closing the popup somewhere else by setting its IsOpen property directly in code-behind as the other answer suggests (though there's nothing in your code example to indicate that you are) then splitting out the two states (validation and popup open) into separate VM properties would let you continue to use pure MVVM without worrying about SetCurrentValue and the like.

Emperor Eto
  • 2,456
  • 2
  • 18
  • 32