Friday, January 21, 2022

Width=Auto

If you define a UIElement with HorizontalAlignment="Stretch" and a Width, you will find the element does not render the way you want. If you make heavy use of styles, you might need a way to override a style's Width property to allow you to specify a HorizontalAlignment on the element's declaration. It turns out you can do this with Width="Auto". Take a look at the XAML below.

You can see the top text box is ignoring the HorizontalAlignment="Stretch" because of the width defined in the default style. The lower text box uses Width="auto" to override the style's width which allows the HorizontalAlignment="Stretch" to take effect.

<Window x:Class="AutoWidth.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:AutoWidth"
        mc:Ignorable="d"
        Title="AutoWidth" Height="450" Width="800">
    <Window.Resources>
        <Style TargetType="TextBox">
            <Setter Property="Width" Value="100"/>
            <Setter Property="Background" Value="Yellow"/>
        </Style>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100"/>
            <ColumnDefinition Width="200"/>
        </Grid.ColumnDefinitions>
       
        <TextBlock Grid.Row="0" Grid.Column="0" Text="Default Width"/>
        <TextBox Grid.Row="0" Grid.Column="1" HorizontalAlignment="Stretch"/>
 
        <TextBlock Grid.Row="1" Grid.Column="0" Text="Width=Auto"/>
        <TextBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Stretch" Width="auto"/>
    </Grid>
</Window>

 



Wednesday, January 12, 2022

Making the Bindable attribute useful

I recently spent several hours trying to understand a subtle binding problem and finally realized that classes at different levels of inheritance had identically named properties. One raised PropertyChanged and the other didn't. The XAML was binding to the wrong one and I had to add a RelativeSource clause.

Surely, I thought, there's a way to mark a property as unsuitable for binding so that an error, or at least a warning, is raised if a binding is created with that property as the target. There is a Binding(true|false) attribute but that does almost nothing. All it does is allow/prevent the property being listed by intellisense in certain circumstances. Surely, I thought, I can do better. Here it is.

The intent is to write a routine that finds all the bindings and makes sure none of them use a property marked Binding(false) as the target. ie, something like this.

        [Bindable(false)]
        public string NonBindableProperty { getset; }

For clarity, I broke the problem into three parts.
  1. Find all properties with an explicit Bindable(false) attribute
  2. Find all bindings
  3. Find all bindings whose targets are in the list from 1.
Start a new WPF Framework, C#, Visual Studio project and call it FindNonBindableBindings. Here's the XAML. It binds one textblock to a bindable property, and another to a non-bindable property.

<Window x:Class="FindNonBindableBindings.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:FindNonBindableBindings"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="Find NonBindable Bindings" Height="450" Width="800">
    <StackPanel>
        <TextBlock Text="{Binding BindableProperty}"/>
        <TextBlock Text="{Binding NonBindableProperty}"/>
    </StackPanel>
</Window>

Let's start the code-behind with just the properties and the constructor.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Data;
using System.Windows.Markup.Primitives;
 
namespace FindNonBindableBindings
{
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        private string _BindableProperty;
        public string BindableProperty
        {
            get { return _BindableProperty; }
            set { SetProperty(ref _BindableProperty, value); }
        }
 
        [Bindable(false)]
        public string NonBindableProperty { get; set; }
 
        [Bindable(false)]
        public string UnusedNonBindableProperty get; set; }
       
        public MainWindow()
        {
            InitializeComponent();
            BindableProperty = "Hello";
            NonBindableProperty = "Goodbye";
        } 

        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = "")
        {
            if (!Object.Equals(storage, value))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

If you run this you can see that modifying NonBindableProperty after InitializeComponent does not update the textblock. Even though we marked the property as Binding(False), WPF did nothing to let us know we had bound to it.


Let's start by creating a simple class to hold source and target details about a binding so we can generate more useful error messages. Add this class directly in the FindNonBindableBindings namespace. As the original problem was caused by multiple identically named properties, we have to track the DataContext of the target properties.

    public class BindingInfo
    {
        public String TargetName { get; set; }
        public String ElementType { get; set; }
        public String DependencyPropertyName { get; set; }
        public String DataContextName { get; set; }
    }

Now we can add some local variables to the constructor.

    List<PropertyInfo> NonBindableProperties;

    List<BindingInfo> Bindings;
    List<String> Errors;                                                                                                                                                                       
And write a method to return a list of all properties with explicit Binding(false) attributes. This is step 1 from above.

    private List<PropertyInfo> FindNonBindableProperties(FrameworkElement fe)
    {
        List<PropertyInfo> NonBindableProperties = new List<PropertyInfo>();
        foreach (PropertyInfo PI in fe.GetType().GetProperties())
        {
            BindableAttribute BA = PI.GetCustomAttribute<BindableAttribute>(true);
            if (BA != null && !BA.Bindable)
            {
                NonBindableProperties.Add(PI);
            }
        }
        return NonBindableProperties;
    }

Step 2 is to find all bindings recursively, like this.

    private List<BindingInfo> FindAllBindings(DependencyObject fe)
    {
        List<BindingInfo> Bindings = new List<BindingInfo>();
        MarkupObject markupObject = MarkupWriter.GetMarkupObjectFor(fe);
        if (markupObject != null)
        {
            foreach (MarkupProperty mp in markupObject.Properties)
            {
                if (mp.DependencyProperty != null)
                {
                    BindingBase b = BindingOperations.GetBindingBase(fe, mp.DependencyProperty);
                    if (b != null)
                        Bindings.Add(new BindingInfo() 
                        
                            TargetName = (b as Binding).Path.Path, 
                            ElementType = fe.GetType().Name, 
                            DependencyPropertyName = mp.DependencyProperty.Name,
                            DataContextName = (fe as FrameworkElement).DataContext.GetType().FullName 
                        });
                }
            }
        }
 
        foreach (object child in LogicalTreeHelper.GetChildren(fe))
            if (child is DependencyObject)
                Bindings.AddRange(FindAllBindings(child as DependencyObject));
 
        return Bindings;
    }

