Search code examples
c#xamlmvvmxamarin.formsinotifydataerrorinfo

How to fix Xamarin.Forms Entry Attach Property "Bleed Over"?


I'm trying to setup validation for an Xamarin.Forms(XF) Entry using INotifyDataErrorInfo and attach properties(AP). The code works as expected while only using one Entry. The problem occurs when I initialise the AP for a second Entry. Then if an error happens in Entry1 it is displayed in Entry2.

I have used the INotifyDataErrorInfo in WPF where it works like a charm. The only problem is that I can't find any documentation on how the "Validation" attach properties is implemented for WPF. I have looked at other alternatives in regards to validation, but non that seems to be as solid as using INotifyDataErrorInfo together with custom model wrappers.

Binding Extension (Used to get binding)

public static class BindingObjectExtensions
{
    public static Binding GetBinding(this BindableObject self, BindableProperty property)
    {
        var methodInfo = typeof(BindableObject).GetTypeInfo().GetDeclaredMethod("GetContext");
        var context = methodInfo?.Invoke(self, new[] { property });

        var propertyInfo = context?.GetType().GetTypeInfo().GetDeclaredField("Binding");
        return propertyInfo?.GetValue(context) as Binding;
    }

    public static object GetBindingExpression(this Binding self)
    {
        var fieldInfo = self?.GetType().GetTypeInfo().GetDeclaredField("_expression");
        return fieldInfo?.GetValue(self);
    }
}

The Attach Properties

using {YourNamespace}.Extensions;
using System;
using System.Collections;
using System.ComponentModel;
using System.Linq;
using Xamarin.Forms;

public static class ValidationBehavior
{
    // Fields
    public static string _propertyName;
    public static BindableObject _view;

    public static readonly BindableProperty IsActiveProperty;
    public static readonly BindableProperty HasErrorProperty;
    public static readonly BindableProperty ErrorsProperty;

    // Constructor
    static ValidationBehavior()
    {
        IsActiveProperty = BindableProperty.CreateAttached("IsActive", typeof(bool), typeof(ValidationBehavior), default(bool), propertyChanged: OnIsActivePropertyChanged);
        ErrorsProperty = BindableProperty.CreateAttached("Errors", typeof(IList), typeof(ValidationBehavior), null);
        HasErrorProperty = BindableProperty.CreateAttached("HasError", typeof(bool), typeof(ValidationBehavior), default(bool));
    }


    // Properties
    #region IsActive Property
    public static bool GetIsActive(BindableObject obj)
    {
        return (bool)obj.GetValue(IsActiveProperty);
    }
    public static void SetIsActive(BindableObject obj, bool value)
    {
        obj.SetValue(IsActiveProperty, value);
    }
    #endregion

    #region Errors Property
    public static IList GetErrors(BindableObject obj)
    {
        return (IList)obj.GetValue(ErrorsProperty);
    }
    public static void SetErrors(BindableObject obj, IList value)
    {
        obj.SetValue(ErrorsProperty, value);
    }
    #endregion

    #region HasError Property
    public static bool GetHasError(BindableObject obj)
    {
        return (bool)obj.GetValue(HasErrorProperty);
    }
    public static void SetHasError(BindableObject obj, bool value)
    {
        obj.SetValue(HasErrorProperty, value);
    }
    #endregion


    // Methodes
    private static void OnIsActivePropertyChanged(BindableObject bindable, object oldValue, object newValue)
    {
        if ((bool)newValue)
        {
            var binding = bindable.GetBinding(Entry.TextProperty); 
            if (binding != null)
            {
                string bindingPath = binding.Path;
                _propertyName = bindingPath.Split('.').Last();
                bindable.BindingContextChanged += Bindable_BindingContextChanged;
            }
        }
        else
        {
            _propertyName = null;
            bindable.BindingContextChanged -= Bindable_BindingContextChanged;
        }
    }

    private static void Bindable_BindingContextChanged(object sender, EventArgs e)
    {
        var bindable = sender as BindableObject;
        if (bindable == null)
        {
            _view = null;
            return;
        }
        else
        {
            _view = bindable;
        }

        var errorInfo = bindable.BindingContext as INotifyDataErrorInfo;
        if (errorInfo == null)
            return;

        errorInfo.ErrorsChanged += ErrorInfo_ErrorsChanged; // NB! Not sure if this will create memory leak
    }

    private static void ErrorInfo_ErrorsChanged(object sender, DataErrorsChangedEventArgs e)
    {
        if (e.PropertyName != _propertyName)
            return;

        var errorInfo = sender as INotifyDataErrorInfo;
        if (errorInfo == null)
            return;

        if (!errorInfo.HasErrors)
        {
            SetErrors(_view, null);
            SetHasError(_view, false);
        }
        else
        {
            var foundErrors = errorInfo.GetErrors(e.PropertyName);
            if (foundErrors == null)
            {
                SetErrors(_view, null);
                SetHasError(_view, false);
            }
            else
            {
                SetErrors(_view, foundErrors.Cast<string>().ToList());
                SetHasError(_view, true);
            }
        }
    }
}

The Entry Style

<Style TargetType="Entry" x:Key="EntryInput">
    <Setter Property="b:ChangeValidationBehavior.IsActive" Value="True"/>
    <Style.Triggers>
        <Trigger Property="b:ChangeValidationBehavior.IsChanged" Value="True" TargetType="Entry">
            <Setter Property="BackgroundColor" Value="SteelBlue"/>
        </Trigger>
        <Trigger Property="b:ChangeValidationBehavior.HasError" Value="True" TargetType="Entry">
            <Setter Property="BackgroundColor" Value="LightCoral"/>
        </Trigger>
    </Style.Triggers>
