Harald Coppoolse
Harald Coppoolse

Reputation: 30454

WPF EventHandler for TextBox.TextChanged in XAML or code behind?

Description

In WPF, using MvvmLight, I have a viewModel with an integer property SelectedIndex. Changing the value of this property is an expensive operation, so I only want to update the property if the operator is fairly certain that he finished typing.

I have a TextBox and a button. The operator types a number, and presses the button. This should lead to a command that updates the property.

Standard WPF MvvmLight solution for this

class MyViewModel
{
    private int selectedIndex;

    public MyViewModel()
    {
        this.CommandSelectIndex = new RelayCommand(ExecuteSelectIndex, CanSelectIndex);
    }

    public public RelayCommand<int> CommandSelectIndex { get; }

    public int SelectedIndex
    {
        get => this.selectedIndex;
        set => base.Set(nameof(SelectedIndex), ref this.selectedIndex, value);
    }

    private bool CanSelectIndex(int proposedIndex)
    {
         return proposedIndex > 0 && proposedIndex < MyData.Count;
    }

    private void ExecuteSelectIndex(int proposedIndex)
    {
        this.SelectedIndex = proposedIndex;
        ProcessSelectedIndex(proposedIndex);  // Expensive!
    }
}

For those who know MvvmLight, this is fairly straightforward.

So while the operator is typing a number, I only want to update the button. I don't want to do anything with the intermediate values:

1 --> 12 --> 123 --> (typing error, backspace) --> 124 [press button]

XAML

<StackPanel Name="Test1" Orientation="Horizontal">
    <TextBox Name="ProposedValue1" Text="1234" Width="300" Height="20"/>
    <Button x:Name="ButtonChangeText1" Content="Change"
                    Height="30" Width="74" Padding="5,2"
                    Command="{Binding Path=CommandSelectedIndex}"
                    CommandParameter="{Binding ElementName=ProposedValue1, Path=Text}"/>
</StackPanel>

This works partly: at startup CanSelectIndex(1234) is called; If the button is pressed ExecuteSelectedIndex(1234) is called.

Problem

However, if the text of the TextBox changes, CanSelectIndex is not called.

The reason is because event ICommand.CanExecuteChanged is not raised when the textbox changes.

Solution:

Add an event handler:

XAML:

<TextBox Name="ProposedValue1" Text="1234" Width="300" Height="20"
         TextChanged="textChangedEventHandler"/>

Code behind:

private void textChangedEventHandler(object sender, TextChangedEventArgs args)
{
    ((MyViewModel)this.DataContext).CommandSelectedIndex.RaiseCanExecuteChanged();
}

I always feel a bit uneasy whenever I have to write code behind. Is it standard to write eventhandlers in code behind, or is that a simplification that I only see in tutorials.

Is there a method that I can do this in XAML? Something with Binding?

TextChanged="TextChanged="{Binding Path=CommandSelectIndex ??? RaiseCanExecuteChanged() }

Upvotes: 0

Views: 1081

Answers (3)

EldHasp
EldHasp

Reputation: 7908

@Harald Coppulse, you are absolutely right!

Here is my test code for MvvmLight.

ViewModel:

using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;

namespace InvalidateCommandMvvmLight.ViewModel
{
    public class MyViewModel : ViewModelBase
    {
        private string _text;
        private int _number;

        public string Text { get => _text; private set => Set(ref _text, value); }

        public int Number { get => _number; set => Set(ref _number, value); }

        public RelayCommand<string> CommandTest { get; }
        public RelayCommand<int> CommandNumber { get; }

        public MyViewModel()
        {
            CommandTest = new RelayCommand<string>(Test, CanTest);
            CommandNumber = new RelayCommand<int>(IntTest, CanIntTest);
        }

        private bool CanTest(string text)
        {
            // the text must have a minimum length of 4 
            // and be different from the current one
            return text != null && text.Length >= 4 && text != Text;
        }
        private void Test(string text)
        {
            Text = text;

        }

        private bool CanIntTest(int num)
        {
            // The "num" parameter must be positive, less than 100
            // and is not equal to the Number property
            return num > 0 && num <100 && num != Number;
        }
        private void IntTest(int num)
        {
            Number = num;
        }
    }
}