Step 3 compares the results of steps 1 and 2, returning a list of errors. 

    private List<String> CheckForNonBindableBindings(List<PropertyInfo> NonBindableProperties, List<BindingInfo> Bindings)

    {
        List<String> Errors = new List<string>();
        foreach (PropertyInfo PI in NonBindableProperties)
        {
            BindingInfo binding = Bindings.FirstOrDefault(b => b.TargetName == PI.Name && b.DataContextName == PI.DeclaringType.FullName);
            if (binding != null)
                Errors.Add(String.Format("Non-Bindable property '{3}.{0}' is the target of binding from the {1} property of a {2} element",
                           binding.TargetName, binding.DependencyPropertyName, binding.ElementType, binding.DataContextName));
        }
        return Errors;
    }

Lastly we tie it all together with a few lines at the end of the constructor.


        NonBindableProperties = FindNonBindableProperties(this);
        Bindings = FindAllBindings(this);
        Errors = CheckForNonBindableBindings(NonBindableProperties, Bindings);

        MessageBox.Show(string.Join(Environment.NewLine, Errors));                                                  

Let's run the application again. Now we see a dialog box telling us we bound to a property we didn't intend to use for binding.







                                                                                                                                                                                                                                                                                                                                                                                         


 


Thursday, January 6, 2022

RenderLayout or Margin bug?

I have a requirement to host a control in the content area of a tab control, but display it to the right of the tabs. I though this would be easy with a negative Margin or a RenderLayout. It worked perfectly, until I tried to interact with the control. It's acting as if there is a transparent panel between the tabs and the right margin of the control that still allows the control to be visible, but traps all mouse clicks. If you write a TabControl.MouseDown event handler you can see it being triggered, even when you click on the upper half of the button below.

<Window x:Class="RenderTransformBug.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RenderTransformBug"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <TabControl>
        <TabItem Header="Tab Item">
            <Button Height="30" Width="100" Content="Click me" Cursor="Hand"
                    VerticalAlignment="Top" Margin="0,-20,0,0"/>
        </TabItem>
    </TabControl>
</Window>

You can see that the button no longer realizes the mouse is over it when I move the cursor outside the Content area of the TabItem.



One solution is to detect when the mouse is over the button and capture the mouse. When the mouse is no longer over the button we need to release the mouse. This means we have to evaluate the location of the mouse whenever it moves. We cannot do this in the CanExecute event handler because that doesn't get called for mouse movements, so we need to write a MouseMove event handler for the window. Take a look at the XAML below and the supporting code.

<Window x:Class="RenderTransformBug.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RenderTransformBug"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        MouseMove="Window_MouseMove"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="ClickCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource ClickCommand}" Executed="Click_Executed"/>
    </Window.CommandBindings>
    <TabControl>
        <TabItem Header="Tab Item">
            <Button Height="30" Width="100" Content="Click me" Cursor="Hand" Initialized="Button_Initialized"
                    VerticalAlignment="Top" Margin="0,-20,0,0" Command="{StaticResource ClickCommand}"/>
        </TabItem>
    </TabControl>
</Window>

 

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
 
namespace RenderTransformBug
{
    public partial class MainWindow : Window
    {
        Button b;
 
        public MainWindow()
        {
            InitializeComponent();
        }
 
        private void Click_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            MessageBox.Show("Clicked");
        }
 
        private void Window_MouseMove(object sender, MouseEventArgs e)
        {
            Point P = Mouse.GetPosition(b);
 
            if (P.X >= 0 && P.X <= b.ActualWidth && P.Y >= 0 && P.Y <= b.ActualHeight)
                b.CaptureMouse();
            else
                b.ReleaseMouseCapture();
        }
 
        private void Button_Initialized(object sender, System.EventArgs e)
        {
            b = (Button)sender;
        }
    }
}

Note that Button.IsMouseOver returns false until the button has captured the mouse so we have to look at the button's coordinates. This now gives us the desired functionality.