Wednesday, September 29, 2021

PropertyChanged and unboxing

One of our engineers had an interesting idea - why not use a data row as the backing store for bound properties? That would eliminate the need for writing code to explode the data row into properties when the data is loaded and vice versa when it is saved. Let's see how that would look. Start Visual Studio and create a Visual Basic WPF Application called Unboxing. The reason for the name will become apparent later.

Change MainWindow.xaml to look like this. 

<Window x:Class="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:Unboxing"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical" Width="100" Height="50" Background="PaleGoldenrod" VerticalAlignment="Center">
        <TextBox Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}"/>
        <Separator/>
        <TextBlock Text="{Binding Text}"/>
    </StackPanel>
</Window>
 
And MainWindow.xaml.vb looks like this. Normally drDocument would be populated by reading the document from the database. You can see this technique removes the need to move all the drDocument fields into private members and vice versa. You could bind directly to the document data row with Text="{Binding drDocument[Text]}" but no PropertyChanged events would be raised. Our technique seems like a good compromise.

Imports System.ComponentModel
Imports System.Data
 
Class MainWindow
    Implements INotifyPropertyChanged
 
    Private dtDocument As DataTable
    Private drDocument As DataRow
 
    Public Sub New()
        dtDocument = New DataTable()
        dtDocument.Columns.Add(New DataColumn("Text", GetType(String)))
 
        drDocument = dtDocument.NewRow()
        drDocument("Text") = ""
        dtDocument.Rows.Add(drDocument)
        InitializeComponent()
    End Sub
 
    Public Property Text As String
        Get
            Return drDocument("Text").ToString()
        End Get
        Set(value As String)
            SetProperty(drDocument("Text"), value)
        End Set
    End Property
 
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
 
    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
    End Sub
 
    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
 
End Class

If you run this and start typing you will notice something is wrong. The text block is always one keystroke behind the text box. Why is this?


Document("Text") is not a string, it's an object of type string. They are not the same thing. When you use Document("Text") as a parameter the compiler adds code to unbox it into a temporary string which gets passed to SetProperty. When SetProperty assigns a value to storage it is only updating the temporary string. Then it raises PropertyChanged which calls the property Getter, which returns drDocument("Text") WHICH HAS NOT BEEN UPDATED, YET.

Then SetProperty exits and the temporary string gets boxed into drDocument("Text"). Because of the implicit unboxing and boxing, the Getter returns the "wrong" value. You can fix this problem by delaying the event, although this may cause other problems. For example, you could replace the RaiseEvent with...

Dispatcher.BeginInvoke(Sub()
                           RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
                       End Sub,
                       DispatcherPriority.ApplicationIdle)

This causes the event to be raised when control returns to the framework. In simple cases, this works well, but may break more complex code. Try it now.


Note this is not a problem in C#. When coding this in C# you have to prefix the first parameter to SetProperty as ref. When you do this you get a build error saying "Property or indexer may not be passed as an out or ref parameter".

SetProperty(ref drDocument["Text"], value)

There is an alternative solution that requires overloads of SetProperty. It works pretty seamlessly. Add two more versions of SetProperty.