XAML:

<Window x:Class="InvalidateCommandMvvmLight.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:InvalidateCommandMvvmLight"
        xmlns:vm="clr-namespace:InvalidateCommandMvvmLight.ViewModel"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <vm:MyViewModel/>
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <TextBox x:Name="tbText"
                Text="Alle eendjes zwemmen in het water" VerticalAlignment="Center"
                />

        <Button Content="Change Text"
                Grid.Column="1"
                Margin="5"
                Padding="5,2"
                Command="{Binding Path=CommandTest}"
                CommandParameter="{Binding ElementName=tbText, Path=Text}"/>
        <TextBox Text="{Binding Text, Mode=OneWay}" Grid.Column="2" IsReadOnly="True" VerticalAlignment="Center"/>
        <TextBox x:Name="tbNumber"
                Grid.Row="1"
                Text="55" VerticalAlignment="Center"/>

        <Button Content="Change Number"
                Grid.Row="1" Grid.Column="1"
                Margin="5"
                Padding="5,2"
                Command="{Binding Path=CommandNumber}"
                CommandParameter="{Binding ElementName=tbNumber, Path=Text}"/>
        <TextBox Text="{Binding Number, Mode=OneWay}" IsReadOnly="True"
                 Grid.Row="1" Grid.Column="2" VerticalAlignment="Center"/>
    </Grid>
</Window>

Unfortunately, the CommandsWpf.RelayCommand class in MvvmLight is implemented not correctly.
It does not take into account the peculiarities of working with values ​​of different types in WPF.

To work in a typical for WPF way, an implementation should have something like this:

using System.ComponentModel;

namespace Common
{
    #region Delegates for WPF Command Methods
    /// <summary>Delegate of the executive team method.</summary>
    /// <param name="parameter">Command parameter.</param>
    public delegate void ExecuteHandler<T>(T parameter);
    /// <summary>Command сan execute method delegate.</summary>
    /// <param name="parameter">Command parameter.</param>
    /// <returns><see langword="true"/> if command execution is allowed.</returns>
    public delegate bool CanExecuteHandler<T>(T parameter);
    #endregion

    /// <summary>Class for typed parameter commands.</summary>
    public class RelayCommand<T> : RelayCommand
    {

        /// <summary>Command constructor.</summary>
        /// <param name="execute">Executable command method.</param>
        /// <param name="canExecute">Method allowing command execution.</param>
        public RelayCommand(ExecuteHandler<T> execute, CanExecuteHandler<T> canExecute = null)
            : base
        (
                  p => execute(TypeDescriptor.GetConverter(typeof(T)).IsValid(p) ? (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(p) : default),
                  p => (canExecute == null) || (TypeDescriptor.GetConverter(typeof(T)).IsValid(p) && canExecute((T)TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(p)))
        )
        {}

    }
}

Unless you have the ability to change the RelayCommand implementation, you need to somehow use Binding's ability to auto-convert values.

One variant.
Create a property of the desired type in the ViewModel and use it as a proxy for autoconversion.
But if a non-numeric value is entered, then the command will not be able to define it.
You also need to check Validation.HasError.

ViewModel:

using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;

namespace InvalidateCommandMvvmLight.ViewModel
{
    public class MyViewModel : ViewModelBase
    {
        private string _text;
        private int _number;
        private int _numberView;

        public string Text { get => _text; private set => Set(ref _text, value); }

        public int Number { get => _number; set => Set(ref _number, value); }
        public int NumberView { get => _numberView; set => Set(ref _numberView, value); }

        public RelayCommand<string> CommandTest { get; }
        public RelayCommand<int> CommandNumber { get; }

        public MyViewModel()
        {
            CommandTest = new RelayCommand<string>(Test, CanTest);
            CommandNumber = new RelayCommand<int>(IntTest, CanIntTest);
        }

        private bool CanTest(string text)
        {
            // the text must have a minimum length of 4 
            // and be different from the current one
            return text != null && text.Length >= 4 && text != Text;
        }
        private void Test(string text)
        {
            Text = text;

        }

