Search code examples
c#wpfxamlbindingattached-properties

On app startup Attached Property Callback Event return null for a Control property binding


Story & Problem:

I create IsActivated property as an Attached Property to be able to add it to any control in XAML page I want. Then when any control set it to true, I want PropertyChangedCallback event fired and examine binding of specific Property of that control. The app is running without any problem, also all controls with setting IsActivated property to true will fired that event and binding object show the value correctly in UI too.

But the problem is, When PropertyChangedcallback event executing, GetBinding() or GetBindingExpression() methods will return null value for those Properties.

(For now I only Check on TextBox and on its TextProperty)

Background & What I already tried

I used the same technique in my production applications, But when I try to simplify it to demonstrate it to a friend, It is not working!

I find out, if I Set/Change IsActivated property for a TextBox at MainWindow.OnLoaded() event, Then in its Callback event it will correctly return binding info for TextBox.TextProperty:

private void OnLoaded(object sender, RoutedEventArgs e)
{
    MyTextBox.SetValue(ChangeBehavior.IsActivatedProperty, false);
}

but I did not do this in my real apps and I do not like this solution!

Source Codes

This is GitHub full sample codes.

But here is my Attached Property class:

public static class ChangeBehavior
{
    public static readonly DependencyProperty IsActivatedProperty;

    static ChangeBehavior()
    {
        IsActivatedProperty = DependencyProperty.RegisterAttached
            (
                "IsActivated",
                typeof(bool),
                typeof(ChangeBehavior),
                new PropertyMetadata(false, OnIsActivatedPropertyChangedCallback)
            );
    }

    public static bool GetIsActivated(DependencyObject obj)
        => (bool)obj.GetValue(IsActivatedProperty);
    public static void SetIsActivated(DependencyObject obj, bool value)
        => obj.SetValue(IsActivatedProperty, value);

    private static void OnIsActivatedPropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (!(d is TextBox)) return;
        
        // ===> Here I get null value
        var binding = BindingOperations.GetBinding(d, TextBox.TextProperty);
        if (binding == null) return;

        if ((bool)e.NewValue)
        {
            var sourceObject = binding.Path.Path;
            //Doing some stuff...
        }
        else
        {
            //Doing some stuff...
        }
    }
}

and this is my UI XAML:

<Grid>
    <TextBox Name="MyTextBox"
             Text="{Binding Model.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
             local:ChangeBehavior.IsActivated="True"/>
</Grid>

MainWindow Code-Behind:

public partial class MainWindow : Window
{
    private readonly ViewModel _viewModel;

    public MainWindow(ViewModel viewModel)
    {
        InitializeComponent();

        _viewModel = viewModel;
        DataContext = _viewModel;
    }
}

ViewModel class: (Omit NotifyProperty... parts to make it shorter)

public class ViewModel : INotifyPropertyChanged
{
    private Model _model;

    public ViewModel()
    {
        Model = new Model {Name = "Model One"};
    }

    public Model Model
    {
        get => _model;
        set
        {
            _model = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEvent...
}

Model class: (Omit NotifyProperty... parts to make it shorter)

public class Model : INotifyPropertyChanged
{
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            if (value == _name) return;
            _name = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEvent...
    }
}

What's wrong with my code!

I expect when InitializeComponent(); execute from my MainWindow constructor (just like my real app) any control that set local:ChangeBehavior.IsActivated to true in XAML, I get correct bindig info of their Properties at OnIsActivatedPropertyChangedCallback method!

Why almost the same technique (and almost the same code!) work in my real applications, but it doesn't work in this simple code!?

What I am missing here!?

Updated: Second way to Fix my problem is:

Instead of using my IsAttached Property Directly on an Element, I should use it in a ResourceDictionary. (The other way is that I marked as answer)


Solution

  • I'd like to see where this is being used in your real projects. In this one, it's getting set before the binding is set on Text. Never let attached properties depend on order of initialization, because that's totally out of your control.

    What we want to do is: If we can do it, do it. If not, do it after the control is loaded, unless it's already loaded, in which case there's almost certainly not going to be anything to do. It's OK to do it in a Loaded handler here because a) consumers of the class don't have to look at it, and b) you have no other choice.

    We ideally would like to do this whenever a binding is set on Text, but there's no event for that, and it's relatively uncommon for bindings to be replaced at runtime.

    private static void OnIsActivatedPropertyChangedCallback(DependencyObject d,
        DependencyPropertyChangedEventArgs e)
    {
        if (d is TextBox textBox)
        {
            if (!tryGetBinding() && !textBox.IsLoaded)
            {
                void onLoaded(object sender, RoutedEventArgs e2)
                {
                    tryGetBinding();
    
                    textBox.Loaded -= onLoaded;
                };
    
                textBox.Loaded += onLoaded;
            }
    
            bool tryGetBinding()
            {
                // ===> Here I get null value
                var binding = BindingOperations.GetBinding(textBox, TextBox.TextProperty);
    
                if (binding == null)
                    return false;
    
                if (GetIsActivated(textBox))
                {
                    var sourceObject = binding.Path.Path;
                    //Doing some stuff...
                }
                else
                {
                    //Doing some stuff...
                }
    
                return true;
            };
        }
    }