Public Function SetProperty(Of T)(dr As Data.DataRow, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional ColumnName As String = Nothing, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
    If Not dr.Table.Columns.Contains(ColumnName) Then Throw New ArgumentException(String.Format("Column {0} not found.", ColumnName))
    If Object.Equals(dr(ColumnName), value) Then Return False
    dr(ColumnName) = value
    NotifyPropertyChanged(PropertyName)
    Return True
End Function
 
Public Function SetProperty(Of T)(dr As Data.DataRowView, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional ColumnName As String = Nothing, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
    If Not dr.Row.Table.Columns.Contains(ColumnName) Then Throw New ArgumentException(String.Format("Column {0} not found.", ColumnName))
    If Object.Equals(dr(ColumnName), value) Then Return False
    dr(ColumnName) = value
    NotifyPropertyChanged(PropertyName)
    Return True
End Function

Take the asynchronous call of RaiseEvent out.

Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
    RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
End Sub

Because the name of the property is the same as the name of the data column, we can change the Text property to look like this.

Public Property Text As String
    Get
        Return drDocument("Text").ToString()
    End Get
    Set(value As String)
        SetProperty(drDocument, value)
    End Set
End Property

When you run it now it works correctly and will also work in more complicated code.





Tuesday, September 21, 2021

String Map Converter

I find myself frequently having to convert one string to another in my XAML. For example I have a control that should be hidden when one option is chosen in a dropdown list but visible for the other options. This can be done through a trigger or a custom converter, but I wrote a generic converter that takes a value and a parameter 'a=b,c=d,e' which returns b if a is passed in, d if c is passed in, otherwise e.

For example, suppose I have a property called TransactionType that can take the value 'STORES' or 'VENDOR'. I want a warehouse warning to be visible when the value is STORES, otherwise hidden. I can use the visibility binding of 

Visibility="{Binding TransactionType, Converter={StaticResource StringMapConverter}, ConverterParameter='STORES=Visible,Collapsed'}"

You can use this converter for text, colors, masks, or any property that can take a string value including numerics and booleans. This is much more compact than triggers and more intuitive than custom converters. It can also be used on any binding and does not require access to a style.

Here's an example showing the converter and how to use it.

Start a Visual Studio WPF, Framework, Visual Basic project (because I'm rolling that way today) and call it StringMapConverter. Add a Converters class to it. 

Add this one converter to the Converter class. As you can see, the last parameter that does not contain an equals sign becomes the default. Note I'm not trimming spaces - you can add that if you want.

<ValueConversion(GetType(String), GetType(String))>
Public Class StringMapConverter
    Implements IValueConverter
 
    Public Function Convert(value As Object, targetType As Type, parameter As Object, culture As Globalization.CultureInfo) As Object Implements IValueConverter.Convert
 
        If value Is Nothing Then Return ""
        If parameter Is Nothing Then Return value
 
        If TypeOf parameter IsNot String Then Return value
 
        Dim Pairs As New Dictionary(Of String, String)()
        Dim d As String = ""
 
        For Each p As String In parameter.ToString().Split(","c)
            If p.Contains("=") Then
                Pairs.Add(p.Split("="c)(0), p.Split("="c)(1))
            Else
                d = p
            End If
        Next
 
        If Pairs.ContainsKey(value.ToString()) Then
            Return Pairs(value.ToString())
        Else
            Return d
        End If
    End Function
 
    Public Function ConvertBack(value As Object, targetType As Type, parameter As Object, culture As Globalization.CultureInfo) As Object Implements IValueConverter.ConvertBack
        Throw New NotImplementedException()
    End Function
End Class

Now it's time for the MainWindow.xaml. It's just a combo box and a text block. The use of the converter is near the end.

<Window x:Class="StringMapConverterTest"
        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:StringMapConverter"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="String Map Converter" Height="450" Width="800">
    <Window.Resources>
        <local:StringMapConverter x:Key="StringMapConverter"/>
    </Window.Resources>
    <StackPanel Orientation="Horizontal" Height="22">
        <ComboBox SelectedValue="{Binding TransactionType}" SelectedValuePath="Content" Width="100">
            <ComboBoxItem Content="STORES"/>
            <ComboBoxItem Content="VENDOR"/>
        </ComboBox>
        <TextBlock Text="Warehouse Required" Foreground="Red" Margin="20,0,0,0"
                   Visibility="{Binding TransactionType, Converter={StaticResource StringMapConverter}, ConverterParameter='STORES=Visible,Collapsed'}"/>
    </StackPanel>
</Window>

Lastly we have the code-behind in Window.xaml.vb. It's trivial.

Imports System.ComponentModel 
Class StringMapConverterTest
    Implements INotifyPropertyChanged
 
    Private _TransactionType As String = "STORES"
    Public Property TransactionType As String
        Get
            Return _TransactionType
        End Get
        Set(value As String)
            _TransactionType = value
            NotifyPropertyChanged()
        End Set
    End Property
 
    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
 
    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(PropertyName))
    End Sub
 
End Class

The result looks like this.