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:
- Let your view model class
AgeViewModel
implement INotifyDataErrorInfo
to enable property validation.
- 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>