Wednesday, October 9, 2024

How to make Text Boxes ReadOnly when their container is Disabled

WPF has a feature that whenever a container is disabled, all its controls are disabled too. We use this feature to easily and reliably disable entire pages with a single binding.

<Grid IsEnabled="{Binding IsEditable}">
...
</Grid>

The problem is that some controls such as TextBoxes, DataGrids, and ListBoxes don't disable well because you cannot interact with them. These controls support IsReadOnly which prevents them from being modified but still allows interaction.

If only there was a way to stop TextBoxes from inheriting their parent's IsEnabled. Instead I would like the text box to become read only when the container is disabled. But if the text box has IsReadOnly bound, the container needs to honor that to.

Here it is...

Start a new Visual Studio project called IsReadOnlyTextBox using Visual Basic, .Net Framework.
Add a new class called CustomGrid and another called CustomTextBox. For a full implementation of this feature we need to subclass all potential containers and all potential controls.

Let's start with the control. We need to break the inheritance of IsEnabled and also intercept changes to IsReadOnly.

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

This allows us to intercept changes to IsReadOnly but we need to know why it was changed. Was it changed because of a binding or because the container was disabled? If it was changed because of the binding we need to store the new value so if the container is re-enabled we can set the text boxes IsReadOnly back to its bound value.

The whole CustomTextBox class looks like this.

Public Class CustomTextBox
    Inherits TextBox

    Public Property IsReadOnlyBackup As Boolean = False
    Public Property IsSetting As Boolean = False

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

    Shared Sub IsReadOnlyChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
        Dim tb As CustomTextBox = TryCast(d, CustomTextBox)
        If Not tb.IsSetting Then
            Debug.WriteLine($"Setting IsReadOnlyBackup {e.NewValue}")
            tb.IsReadOnlyBackup = Convert.ToBoolean(e.NewValue)
        End If
    End Sub

    Shared Function IsReadOnlyCoerced(d As DependencyObject, value As Object) As Object
        Return value
    End Function

    Public Sub SetIsReadOnly(value As Boolean)
        IsSetting = True
        Debug.WriteLine($"SetIsReadOnly {value}")
        If value Then
            Me.IsReadOnly = True
        Else
            Me.IsReadOnly = IsReadOnlyBackup
        End If
        Debug.WriteLine($"IsReadOnly is now {Me.IsReadOnly}")
        IsSetting = False
    End Sub
End Class

We need to subclass the container, in this case Grid, to intercept changes to IsEnabled and apply the change to its children. It looks like this...

Public Class CustomTextBox
    Inherits TextBox

    Public Property IsReadOnlyBackup As Boolean = False
    Public Property IsSetting As Boolean = False

    Shared Sub New()

        IsEnabledProperty.OverrideMetadata(GetType(CustomTextBox),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))

        IsReadOnlyProperty.OverrideMetadata(GetType(CustomTextBox),
                                            New FrameworkPropertyMetadata(
                                                defaultValue:=False,
                                                propertyChangedCallback:=AddressOf IsReadOnlyChanged,
                                                coerceValueCallback:=AddressOf IsReadOnlyCoerced
                                          ))
    End Sub

    Shared Sub IsReadOnlyChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
        Dim tb As CustomTextBox = TryCast(d, CustomTextBox)
        If Not tb.IsSetting Then
            Debug.WriteLine($"Setting IsReadOnlyBackup {e.NewValue}")
            tb.IsReadOnlyBackup = Convert.ToBoolean(e.NewValue)
        End If
    End Sub

    Shared Function IsReadOnlyCoerced(d As DependencyObject, value As Object) As Object
        Return value
    End Function

    Public Sub SetIsReadOnly(value As Boolean)
        IsSetting = True
        Debug.WriteLine($"SetIsReadOnly {value}")
        If value Then
            Me.IsReadOnly = True
        Else
            Me.IsReadOnly = IsReadOnlyBackup
        End If
        Debug.WriteLine($"IsReadOnly is now {Me.IsReadOnly}")
        IsSetting = False
    End Sub