</Style>

The Entries in the View

<Entry BindingContext="{Binding Project}" Text="{Binding Name, Mode=TwoWay}"
       Grid.Column="1" Style="{StaticResource EntryInput}"/>

<Entry BindingContext="{Binding Project}" Text="{Binding Description, Mode=TwoWay}"
       Grid.Row="3" Grid.Column="1" Style="{StaticResource EntryInput}"/>

What I expected to happen is that I should be able to activate this ValidationBehavior for multiple Entry controls and then display the errors for each of them.

I'm guessing it has something to do with the fact that the AP is a static class and so are the properties, but then I'm kinda lost, because if this is correct then what is the point with attach properties..

Does anyone have the faintest idea of how to do this?


Solution

  • It suddenly dawned on me.. The _propertyName and _view are only static fields..

    So the solution for now is to create the _propertyName as an attach property and writing the "ErrorInfo_ErrorsChanged" methode as an lamda expression.

    Here is the code:

    using {YourNamespace}.Extensions;
    using System;
    using System.Collections;
    using System.ComponentModel;
    using System.Linq;
    using Xamarin.Forms;
    
    public static class ValidationBehavior
    {
        // Fields
        public static readonly BindableProperty IsActiveProperty;
        public static readonly BindableProperty HasErrorProperty;
        public static readonly BindableProperty ErrorsProperty;
        public static readonly BindableProperty PropertyNameProperty;
    
        // Constructor
        static ValidationBehavior()
        {
            IsActiveProperty = BindableProperty.CreateAttached("IsActive", typeof(bool), typeof(ValidationBehavior), default(bool), propertyChanged: OnIsActivePropertyChanged);
            ErrorsProperty = BindableProperty.CreateAttached("Errors", typeof(IList), typeof(ValidationBehavior), null);
            HasErrorProperty = BindableProperty.CreateAttached("HasError", typeof(bool), typeof(ValidationBehavior), default(bool));
            PropertyNameProperty = BindableProperty.CreateAttached("PropertyName", typeof(string), typeof(ChangeValidationBehavior), default(string));
        }
    
    
        // Properties
        #region IsActive Property
        public static bool GetIsActive(BindableObject obj)
        {
            return (bool)obj.GetValue(IsActiveProperty);
        }
        public static void SetIsActive(BindableObject obj, bool value)
        {
            obj.SetValue(IsActiveProperty, value);
        }
        #endregion
    
        #region Errors Property
        public static IList GetErrors(BindableObject obj)
        {
            return (IList)obj.GetValue(ErrorsProperty);
        }
        public static void SetErrors(BindableObject obj, IList value)
        {
            obj.SetValue(ErrorsProperty, value);
        }
        #endregion
    
        #region HasError Property
        public static bool GetHasError(BindableObject obj)
        {
            return (bool)obj.GetValue(HasErrorProperty);
        }
        public static void SetHasError(BindableObject obj, bool value)
        {
            obj.SetValue(HasErrorProperty, value);
        }
        #endregion
    
        #region PropertyName Property
        public static string GetPropertyName(BindableObject obj)
        {
            return (string)obj.GetValue(PropertyNameProperty);
        }
        public static void SetPropertyName(BindableObject obj, string value)
        {
            obj.SetValue(PropertyNameProperty, value);
        }
        #endregion
    
    
        // Methodes
        private static void OnIsActivePropertyChanged(BindableObject bindable, object oldValue, object newValue)
        {
            if ((bool)newValue)
            {
                var binding = bindable.GetBinding(Entry.TextProperty); 
                if (binding != null)
                {
                    string bindingPath = binding.Path;
                    string propertyName = bindingPath.Split('.').Last();
                    SetPropertyName(bindable, propertyName);
                    bindable.BindingContextChanged += Bindable_BindingContextChanged;
                }
            }
            else
            {
                SetPropertyName(bindable, null);
                bindable.BindingContextChanged -= Bindable_BindingContextChanged;
            }
        }
    
        private static void Bindable_BindingContextChanged(object sender, EventArgs e)
        {
            var bindable = sender as BindableObject;
            if (bindable == null)
                return;
    
            var errorInfo = bindable.BindingContext as INotifyDataErrorInfo;
            if (errorInfo == null)
                return;
    
            // NB! Not sure if this will create memory leak
            errorInfo.ErrorsChanged += (s, ea) =>
            {
                if (ea.PropertyName != GetPropertyName(bindable))
                    return;
    
                var info = s as INotifyDataErrorInfo;
                if (info == null)
                    return;
    
                if (!info.HasErrors)
                {
                    SetErrors(bindable, null);
                    SetHasError(bindable, false);
                }
                else
                {
                    var foundErrors = info.GetErrors(ea.PropertyName);
                    if (foundErrors == null)
                    {
                        SetErrors(bindable, null);
                        SetHasError(bindable, false);
                    }
                    else
                    {
                        SetErrors(bindable, foundErrors.Cast<string>().ToList());
                        SetHasError(bindable, true);
                    }
                }
            };
        }
    }
    

    This may not be the "right way" to do it, but it works. Any insights or improvements to the code would be much appreciated.

    Hope it helps somebody:)