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.





No comments:

Post a Comment