End Class

Here's some XAML that demonstrates how these two controls work together.

<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:IsReadOnlyTextBox"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="Inheriting IsEnabled" SizeToContent="WidthAndHeight">
    <StackPanel Orientation="Vertical" Margin="10">
        <StackPanel Orientation="Horizontal">
            <CheckBox IsChecked="{Binding IsGridEnabled}" Content="Enable Grid"/>
            <CheckBox IsChecked="{Binding IsCheckboxEnabled}" Content="Checkbox is enabled" Margin="20,0,0,0"/>
            <CheckBox IsChecked="{Binding IsOldTextboxReadOnly}" Content="Old TextBox is Readonly" Margin="20,0,0,0"/>
            <CheckBox IsChecked="{Binding IsNewTextboxReadOnly}" Content="New TextBox is Readonly" Margin="20,0,0,0"/>
        </StackPanel>
        <Border Margin="10" Padding="10" BorderThickness="1" BorderBrush="Black">
            <local:CustomGrid IsEnabled="{Binding IsGridEnabled}">
                <Grid.RowDefinitions>
                    <RowDefinition Height="auto"/>
                    <RowDefinition Height="auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <CheckBox Grid.Row="0" Grid.Column="1" Content="A check box" IsEnabled="{Binding IsCheckboxEnabled, Mode=TwoWay}"/>
                <TextBox Grid.Row="1" Grid.Column="2"
                         Text="A regular old text box" Width="100"
                         IsReadOnly="{Binding IsOldTextboxReadOnly, Mode=TwoWay}"
                         HorizontalScrollBarVisibility="Auto"/>
                <local:CustomTextBox Grid.Row="1" Grid.Column="3"
                                     Text="A new custom text box" Width="100"
                                     IsReadOnly="{Binding IsNewTextboxReadOnly, Mode=TwoWay}"
                                     HorizontalScrollBarVisibility="Auto"/>
            </local:CustomGrid>
        </Border>
    </StackPanel>
</Window>

The code behind looks like this...
Imports System.ComponentModel
Imports System.Runtime.CompilerServices

Class MainWindow
    Implements INotifyPropertyChanged

    Private _IsGridEnabled As Boolean = True
    Public Property IsGridEnabled As Boolean
        Get
            Return _IsGridEnabled
        End Get
        Set(value As Boolean)
            SetProperty(_IsGridEnabled, value)
        End Set
    End Property

    Private _IsOldTextboxReadOnly As Boolean = False
    Public Property IsOldTextboxReadOnly As Boolean
        Get
            Return _IsOldTextboxReadOnly
        End Get
        Set(value As Boolean)
            SetProperty(_IsOldTextboxReadOnly, value)
        End Set
    End Property

    Private _IsNewTextboxReadOnly As Boolean
    Public Property IsNewTextboxReadOnly As Boolean
        Get
            Return _IsNewTextboxReadOnly
        End Get
        Set(value As Boolean)
            SetProperty(_IsNewTextboxReadOnly, value)
        End Set
    End Property

    Private _IsCheckboxEnabled As Boolean = True

    Public Sub New()
        Debug.WriteLine("-------------------")
        InitializeComponent()
    End Sub

    Public Property IsCheckboxEnabled As Boolean
        Get
            Return _IsCheckboxEnabled
        End Get
        Set(value As Boolean)
            SetProperty(_IsCheckboxEnabled, value)
        End Set
    End Property

    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <CallerMemberName> Optional PropertyName As String = "") As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
    Public Sub NotifyPropertyChanged(PropertyName As String)
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(PropertyName))
    End Sub
End Class

The result looks like this...


When the grid is disabled the new custom text control goes into Read Only mode.



Tuesday, October 1, 2024

Breaking the IsEnabled inheritance