        private bool CanIntTest(int num)
        {
            // The "num" parameter must be positive, less than 100
            // and is not equal to the Number property
            return num > 0 && num <100 && num != Number;
        }
        private void IntTest(int num)
        {
            Number = num;
        }
    }
}

XAML:

<Window x:Class="InvalidateCommandMvvmLight.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:InvalidateCommandMvvmLight"
        xmlns:vm="clr-namespace:InvalidateCommandMvvmLight.ViewModel"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <vm:MyViewModel NumberView="55"/>
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <TextBox x:Name="tbText"
                Text="Alle eendjes zwemmen in het water" VerticalAlignment="Center"
                />

        <Button Content="Change Text"
                Grid.Column="1"
                Margin="5"
                Padding="5,2"
                Command="{Binding Path=CommandTest}"
                CommandParameter="{Binding ElementName=tbText, Path=Text}"/>
        <TextBox Text="{Binding Text, Mode=OneWay}" Grid.Column="2" IsReadOnly="True" VerticalAlignment="Center"/>
        <TextBox x:Name="tbNumber"
                Grid.Row="1"
                Text="{Binding NumberView, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>

    <Button Content="Change Number"
                Grid.Row="1" Grid.Column="1"
                Margin="5"
                Padding="5,2"
                Command="{Binding Path=CommandNumber}"
                CommandParameter="{Binding NumberView}">
        <Button.Style>
            <Style TargetType="Button">
                <Style.Triggers>
                    <DataTrigger Binding="{Binding Path=(Validation.HasError), ElementName=tbNumber}"
                                 Value="True">
                        <Setter Property="IsEnabled" Value="False"/>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </Button.Style>
    </Button>
        <TextBox Text="{Binding Number, Mode=OneWay}" IsReadOnly="True"
                 Grid.Row="1" Grid.Column="2" VerticalAlignment="Center"/>
    </Grid>
</Window>

Second variant.
Create an explicit proxy converter.

Converter:

using System;
using System.ComponentModel;
using System.Windows;

namespace InvalidateCommandMvvmLight
{
    public class ProxyBinding : Freezable
    {
        public Type Type
        {
            get { return (Type)GetValue(TypeProperty); }
            set { SetValue(TypeProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Type.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty TypeProperty =
            DependencyProperty.Register(nameof(Type), typeof(Type), typeof(ProxyBinding), new PropertyMetadata(typeof(object), ChangedValueOrType));

        private static void ChangedValueOrType(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            ProxyBinding proxy = (ProxyBinding)d;
            if (proxy.Type == null)
            {
                proxy.Value = null;
                return;
            }
            if (proxy.Source == null)
                return;

            if (proxy.Type == proxy.Source.GetType())
                return;

            if (TypeDescriptor.GetConverter(proxy.Type).IsValid(proxy.Source))
                proxy.Value = TypeDescriptor.GetConverter(proxy.Type).ConvertFrom(proxy.Source);
            else
                proxy.Value = null;
        }

        public object Source
        {
            get { return GetValue(SourceProperty); }
            set { SetValue(SourceProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Value.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty SourceProperty =
            DependencyProperty.Register(nameof(Source), typeof(object), typeof(ProxyBinding), new PropertyMetadata(null, ChangedValueOrType));

        public object Value
        {
            get { return GetValue(ValueProperty); }
            protected  set { SetValue(ValuePropertyKey, value); }
        }

        // Using a DependencyProperty as the backing store for readonly Value.  This enables animation, styling, binding, etc...
        protected static readonly DependencyPropertyKey ValuePropertyKey =
            DependencyProperty.RegisterReadOnly(nameof(Value), typeof(object), typeof(ProxyBinding), new PropertyMetadata(null));
        public static readonly DependencyProperty ValueProperty = ValuePropertyKey.DependencyProperty;

        protected override Freezable CreateInstanceCore()
        {
            return new ProxyBinding();
        }
    }
}

XAML:

<Window x:Class="InvalidateCommandMvvmLight.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:InvalidateCommandMvvmLight"
            xmlns:vm="clr-namespace:InvalidateCommandMvvmLight.ViewModel"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        mc:Ignorable="d"
            Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <vm:MyViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <local:ProxyBinding x:Key="ProxyInt"
                Type="{x:Type sys:Int32}"
                Source="{Binding ElementName=tbNumber, Path=Text, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"/>
    </Window.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <TextBox x:Name="tbText"
                    Text="Alle eendjes zwemmen in het water" VerticalAlignment="Center"
                    />

        <Button Content="Change Text"
                    Grid.Column="1"
                    Margin="5"
                    Padding="5,2"
                    Command="{Binding Path=CommandTest}"
                    CommandParameter="{Binding ElementName=tbText, Path=Text}"/>
        <TextBox Text="{Binding Text, Mode=OneWay}" Grid.Column="2" IsReadOnly="True" VerticalAlignment="Center"/>
        <TextBox x:Name="tbNumber"
                    Grid.Row="1"
                    Text="55" VerticalAlignment="Center"/>

        <Button Content="Change Number"
                    Grid.Row="1" Grid.Column="1"
                    Margin="5"
                    Padding="5,2"
                    Command="{Binding Path=CommandNumber}"
                    CommandParameter="{Binding Value, Source={StaticResource ProxyInt}}">
        </Button>
        <TextBox Text="{Binding Number, Mode=OneWay}" IsReadOnly="True"
                     Grid.Row="1" Grid.Column="2" VerticalAlignment="Center"/>
        <TextBlock Grid.Row="2" Text="{Binding Value,Source={StaticResource proxy}}"/>
    </Grid>
</Window>

Another variant.
Create converter for bindings:

using System;
using System.ComponentModel;
using System.Globalization;
using System.Windows.Data;

namespace InvalidateCommandMvvmLight
{
    public class ValueTypeConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (parameter is Type type && TypeDescriptor.GetConverter(type).IsValid(value))
                return TypeDescriptor.GetConverter(type).ConvertFrom(value);
            return null;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

XAML:

<Window x:Class="InvalidateCommandMvvmLight.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:InvalidateCommandMvvmLight"
            xmlns:vm="clr-namespace:InvalidateCommandMvvmLight.ViewModel"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        mc:Ignorable="d"
            Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <vm:MyViewModel/>
    </Window.DataContext>
    <Window.Resources>
    <local:ValueTypeConverter x:Key="ValueTypeConverter"/>
</Window.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <TextBox x:Name="tbText"
                    Text="Alle eendjes zwemmen in het water" VerticalAlignment="Center"
                    />

        <Button Content="Change Text"
                    Grid.Column="1"
                    Margin="5"
                    Padding="5,2"
                    Command="{Binding Path=CommandTest}"
                    CommandParameter="{Binding ElementName=tbText, Path=Text}"/>
        <TextBox Text="{Binding Text, Mode=OneWay}" Grid.Column="2" IsReadOnly="True" VerticalAlignment="Center"/>
        <TextBox x:Name="tbNumber"
                    Grid.Row="1"
                    Text="55" VerticalAlignment="Center"/>

        <Button Content="Change Number"
                    Grid.Row="1" Grid.Column="1"
                    Margin="5"
                    Padding="5,2"
                    Command="{Binding Path=CommandNumber}"
                    CommandParameter="{Binding Text, Converter={StaticResource ValueTypeConverter}, ConverterParameter={x:Type sys:Int32}, ElementName=tbNumber}">
        </Button>
        <TextBox Text="{Binding Number, Mode=OneWay}" IsReadOnly="True"
                     Grid.Row="1" Grid.Column="2" VerticalAlignment="Center"/>
    </Grid>
</Window>

Upvotes: 0

EldHasp
EldHasp

Reputation: 7908

The RelayCommand class in MvvmLight has two implementations. In the GalaSoft.MvvmLight.Command namespace and in the GalaSoft.MvvmLight.CommandWpf namespace.

You've probably used from namespace GalaSoft.MvvmLight.Command. And this type doesn't actually update the state of the command.

If used from the GalaSoft.MvvmLight.CommandWpf namespace, then the state of the command is updated according to the predetermined logic.

Upvotes: 1

mm8
mm8

Reputation: 169200

Is there a method that I can do this in XAML? Something with Binding?

Just bind the Text property of the TextBox to a string source property of the view model and raise call the RaiseCanExecuteChanged method of the command from the setter of this one.

If you really want to handle an actual event for some reason, you should look into interaction triggers.

Upvotes: 0

Related Questions