简体   繁体   中英

WPF Binding only working when using a Converter

I'm struggling with a reusable and relatively simple UserControl.

My data is stored in classes that implement the simple IHasValue interface:

public interface IHasValue<T>
{
    T Value { get; }
    ValueType Type { get; }
}

I need a reusable control to edit data in multiple places around the application. This is the control I have at the moment, it just shows a button with a plus sign when SelectedValue is null, clicking it sets SelectedValue (I will add user input later) and shows the value instead of the button:

<UserControl x:Class="DotsCompanion.Controls.HasValueFloatSelectorControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:DotsCompanion.Controls"
             xmlns:conv="clr-namespace:DotsCompanion.Converters"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">


    <UserControl.Resources>
        <conv:NullToTrueFalseConverter x:Key="NullToTrueFalseConverter" />
        <conv:MyDebugConverter x:Key="MyDebugConverter" />

        <DataTemplate x:Key="EmptyValueTemplate" DataType="{x:Type ContentControl}">
            <Button Click="NewValueClick">
                <TextBlock Text="+"></TextBlock>
            </Button>
        </DataTemplate>
        <DataTemplate x:Key="HasValueTemplate" DataType="{x:Type ContentControl}">
            <StackPanel DataContext="{Binding Path=DataContext, RelativeSource={RelativeSource AncestorType={x:Type ContentControl}}}">
                <!-- THIS IS THE PROBLEMATIC BINDING -->
                <TextBlock Text="{Binding Path=SelectedValue, Converter={StaticResource MyDebugConverter}}"></TextBlock>
            </StackPanel>
        </DataTemplate>
    </UserControl.Resources>


    <Grid Name="LayoutRoot">
        <ContentControl>
            <ContentControl.Style>
                <Style TargetType="ContentControl">
                    <Style.Triggers>
                        <DataTrigger Binding="{Binding SelectedValue, Converter={StaticResource NullToTrueFalseConverter}}" Value="true">
                            <Setter Property="ContentTemplate" Value="{DynamicResource HasValueTemplate}"/>
                        </DataTrigger>
                        <DataTrigger Binding="{Binding SelectedValue, Converter={StaticResource NullToTrueFalseConverter}}" Value="false">
                            <Setter Property="ContentTemplate" Value="{DynamicResource EmptyValueTemplate}"/>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </ContentControl.Style>
        </ContentControl>
    </Grid>
</UserControl>

This is the extremely simple code-behind for the control:

public partial class HasValueFloatSelectorControl : UserControl
{
    public IHasValue<float> SelectedValue
    {
        get { return (IHasValue<float>)GetValue(SelectedValueProperty); }
        set { SetValue(SelectedValueProperty, value); }
    }

    public static readonly DependencyProperty SelectedValueProperty =
        DependencyProperty.Register("SelectedValue", typeof(IHasValue<float>), typeof(HasValueFloatSelectorControl), new PropertyMetadata(default(IHasValue<float>)));


    public HasValueFloatSelectorControl()
    {
        InitializeComponent();
        LayoutRoot.DataContext = this;
    }

    private void NewValueClick(object sender, EventArgs e)
    {
        SelectedValue = new FloatPrimitive(2.0f);
    }
}

FloatPrimitive is the class where the data (a float) is actually stored, it implements IHasValue and also overrides ToString() so that the float is shown as a string.

The issue is, binding Text to SelectedValue only seems to be working when using a Converter. In the code above, MyDebugConverter just returns the value as is:

// This should be literally useless AFAIK
public class MyDebugConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return value;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return value;
    }
}

If I remove the converter, the binding just shows nothing. According to the output window trace, DataItem is null, but using the live property explorer I can see that DataContext is being correctly set and SelectedValue is, indeed, a FloatPrimitive.

I tried searching for a solution but so far I only found opposite cases (binding not working with a converter). I'm pretty new to WPF so I'm not sure where to begin debugging as this seems a rather uncommon situation and online resources are not helping at the moment.

What I tried:

  • binding to SelectedValue outside of a DataTemplate, by adding a simple TextBlock inside the LayoutRoot Grid, but I got the very same result (converter is needed);
  • setting SelectedValue both by binding to it from outside the control and by calling the NewValueClick() method inside the control;
  • adding a Debugger.Break() call inside MyDebugConverter which allows me to check that the value is not null and is being correctly set, but then again using the converter is what allows it to be correctly shown in the first place;
  • a lot of fiddling around with the live property explorer

