時刻入力用TextBoxBehavior作成 (MaskedTextProviderを利用)

#はじめに
時刻入力用のTextBoxが必要なことがあり、MaskedTextProviderを使えばいいらしいことは分かったのですが、実装がうまくできませんでした。

欲しかった画面はこんな感じでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/956246/41ce20b1-8451-64d5-bce3-2712cf230a88.png)
日付を入力して、そのあと、時間を入力するというもの。
時刻はText(string)で使えるようにしたいです。

MaterialDesignのExsampleにMaskedTextBoxがあったので使えるかなと思ったのですが、これはXceed SoftwareのExtended.Wpf.Toolkitを利用していました。
Extended.Wpf.Toolkitはライセンスが以前はMS-PLライセンスだったみたいですが、ライセンスが変わって、非商用でも10人以上のユーザーがいれば商用ライセンスが必要となったようで、採用するのは難しいと思いました。

そこで、いろいろと調べていたところ、[Blindmeis's BlogにWPF – Masked Textbox Behavior](https://blindmeis.wordpress.com/2010/06/01/wpf-masked-textbox-behavior/)がとても参考になりました。
ブログの記事だと、汎用的だったので、参考にしながら、時刻用の設定を追加したりして完成できました。

詳細は後で記事にするかもしれませんが、まずは参考にしていただければと思い、全コードを記載しておきます。
ちなみに、ビヘイビアとして作成しています。

#MaskedTextBoxBehavior : Behavior<TextBox>
```C#
using Microsoft.Xaml.Behaviors;
using System;
using System.ComponentModel;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace Sample.WPF.Behaviors
{
     public class MaskedTextBoxBehavior : Behavior<TextBox> 
    {
        #region DependencyProperties
        public string InputMask
        {
            get { return (string)GetValue(InputMaskProperty); }
            set { SetValue(InputMaskProperty, value); }
        }

        public static readonly DependencyProperty InputMaskProperty =
          DependencyProperty.Register("InputMask", typeof(string), 
                                        typeof(MaskedTextBoxBehavior),  
                                        null);


        public char PromptChar
        {
            get { return (char)GetValue(PromptCharProperty); }
            set { SetValue(PromptCharProperty, value); }
        }

        public static readonly DependencyProperty PromptCharProperty =
           DependencyProperty.Register("PromptChar", typeof(char), 
                                        typeof(MaskedTextBoxBehavior),
                                        new PropertyMetadata('_'));


        public bool IsTime
        {
            get { return (bool)GetValue(IsTimeProperty); }
            set { SetValue(IsTimeProperty, value); }
        }

        public static readonly DependencyProperty IsTimeProperty =
            DependencyProperty.Register("MyProperty", typeof(bool), 
                                        typeof(MaskedTextBoxBehavior), 
                                        new PropertyMetadata(false));

        #endregion

        public MaskedTextProvider Provider { get; private set; }

        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.Loaded += AssociatedObjectLoaded;
            AssociatedObject.PreviewTextInput += AssociatedObjectPreviewTextInput;
            AssociatedObject.PreviewKeyDown += AssociatedObjectPreviewKeyDown;
            AssociatedObject.LostFocus += AccociateObjectLostFocus;
            AssociatedObject.GotFocus += AssociatedObjectGotFocus;

            DataObject.AddPastingHandler(AssociatedObject, Pasting);
        }


        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.Loaded -= AssociatedObjectLoaded;
            AssociatedObject.PreviewTextInput -= AssociatedObjectPreviewTextInput;
            AssociatedObject.PreviewKeyDown -= AssociatedObjectPreviewKeyDown;
            AssociatedObject.LostFocus -= AccociateObjectLostFocus;
            AssociatedObject.GotFocus -= AssociatedObjectGotFocus;

            DataObject.RemovePastingHandler(AssociatedObject, Pasting);
        }


        /// <summary>
        /// カーソルを最初の位置にする
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void AssociatedObjectGotFocus(object sender, RoutedEventArgs e)
        {
            AssociatedObject.Select(0, 0);

            //選択時にSelectAllにする場合はこちら
            //AssociatedObject.Select(0, AssociatedObject.Text.Length);
        }

    
        void AssociatedObjectLoaded(object sender, System.Windows.RoutedEventArgs e)
        {
            Provider = new MaskedTextProvider(InputMask, CultureInfo.CurrentCulture);
            Provider.PromptChar = PromptChar;
            
            Provider.Set(AssociatedObject.Text);

            //ToDisplayString:表示できる形式で書式設定された文字列
            AssociatedObject.Text = Provider.ToDisplayString();

            var textProp = DependencyPropertyDescriptor.FromProperty(TextBox.TextProperty, typeof(TextBox));
            if (textProp != null)
            {
                textProp.AddValueChanged(AssociatedObject, (s, args) => UpdateText());
            }
        }

        void AssociatedObjectPreviewTextInput(object sender, System.Windows.Input.TextCompositionEventArgs e)
        {
            TreatSelectedText();

            var position = GetNextCharacterPosition(AssociatedObject.SelectionStart);

            if (Keyboard.IsKeyToggled(Key.Insert))
            {
                //上書きモード
                if (Provider.Replace(e.Text, position))
                    position++;
            }
            else
            {
                //挿入モード
                if (Provider.InsertAt(e.Text, position))
                    position++;
            }

            position = GetNextCharacterPosition(position);

            RefreshText(position);

            e.Handled = true;
        }

        void AssociatedObjectPreviewKeyDown(object sender, KeyEventArgs e)
        {

            if (e.Key == Key.Space)
            {
                TreatSelectedText();

                var position = GetNextCharacterPosition(AssociatedObject.SelectionStart);

                if (Provider.InsertAt(" ", position))
                    RefreshText(position);

                e.Handled = true;
            }

            if (e.Key == Key.Back)
            {
                if (TreatSelectedText())
                {
                    RefreshText(AssociatedObject.SelectionStart);
                }
                else
                {
                    if (AssociatedObject.SelectionStart != 0)
                    {
                        if (Provider.RemoveAt(AssociatedObject.SelectionStart - 1))
                            RefreshText(AssociatedObject.SelectionStart - 1);
                    }
                }

                e.Handled = true;
            }

            if (e.Key == Key.Delete)
            {
                if (TreatSelectedText())
                {
                    RefreshText(AssociatedObject.SelectionStart);
                }
                else
                {

                    if (Provider.RemoveAt(AssociatedObject.SelectionStart))
                        RefreshText(AssociatedObject.SelectionStart);

                }

                e.Handled = true;
            }

        }

        private void AccociateObjectLostFocus(object sender, RoutedEventArgs e)
        {
            if (IsTime)
            {
                if(CheckTime(AssociatedObject.Text) != null)
                {
                    // Time is correct!
                }
                else
                {
                    AssociatedObject.Text = null;
                }
            }
        }

        /// <summary>
        /// 00:00形式のテキスト入力が正しくできているかどうか、違う場合はnullにする
        /// </summary>
        /// <param name="time"></param>
        /// <returns></returns>
        private string CheckTime(string time)
        {
            if (time == null) return null;

            if (string.IsNullOrWhiteSpace(time)) return null;

            string[] HHmm = time.Split(new char[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
            if (HHmm.Length != 2) return null;

            int hour;
            int minute;
            if (!int.TryParse(HHmm[0], out hour)) return null;
            if (!int.TryParse(HHmm[1], out minute)) return null;

            if (hour < 0 || hour > 24) return null;
            if (minute < 0 || minute > 59) return null;

            return time;
        }

        /// <summary>
        /// ペースト対応
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Pasting(object sender, DataObjectPastingEventArgs e)
        {
            if (e.DataObject.GetDataPresent(typeof(string)))
            {
                var pastedText = (string)e.DataObject.GetData(typeof(string));

                TreatSelectedText();

                var position = GetNextCharacterPosition(AssociatedObject.SelectionStart);

                if (Provider.InsertAt(pastedText, position))
                {
                    RefreshText(position);
                }
            }

            e.CancelCommand();
        }

        /// <summary>
        /// Textの更新
        /// </summary>
        private void UpdateText()
        {
            //同じときは更新なし
            if (Provider.ToDisplayString().Equals(AssociatedObject.Text))
                return;

            var isSet = Provider.Set(AssociatedObject.Text);

            SetText(isSet ? Provider.ToDisplayString() : AssociatedObject.Text);
        }

        /// <summary>
        /// Provider.Textの整理
        /// </summary>
        /// <returns></returns>
        private bool TreatSelectedText()
        {
            if (AssociatedObject.SelectionLength > 0)
            {
                return Provider.RemoveAt(AssociatedObject.SelectionStart,
                                        AssociatedObject.SelectionStart + AssociatedObject.SelectionLength - 1);
            }
            return false;
        }

        private void RefreshText(int position)
        {
            SetText(Provider.ToDisplayString());

            AssociatedObject.SelectionStart = position;
        }

        /// <summary>
        /// Textをセットします
        /// </summary>
        /// <param name="text"></param>
        private void SetText(string text)
        {
            AssociatedObject.Text = string.IsNullOrWhiteSpace(text) ? string.Empty : text;
        }

        private int GetNextCharacterPosition(int startPosition)
        {
            var position = Provider.FindEditPositionFrom(startPosition, true);

            if (position == -1)
                return startPosition;
            else
                return position;
        }
    }
}
```

参考)
[Blindmeis's Blog WPF – Masked Textbox Behavior](https://blindmeis.wordpress.com/2010/06/01/wpf-masked-textbox-behavior/) 　(ドイツ語のブログでした💦)
[Microsoft Document MaskedTextProvider クラス](https://docs.microsoft.com/ja-jp/dotnet/api/system.componentmodel.maskedtextprovider?view=net-5.0)