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.



No comments:

Post a Comment