One useful feature of WPF is that when you disable a container, all the controls in that container are disabled too. So if you want to put a page into read-only mode, you just have to disabled the page. The upside is simplicity and security. The downside to this is that there are probably some buttons you want the user to be able to click on.

The common technique to get around this is to put the buttons in a different container. However, this does not solve the problems associated with grids, lists, multi-line text boxes and other controls that need to respond to user interactions without allowing the user to edit them.

I read an interesting response to a question on StackOverflow see the first answer here https://stackoverflow.com/questions/14584662/enable-a-child-control-when-the-parent-is-disabled. It struck me as a very clever solution and definitely not a hack. I rewrote it in Visual Basic and created a full solution.

The idea is to wrap a component that should inherits its parent IsEnabled property in a container that breaks that inheritance. It's really quite elegant. 

Start a WPF, VB, .Net Framework Visual Studio project and call it BreakEnabledInheritance. It should work for .Net Core just as well.

Add a class called BreakEnabledInheritanceContainer. The code is very simple.

Public Class BreakEnabledInheritanceContainer
    Inherits ContentControl

    Shared Sub New()
        IsEnabledProperty.OverrideMetadata(GetType(BreakEnabledInheritanceContainer),
                                           New UIPropertyMetadata(defaultValue:=True,
                                                                  propertyChangedCallback:=Sub(a, b)
                                                                                           End Sub,
                                                                  coerceValueCallback:=Function(a, b) b))
    End Sub

End Class

Now replace MainWindow.xaml with this. It creates two multi-line text boxes inside a disabled grid. The second text box is wrapped by a BreakEnabledInheritanceContainer and has IsReadOnly set true.
This leaves IsEnabled true and IsReadOnly true which allows the user to scroll it but not change it.

The first text box directly inherits the grid's IsEnabled so the user cannot interact with it at all.

<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:BreakEnabledInheritance"
        DataContext="{Binding RelativeSource={RelativeSource self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <Style TargetType="TextBox" x:Key="MultiLineTextBoxStyle" BasedOn="{StaticResource {x:Type TextBox}}">
            <Setter Property="TextWrapping" Value="Wrap"/>
            <Setter Property="AcceptsReturn" Value="True"/>
            <Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
            <Setter Property="VerticalContentAlignment" Value="Top"/>
        </Style>
    </Window.Resources>
    <Grid IsEnabled="false" Height="30">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <TextBox Grid.Column="0" Style="{StaticResource MultiLineTextBoxStyle}" Text="{Binding LongText}"/>
        <local:BreakEnabledInheritanceContainer Grid.Column="1">
            <TextBox Grid.Column="0" Style="{StaticResource MultiLineTextBoxStyle}" Text="{Binding LongText}" IsReadOnly="True"/>
        </local:BreakEnabledInheritanceContainer>
    </Grid>
</Window>

The code behind is trivial.
Class MainWindow
    Public Property LongText As String = "This is a long" & Environment.NewLine & "piece of text" & Environment.NewLine & "spread over several" & Environment.NewLine & "lines"
End Class

Here's the result...


This approach has the advantage that the preferred solution (controls inherit their parent's IsEnabled) can be used for most controls and we can override that functionality for selected controls. This reduces the chance of a developer accidently enabling a control that should be disabled and vice versa.

Displaying browsing history

I came across a good article that explains how to access a browser's recent history but it is written in C++ for some reason. I wanted to see how to do it using C# so I wrote this project. The original article is at https://www.codeproject.com/Articles/5388718/Displaying-recent-browsing-history If you don't subscribe to the CodeProject newsletters you can subscribe at https://www.codeproject.com/Feature/Insider/

You will need SQLite and also the .Net wrapper. Start a new WPF C# .NetCore project in Visual Studio and call it BrowserHistory. I'm going to display the Edge browser history but if you read the original article you can see how to easily modify the project to display the Chrome history.

Using Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution, select Browse and search for SQLite (only one L). Install it. Now search for System.Data.SQLite and install that. Your solution looks like this.


Next we will write the XAML. Alter MainWindow.xaml to look like this. It defines a datagrid that binds to a property called dtHistory.

<Window x:Class="BrowserHistory.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:BrowserHistory"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="Edge Browser History" Height="450" Width="800">
    <Grid>
        <DataGrid ItemsSource="{Binding dtHistory}" IsReadOnly="True" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Visited (UT)" Binding="{Binding DateTimeStamp}" Width="auto"/>
                <DataGridTextColumn Header="URL" Binding="{Binding url}" Width="*"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

Now we can work on the fun stuff. The steps are.

  1. Find the browser history
  2. Make a copy (because the browser may have the original locked)
  3. Read parts of the browser history into a data table
  4. Convert microseconds since 1/1/1601 into a date time so we can display it
Step 1. The Edge browsing history for the current user can be found here...

        private String GetHistoryPath()
        {
            return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\Edge\\User Data\\Default\\History";
        }

Step 2. We copy the database into the temp folder, deleting old copies first.

        private String CopyHistoryToTemp(String HistoryPath)
        {
            String TempPath = "";
            try
            {
                TempPath = Path.Combine(Path.GetTempPath(), "History");
                DeleteTemp(TempPath);
                File.Copy(HistoryPath, TempPath);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"CopyHistoryToTemp:{ex.Message}");
                Shutdown();
            }
            return TempPath;
        }

        private void DeleteTemp(String TempPath)
        {
            try
            {
                File.Delete(TempPath);
            }
            catch (Exception ex)
            {
                // Don't sweat it
            }
        }

Step 3. Open the database and read the history into a data table. 

        private DataTable GetHistoryDT(String TempPath)
        {
            SQLiteConnection db = null;
            String SQL = "SELECT u.url, v.visit_time FROM urls u JOIN visits v on u.id=v.url ORDER BY v.visit_time DESC;";
            SQLiteDataAdapter da;
            SQLiteCommand cmd;
            DataTable dt = new DataTable();

            try
            {
                db = new SQLiteConnection($"data source={TempPath}");
                db.Open();
                cmd = db.CreateCommand();
                cmd.CommandText = SQL;
                da = new SQLiteDataAdapter(cmd);
                da.Fill(dt);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"GetHistoryDT:{ex.Message}");
                Shutdown();
            }
            finally
            {
                if (db != null) db.Close();
            }
            return dt;
        }

Step 4. Create and populate a DateTimeStamp column because microseconds since 1/1/1601 isn't very useful.

        private DataTable PopulateDateTimeStamp(DataTable dt)
        {
            dt.Columns.Add(new DataColumn("DateTimeStamp", typeof(DateTime)));
            foreach (DataRow dr in dt.Rows)
            {
                dr["DateTimeStamp"] = ConvertWebkitToDateTime(Convert.ToInt64(dr["visit_time"]));
            }
            return dt;
        }

        private DateTime ConvertWebkitToDateTime(long WebKitTime)
        {
            return new DateTime(1601, 1, 1).AddMicroseconds(WebKitTime);
        }

Lastly we add a trivial method to terminate the application if there is an error.

        private void Shutdown()
        {
            Environment.Exit(0);
        }

If you run the program you will see results like this.



The complete MainWindow.xaml.cs looks like this.

using System.IO;
using System.Windows;
using System.Data.SQLite;
using System.Data;

namespace BrowserHistory
{
    public partial class MainWindow : Window
    {
        public DataTable dtHistory { get; set; }

        public MainWindow()
        {
            String HistoryPath = "";
            String TempPath = "";

            try
            {
                HistoryPath = GetHistoryPath();
                TempPath = CopyHistoryToTemp(HistoryPath);
                dtHistory = GetHistoryDT(TempPath);
                PopulateDateTimeStamp(dtHistory);
                InitializeComponent();
            }
            catch (Exception ex)
            {
                MessageBox.Show($"Main:{ex.Message}");
            }
            finally
            {
                DeleteTemp(TempPath);
            }
        }

        private String GetHistoryPath()
        {
            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ,"\\Microsoft\\Edge\\User Data\\Default\\History");
        }

        private String CopyHistoryToTemp(String HistoryPath)
        {
            String TempPath = "";
            try
            {
                TempPath = Path.Combine(Path.GetTempPath(), "History");
                DeleteTemp(TempPath);
                File.Copy(HistoryPath, TempPath);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"CopyHistoryToTemp:{ex.Message}");
                Shutdown();
            }
            return TempPath;
        }

        private void DeleteTemp(String TempPath)
        {
            try
            {
                File.Delete(TempPath);
            }
            catch (Exception ex)
            {
                // Don't sweat it
            }
        }

        private DataTable GetHistoryDT(String TempPath)
        {
            SQLiteConnection db = null;
            String SQL = "SELECT u.url, v.visit_time FROM urls u JOIN visits v on u.id=v.url ORDER BY v.visit_time DESC;";
            SQLiteDataAdapter da;
            SQLiteCommand cmd;
            DataTable dt = new DataTable();

            try
            {
                db = new SQLiteConnection($"data source={TempPath}");
                db.Open();
                cmd = db.CreateCommand();
                cmd.CommandText = SQL;
                da = new SQLiteDataAdapter(cmd);
                da.Fill(dt);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"GetHistoryDT:{ex.Message}");
                DeleteTemp(TempPath);
                Shutdown();
            }
            finally
            {
                if (db != null) db.Close();
            }

            return dt;
        }

        private DataTable PopulateDateTimeStamp(DataTable dt)
        {
            dt.Columns.Add(new DataColumn("DateTimeStamp", typeof(DateTime)));
            foreach (DataRow dr in dt.Rows)
            {
                dr["DateTimeStamp"] = ConvertWebkitToDateTime(Convert.ToInt64(dr["visit_time"]));
            }
            return dt;
        }

        private DateTime ConvertWebkitToDateTime(long WebKitTime)
        {
            return new DateTime(1601, 1, 1).AddMicroseconds(WebKitTime);
        }

        private void Shutdown()
        {
            Environment.Exit(0);
        }
    }
}



Tuesday, September 24, 2024

DataColumn AutoIncrement - sequence of events does matter

I spent quite a while today trying to figure out some bizarre behavior in the DataTable.NewRow method.

I have a data table with 4 rows in it. One of the columns is the primary key and has AutoIncrement turned on. The Seed and Step are both -1. When I add a new row, I expect the primary key to be set to -1. This isn't happening. It's setting the primary key to one less than the maximum value in the existing rows. Let me show you with a simple example.

Imports System.Data

Module Program
    Sub Main(args As String())
        Dim dt As New DataTable()

        dt.Columns.Add(New DataColumn("ID", GetType(Integer)))
        dt.Columns.Add(New DataColumn("Code", GetType(String)))

        dt.PrimaryKey = {dt.Columns("ID")}
        With dt.Columns("ID")
            .AutoIncrement = True
        End With

        dt.Rows.Add({1, "1"})
        dt.Rows.Add({2, "2"})

        With dt.Columns("ID")
            .AutoIncrementSeed = -1
            .AutoIncrementStep = -1
        End With

        Dim dr As DataRow = dt.NewRow()
        Console.WriteLine(dr("ID").ToString())
        Console.ReadKey()
    End Sub
End Module

If you run this you get the result "1". I'm expecting "-1". You can fix it by moving .AutoIncrement = True after you populate the rows.

Imports System.Data

Module Program
    Sub Main(args As String())
        Dim dt As New DataTable()

        dt.Columns.Add(New DataColumn("ID", GetType(Integer)))
        dt.Columns.Add(New DataColumn("Code", GetType(String)))

        dt.PrimaryKey = {dt.Columns("ID")}
 
        dt.Rows.Add({1, "1"})
        dt.Rows.Add({2, "2"})

        With dt.Columns("ID")
            .AutoIncrement = True
            .AutoIncrementSeed = -1
            .AutoIncrementStep = -1
        End With

        Dim dr As DataRow = dt.NewRow()
        Console.WriteLine(dr("ID").ToString())
        Console.ReadKey()
    End Sub
End Module

It appears when you first set AutoIncrement true the data table evaluates the contents of the column and its behavior depends on what values it finds. Curiously, even the following code fails.


Imports System.Data

Module Program
    Sub Main(args As String())
        Dim dt As New DataTable()

        dt.Columns.Add(New DataColumn("ID", GetType(Integer)))
        dt.Columns.Add(New DataColumn("Code", GetType(String)))

        dt.PrimaryKey = {dt.Columns("ID")}
         With dt.Columns("ID")
            .AutoIncrement = True
        End With

        dt.Rows.Add({1, "1"})
        dt.Rows.Add({2, "2"})

        With dt.Columns("ID")
            .AutoIncrement = False
            .AutoIncrement = True
            .AutoIncrementSeed = -1
            .AutoIncrementStep = -1
        End With

        Dim dr As DataRow = dt.NewRow()
        Console.WriteLine(dr("ID").ToString())
        Console.ReadKey()
    End Sub
End Module

Friday, August 9, 2024

FODY

The Problem

I spent some time looking at ways to reduce all the repetitive property declarations required for MVVM. So have a lot of other people, most of whom are far smarter than me.

The Community Toolkit looked very promising.


But even though it claims to work in VB and .Net Framework it does not. No errors, no warnings, no binding errors, modifying properties in controls does not update the bound properties. So that's a shame.

The good news

One of my colleagues recommend FODY - a GitHub project that claims to work for VB in .Net Framework. OK - I'm game. I put together the most trivial project I could think of. We have our ViewModels in our code-behind, we use VB, and we're still on .Net Framework. Yes, we're practically Neanderthal.


FODY is much bigger than simply providing MVVM functionality. It's a framework for all sorts of things. The specific package I need is called PropertyChanged.Fody. Let's get started then.

In Visual Studio 2022 create a WPF, VB, .NetFramework project called FODY


In the menu select Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution...

Click the Browse tab and search for FODY


Install Fody and PropertyChanged.Fody into your solution. Your packages.config will look something like this.

<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="Fody" version="6.8.1" targetFramework="net472" developmentDependency="true" />
  <package id="PropertyChanged.Fody" version="4.1.0" targetFramework="net472" />
</packages>


Now we will write some standard XAML with a text box and a text block. Whatever you type in the text box will appear in the text block thanks to some MVVM bindings. Fody does not require any changes to your XAML.

<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:FODY"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="FODY Test" Height="450" Width="800">
    <StackPanel Orientation="Vertical" HorizontalAlignment="Left">
        <TextBox Text="{Binding theText, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
        <TextBlock Text="{Binding theText}"/>
    </StackPanel>
</Window>

Without clever add-ins the view model would look something like this. Which requires a lot of typing.

Imports System.ComponentModel
Imports System.Runtime.CompilerServices

Class MainWindow
    Implements INotifyPropertyChanged

    Private _theText As String = ""
    Public Property theText As String
        Get
            Return _theText
        End Get
        Set(value As String)
            SetProperty(_theText, value)
        End Set
    End Property

    Public Event PropertyChanged As System.ComponentModel.PropertyChangedEventHandler Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged

    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
End Class

The end result predictably looks like this

But with Fody we can replace these 30 lines of code with something far more succinct.

Imports PropertyChanged
<AddINotifyPropertyChangedInterface>
Class MainWindow
    Public Property theText As String = ""
End Class

Which gives us exactly the same result.

So what's the bad news?

A common technique for investigating binding issues is to put break points on property getters and setters to make sure they are being called when expected. Setter not called when the user modifies a control - check UpdateSourceTrigger. Getter not called after the setter is called - control is not bound to the property. When we don't have explicit setters and getters, we can't use these techniques.

So maybe all that repetitive code has value after all.