Monday, December 17, 2018

Textbox with auto forward

I have a requirement to create a text box which automatically moves to the next field when it is full. In addition, the text box can only accept digits. There are some interesting issues when you use this inside a DataGrid. I'm going to walk through a simplified version of the custom control to highlight the DataGrid issues and solution.

Let's start by creating a new WPF project in C# called Autoforward.


Add a new class called CustomTextbox to hold the custom control. This would normally be in a separate project, but I want to keep the sample simple. The code in the class looks like this. It adds a KeyDown event handler that crudely predicts when the text box will be full, lets the event handler complete, then executes a Next TraversalRequest. This has to be done asynchronously so the text box can be updated with the key before we move to the next control.

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace Autoforward
{
    class CustomTextbox : TextBox
    {
        public CustomTextbox()
        {
            this.Loaded += Textbox_Loaded;
        }

        ~CustomTextbox()
        {
            this.KeyDown -= Textbox_KeyDown;
        }

        private void Textbox_Loaded(object sender, EventArgs e)
        {
            this.KeyDown += Textbox_KeyDown;
        }

        private void Textbox_KeyDown(object sender, KeyEventArgs e)
        {
            TextBox tb = (TextBox)sender;
            string c = KeyToChar(e.Key);
            if (c == "")
                e.Handled = true;
            else
                if ((tb.Text + c).Length == tb.MaxLength)
            {
                Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle,
                    (Action)(() =>
                    {
                        TraversalRequest tr = new TraversalRequest(FocusNavigationDirection.Next);
                        this.MoveFocus(tr);
                    }
            ));
            }
        }

        private static string KeyToChar(Key key)
        {
            if (new Regex(@"D[0-9]").IsMatch(key.ToString())) return key.ToString().Replace("D", "");
            if (new Regex(@"NumPad[0-9]").IsMatch(key.ToString())) return key.ToString().Replace("NumPad", "");
            return "";
        }

   }
}

The MainWindow.xaml looks like this. It has two text boxes with a max length defined.


<Window x:Class="Autoforward.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:Autoforward"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <StackPanel Orientation="Horizontal">
            <local:CustomTextbox Width="100" MaxLength="4"/>
            <local:CustomTextbox Width="100" MaxLength="4"/>
        </StackPanel>
    </StackPanel>
</Window>

The result, after typing five characters, looks like this.


Now let's wire up a DataGrid that uses the same custom control. Change the XAML to look like this.

<Window x:Class="Autoforward.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:Autoforward"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <StackPanel Orientation="Horizontal">
            <local:CustomTextbox Width="100" MaxLength="4"/>
            <local:CustomTextbox Width="100" MaxLength="4"/>
        </StackPanel>
        <DataGrid ItemsSource="{Binding Data}" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTemplateColumn Header="Text 1">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <local:CustomTextbox MaxLength="4" Text="{Binding text1}"></local:CustomTextbox>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Text 2">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <local:CustomTextbox MaxLength="4" Text="{Binding text2}"></local:CustomTextbox>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
            </DataGrid.Columns>
        </DataGrid>
    </StackPanel>
</Window>



And adding an items source in the code behind.

using System;
using System.Collections.ObjectModel;
using System.Windows;

namespace Autoforward
{
    public partial class MainWindow : Window
    {
        public class Datum
        {
            public String text1;
            public String text2;
        }

        public ObservableCollection<Datum> Data { get; set; }

        public MainWindow()
        {
            Data = new ObservableCollection<Datum>() { new Datum() };
            InitializeComponent();
        }
    }
}

If you run the application now and start entering text into the DataGrid you will see focus doesn't really move properly. We want the next cell to start accepting input as soon as the previous cell filled up. To do this, we have to explicitly set focus to the next cell.

The thing to do is to put the data grid into edit mode whenever any cell gets focus. This event handler is called every time a new cell gets focus. We can handle this in the custom control. Change the custom control to look like this. You probably have your own FindChildrenByType and GetControlAncestor library functions already.

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace Autoforward
{
    class CustomTextbox : TextBox
    {
        DataGrid dg = null;

        public CustomTextbox()
        {
            this.Loaded += Textbox_Loaded;
        }

        ~CustomTextbox()
        {
            this.KeyDown -= Textbox_KeyDown;
            if (dg != null) dg.GotFocus -= DataGrid_GotFocus;
        }

        private void Textbox_Loaded(object sender, EventArgs e)
        {
            this.KeyDown += Textbox_KeyDown;
            dg = GetControlAncestor<DataGrid>(this);
            if (dg != null) dg.GotFocus += DataGrid_GotFocus;
        }

        private void Textbox_KeyDown(object sender, KeyEventArgs e)
        {
            TextBox tb = (TextBox)sender;
            string c = KeyToChar(e.Key);
            if (c == "")
                e.Handled = true;
            else
                if ((tb.Text + c).Length == tb.MaxLength)
            {
                Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle,
                    (Action)(() =>
                    {
                        TraversalRequest tr = new TraversalRequest(FocusNavigationDirection.Next);
                        this.MoveFocus(tr);
                    }
            ));
            }
        }

        private void DataGrid_GotFocus(object sender, RoutedEventArgs e)
        {
            DataGridCell cell = e.OriginalSource as DataGridCell;
            if (!dg.IsReadOnly && cell != null)
            {
                foreach (TextBox child in FindChildrenByType<TextBox>(cell))
                {
                    child.Focus();
                }
            }
        }

        private static string KeyToChar(Key key)
        {
            if (new Regex(@"D[0-9]").IsMatch(key.ToString())) return key.ToString().Replace("D", "");
            if (new Regex(@"NumPad[0-9]").IsMatch(key.ToString())) return key.ToString().Replace("NumPad", "");
            return "";
        }

        private static List<T> FindChildrenByType<T>(DependencyObject Parent) where T : DependencyObject
        {

            List<T> Children = new List<T>();
            if (Parent != null)
            {
                for (int i = 0; i < VisualTreeHelper.GetChildrenCount(Parent); i++)
                {
                    DependencyObject child = VisualTreeHelper.GetChild(Parent, i);
                    if (child != null && child is T)
                        Children.Add(child as T);
                    Children.AddRange(FindChildrenByType<T>(child));
                }
            }
            return Children;
        }

        private static T GetControlAncestor<T>(DependencyObject Child) where T : DependencyObject
        {
            DependencyObject Ancestor;
            DependencyObject Parent;

            if (Child == null) return default(T);
            Ancestor = Child;
            while (Ancestor != null && !(Ancestor is T))
            {
                Parent = VisualTreeHelper.GetParent(Ancestor);
                if (Parent == null) Parent = LogicalTreeHelper.GetParent(Ancestor);
                if (Parent == null && Child is Control) Parent = (Ancestor as Control).Parent;
                Ancestor = Parent;
            }

            return Ancestor as T;
        }
    }
}

Now we have a digits-only text box that auto-forwards while inside and outside a data grid.