Could this be caused by the fact that I'm using an interface? I have a very similar setup for another user control in the project that works just fine, but it uses an abstract class + inheritance as opposed to an interface + implementation.

Apparently, the problem is that your property SelectedValue is of an interface type.

If you change the type of SelectedItem to FloatPrimitive , everything works as expected (not a solution, just a test of my point). I've also confirmed through testing and research that the problem is the interface aspect, not the fact that it's a generic type.


Explanation of the Problem

WPF uses Type Converters under the hood to handle conversions between types during a binding update.

  • There are a bunch of default TypeConverter s for types like Integer , String , etc.
  • You can define a TypeConverter for your own class by applying the TypeConverterAttribute to the class you want to convert.
  • When WPF is converting to String , if all else fails, it will call Object.ToString() .

Here's the problem: as explained in this answer , when WPF handles data binding, it doesn't check the actual type of the value being transferred, it only looks at the declared types of the source and destination properties. For this reason, it doesn't matter what conversion operators you declare on FloatPrimitive or whether or not you override ToString() ; WPF only sees IHasValue<T> because that's the SelectedItem property's type.

IHasValue<T> obviously doesn't have a default TypeConverter and you haven't declared your own. Since TextBlock.Text is a String , WPF would usually try to call Object.ToString() , but IHasValue<T> isn't an Object . The object implementing IHasValue<T> definitely is a descendant of Object , but WPF doesn't know that, it just knows it's an IHasValue<T> . So conversion fails and null is returned as a result.

You can actually see this conversion failing. If you add PresentationTraceSources.TraceLevel=High to your {Binding SelectedItem ...} (and leave off Converter=... ), the output will show:

System.Windows.Data Warning: 101 : BindingExpression (hash=16490914): GetValue at level 0 from HasValueFloatSelectorControl (hash=72766) using DependencyProperty(SelectedValue): FloatPrimitive (hash=14200498)
System.Windows.Data Warning: 80 : BindingExpression (hash=16490914): TransferValue - got raw value FloatPrimitive (hash=14200498)
System.Windows.Data Warning: 84 : BindingExpression (hash=16490914): TransferValue - implicit converter produced <null>
System.Windows.Data Warning: 89 : BindingExpression (hash=16490914): TransferValue - using final value <null>

The binding correctly gets the FloatPrimitive , tries to convert it, but ends up with null , which explains why the TextBlock is empty.

The apparent solution would be to declare your own TypeConverter for IHasValue<T> which calls ToString() . Unfortunately, this doesn't work (I tried it myself). It seems WPF just doesn't support TypeConverter s for interface s. This question shows someone with a similar problem (they were trying to convert to an interface type).

Why MyDebugConverter fixes things

When you use Binding.Converter , WPF will call the IValueConverter provided before trying anything else. But the IValueConverter isn't required to return the exact type of the destination property, so WPF needs to check the type of the output value. If the IValueConverter output isn't the exact type of the target property, WPF will try to use TypeConverter s to finish the job.

The thing is, this requires WPF to actually check the type of the output value. So even though MyDebugConverter does nothing, WPF assumed the input was an IHasValue<T> , but actually looked at the output and found it was now a FloatPrimitive . So now it tries to convert it as the later rather than the former. This allows it to use the applicable default TypeConverter to call Object.ToString() .


Solutions

As mentioned above, declaring your own TypeConverter won't work for interface types, This leaves you with a few options:

  • Change the type of SelectedItem to be some base class , rather than an interface . (This might not be an option, based on your requirements)
  • Add a Text (or similarly-named) property to your IHasValue<T> implementations (and probably to the interface itself) which will return ToString() . Bind to that property instead of the object.
    For example, if FloatPrimitive had a public string Text => ToString(); , then you coud bind to SelectedItem.Text .
  • Don't bind String properties. If you replaced your TextBlock with a ContentControl and bound ContentControl.Content instead, everything would display as expected (the internal ContentPresenter knows to calls ToString() ). This also gives you the option to add DataTemplate s in case you want to show more than just text. Example: <ContentControl Content="{Binding SelectedValue}"/>
  • Use Binding.Converter with an IValueConverter when binding to String -type or other-type properties.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM