WPF – Masked Textbox Behavior


Pls use the lastest Version of my Behavior at the end of this Blog.

Auf der Suche nach einer MaskedTextbox für Wpf bin ich über die Implementierung von Marlon Grech gestolpert. In Anlehnung daran hab ich das ganze als Behavior für mein Projekt implementiert.

MasekdTextbox Behavior Sample

Die Grundlage für die MaskedTextBox ist der MaskedTextProvider, dieser übernimmt letztendlich das Auswerten der Eingaben bzgl. der InputMaske.

Was soll/muss das Behavior alles leisten:

  • InputMaske und PromptChar als Parameter
  • Eingaben, BackSpace, Delete Key etc. prüfen
  • Clipboard Pasting prüfen
  • Selektierten Text beachten
  • Ui + MVVM/CodeBehind in sync

Für die Parameter werden 2 DependencyProperties angelegt.

Folgende Events müssen behandelt werden:

Neben der Initialisierung des MasekdTextProvider in AssociatedObjectLoaded wird auch noch ein Eventhandler an das TextProperty der TextBox angehangen. Sollte das TextProperty der TextBox durch einen Converter, Binding(zb. MVVM) oder im CodeBehind geändert werden, kann man in diesem Eventhandler darauf reagieren.

Die Behandlung von Nutzereingaben sieht immer recht ähnlich aus. Zu beachten ist in jedem Fall ob der Nutzer in der TextBox Text selektiert hat und diesen dann entsprechend zu behandeln (TreatSelectedText();). Alles weitere übernimmt mehr oder weniger der MaskedTextProvider.

Was macht die UpdateText() Methode? Letzendlich wird hier nocheinmal geprüft ob der Text in dem MaskedTextProvider gleich dem Text der TextBox ist. Solange die Eingaben in die TextBox von „oben(Target)“ kommen, sollte dies auch immer der Fall sein. Sobald aber Eingaben von „unten(Source)“ kommen, ist das nicht unbedingt gewährleistet. Daher muss man sich jetzt auch entscheiden, was passieren soll wenn der Input aus der Source nicht mit der InputMaske der MaskedTextBox zusammen passt. Meine Philosophie lautet in diesem Fall: Ui + Source have to be in sync! D.h. in der Oberfläche sollen niemals Werte angezeigt werden, die nicht auch in der Quelle so sind und umgekehrt 🙂 Deshalb zeigt die UpdateText() Methode die Daten der Source an, wenn die InputMaske nicht passen sollte.

So sieht das ganze dann im XAML aus.

Und immer daran denken eine MaskedTextBox hat nichts mit VALIDIERUNG zu tun.

Download demo source code from here. NOTE: Rename the file extension from .DOC to .ZIP and then decompress it.

Hier mal mein letzter Stand zu dem Behavior:

    public class TextBoxInputMaskBehavior : Behavior<TextBox>
    {
        private WeakPropertyChangeNotifier _notifier;

        #region DependencyProperties

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

        public string InputMask
        {
            get { return (string)GetValue(InputMaskProperty); }
            set { SetValue(InputMaskProperty, value); }
        }

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

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

        public static readonly DependencyProperty ResetOnSpaceProperty =
           DependencyProperty.Register"ResetOnSpace", typeof(bool), typeof(TextBoxInputMaskBehavior), new PropertyMetadata(false));

        public bool ResetOnSpace
        {
            get { return (bool)GetValue(ResetOnSpaceProperty); }
            set { SetValue(ResetOnSpaceProperty, value); }
        }

        public static readonly DependencyProperty IgnorSpaceProperty =
          DependencyProperty.Register("IgnorSpace", typeof(bool), typeof(TextBoxInputMaskBehavior), new PropertyMetadata(true));

        public bool IgnorSpace
        {
            get { return (bool)GetValue(IgnorSpaceProperty); }
            set { SetValue(IgnorSpaceProperty, value); }
        }

        #endregion

        public MaskedTextProvider Provider { get; private set; }

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

            DataObject.AddPastingHandler(AssociatedObject, Pasting);
        }
        
        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.Loaded -= AssociatedObjectLoaded;
            AssociatedObject.PreviewTextInput -= AssociatedObjectPreviewTextInput;
            AssociatedObject.PreviewKeyDown -= AssociatedObjectPreviewKeyDown;

            DataObject.RemovePastingHandler(AssociatedObject, Pasting);
        }

        /*
        Mask Character  Accepts  Required?  
        0  Digit (0-9)  Required  
        9  Digit (0-9) or space  Optional  
        #  Digit (0-9) or space  Required  
        L  Letter (a-z, A-Z)  Required  
        ?  Letter (a-z, A-Z)  Optional  
        &amp;amp;  Any character  Required  
        C  Any character  Optional  
        A  Alphanumeric (0-9, a-z, A-Z)  Required  
        a  Alphanumeric (0-9, a-z, A-Z)  Optional  
           Space separator  Required 
        .  Decimal separator  Required  
        ,  Group (thousands) separator  Required  
        :  Time separator  Required  
        /  Date separator  Required  
        $  Currency symbol  Required  

        In addition, the following characters have special meaning:

        Mask Character  Meaning  
        <  All subsequent characters are converted to lower case  
        >  All subsequent characters are converted to upper case  
        |  Terminates a previous &amp;lt; or &amp;gt;  
        \  Escape: treat the next character in the mask as literal text rather than a mask symbol  

        */
        void AssociatedObjectLoaded(object sender, System.Windows.RoutedEventArgs e)
        {
            this.Provider = new MaskedTextProvider(InputMask, CultureInfo.CurrentCulture);
            this.Provider.PromptChar = this.PromptChar;
            this.Provider.SkipLiterals = true;
            this.Provider.ResetOnSpace = this.ResetOnSpace;
            this.Provider.Set(HandleCharacterCasing(AssociatedObject.Text));
            this.AssociatedObject.AllowDrop = false;

            this.AssociatedObject.Text = GetProviderText();
           

            //seems the only way that the text is formatted correct, when source is updated
            //AddValueChanged for TextProperty in a weak manner
            this._notifier = new WeakPropertyChangeNotifier(this.AssociatedObject, TextBox.TextProperty);
            this._notifier.ValueChanged += new EventHandler(this.UpdateText);          
        }
        void AssociatedObjectPreviewTextInput(object sender, System.Windows.Input.TextCompositionEventArgs e)
        {
#if DEBUG
            Debug("PreviewTextInput");
#endif
            e.Handled = true;
            var text = HandleCharacterCasing(e.Text);

            this.TreatSelectedText();

            var position = this.GetNextCharacterPosition(AssociatedObject.CaretIndex);

            if (Keyboard.IsKeyToggled(Key.Insert))
            {
                if(!this.Provider.Replace(text, position))
                {
                    System.Media.SystemSounds.Beep.Play();
                    return;
                }
            }
            else
            {
                if(!this.Provider.InsertAt(text, position))
                {
                    System.Media.SystemSounds.Beep.Play();
                    return;
                }
            }

            var nextposition = this.GetNextCharacterPosition(position + 1);
            this.RefreshText(nextposition);
        }

        void AssociatedObjectPreviewKeyDown(object sender, KeyEventArgs e)
        {
            //WICHTIG: TreatSelectedText oder sonst was nur in den IF's behandeln, weil KeyDown immer als erstes kommt
#if DEBUG
            Debug("PreviewKeyDown");
#endif
            if (e.Key == Key.Space)//handle the space
            {
                e.Handled = true;

                if (this.IgnorSpace)
                {
                    System.Media.SystemSounds.Beep.Play();
                    return;
                }

                this.TreatSelectedText();
                var position = this.GetNextCharacterPosition(AssociatedObject.CaretIndex);

                if (!this.Provider.InsertAt(" ", position))
                {
                    System.Media.SystemSounds.Beep.Play();
                    return;
                }

                this.RefreshText(AssociatedObject.CaretIndex + 1);
            }

            if (e.Key == Key.Back)//handle the back space
            {
                e.Handled = true;

                //wenn etwas markiert war und der nutzer Backspace klickt soll nur das markierte verschwinden
                if(this.TreatSelectedText())
                {
                    this.RefreshText(AssociatedObject.CaretIndex);
                    return;
                }
                
                //wenn man ganz vorne steht gibs nix zu löschen, ausser wenn was selektiert war, s.h.oben
                if(AssociatedObject.CaretIndex == 0)
                    return;

                var denDavor = AssociatedObject.CaretIndex - 1;

                if(this.Provider.IsEditPosition(denDavor))
                {
                    if (!this.Provider.RemoveAt(denDavor))
                    {
                        System.Media.SystemSounds.Beep.Play();
                        return;
                    }
                }

                this.RefreshText(AssociatedObject.CaretIndex - 1);
            }

            if (e.Key == Key.Delete)//handle the delete key
            {
                e.Handled = true;

                //wenn etwas markiert war und der nutzer Delete klickt soll nur das markierte verschwinden
                if (this.TreatSelectedText())
                {
                    this.RefreshText(AssociatedObject.CaretIndex);
                    return;
                }


                var position = AssociatedObject.CaretIndex;

                if (this.Provider.IsEditPosition(position))
                {
                    if (!this.Provider.RemoveAt(position))
                    {
                        System.Media.SystemSounds.Beep.Play();
                        return;
                    }
                }
                else
                {
                    System.Media.SystemSounds.Beep.Play();
                    return;
                }

                this.RefreshText(AssociatedObject.CaretIndex);
            }
        }

        /// <summary>
        /// Pasting prüft ob korrekte Daten reingepastet werden
        /// </summary>
        private void Pasting(object sender, DataObjectPastingEventArgs e)
        {
            //nur strg+c zulassen kein drag&drop
            if (e.DataObject.GetDataPresent(typeof(string)) && !e.IsDragDrop)
            {
                var pastedText = HandleCharacterCasing((string)e.DataObject.GetData(typeof(string)));

                this.TreatSelectedText();

                var position = GetNextCharacterPosition(AssociatedObject.CaretIndex);

                if (!this.Provider.InsertAt(pastedText, position))
                {
                    System.Media.SystemSounds.Beep.Play();
                }
                else
                {
                    this.RefreshText(position);
                    this.AssociatedObject.Focus();
                    
                }
            }

            e.CancelCommand();
        }

        private void UpdateText(object sender, EventArgs eventArgs)
        {
#if DEBUG
            Debug("UpdateText");
#endif
            //check Provider.Text + TextBox.Text
            if (HandleCharacterCasing(this.Provider.ToDisplayString()).Equals(HandleCharacterCasing(AssociatedObject.Text)))
                return;

            //use provider to format
            var success = this.Provider.Set(HandleCharacterCasing(AssociatedObject.Text));

            //ui and mvvm/codebehind should be in sync
            this.SetText(success ? GetProviderText() : HandleCharacterCasing(AssociatedObject.Text));
        }

        private string HandleCharacterCasing(string text)
        {
            switch (AssociatedObject.CharacterCasing)
            {
               case CharacterCasing.Lower:
                    return text.ToLower();
               case CharacterCasing.Upper:
                    return text.ToUpper();

                default:
                    return text;
            }
            
        }

        /// <;summary>
        /// Falls eine Textauswahl vorliegt wird diese entsprechend behandelt.
        /// </summary>
        private bool TreatSelectedText()
        {
            if (AssociatedObject.SelectionLength > 0)
            {
                this.Provider.RemoveAt(AssociatedObject.SelectionStart, AssociatedObject.SelectionStart + AssociatedObject.SelectionLength - 1);
                return true;
            }
            return false;
        }

        private void RefreshText(int position)
        {
            SetText(GetProviderText());

            Debug("SetText");
            AssociatedObject.CaretIndex = position;
        }

        private void SetText(string text)
        {
            AssociatedObject.Text = String.IsNullOrWhiteSpace(text) ? String.Empty : text;
        }

        private int GetNextCharacterPosition(int caretIndex)
        {
            var start = caretIndex + GetAnzahlIncludeLiterals(caretIndex);

            var position = this.Provider.FindEditPositionFrom(start, true);

            if (position == -1)
                return start;
            else
                return position;
        }

        private string GetProviderText()
        {
            //wenn noch gar kein Zeichen eingeben wurde, soll auch nix drin stehen
            //könnte man noch anpassen wenn man masken in der Oberfläche vllt doch haben will bei nem leeren feld
            return this.Provider.AssignedEditPositionCount > 0
                       ? HandleCharacterCasing(this.Provider.ToDisplayString())
                       : HandleCharacterCasing(this.Provider.ToString(false, false));
        }

        private int GetAnzahlIncludeLiterals(int index)
        {
            //todo??
            return anzLiterals;
        }

        private void Debug(string name)
        {
            System.Diagnostics.Debug.WriteLine(name + ": Textbox:  " + AssociatedObject.Text);
            System.Diagnostics.Debug.WriteLine(name + ": Provider: " + Provider.ToDisplayString());
        }
    }
Dieser Beitrag wurde unter Behavior - System.Windows.Interactivity abgelegt und mit , , , , verschlagwortet. Setze ein Lesezeichen auf den Permalink.

14 Antworten zu WPF – Masked Textbox Behavior

  1. Hugh schreibt:

    Greetings from Colorado, USA. 🙂

    This is a great solution, thank you. I have made an improvement to handle the backspace key a little nicer.

    Everywhere in your code, where you call this.GetNextCharacterPosition() I have added a new parameter to this method, the 2nd parameter is a bool for goForward. Then I modified all your calls to this method to pass true as the 2nd parameter. Then in GetNextCharacterPosition() I pass the new goForward parameter to this.Provider.FindEditPositionFrom() instead of always passing true for the 2nd parameter. So, the caller of this function can control the search direction.

    Next I modified the AssociatedObjectPreviewKeyDown() method in the specific part to change the if (e.Key == Key.Back) logic and pass false for the search direction, to search for the next position backwards. Here is the new code:


    if (e.Key == Key.Back)//handle the back space
    {
    this.TreatSelectedText();

    var position = this.GetNextCharacterPosition(AssociatedObject.SelectionStart, false);

    if (position > 0)
    {
    position = this.GetNextCharacterPosition(position-1, false);

    if (this.Provider.RemoveAt(position))
    {
    if (position > 0)
    position = this.GetNextCharacterPosition(position, false);
    }
    }

    this.RefreshText(position);

    e.Handled = true;
    }

    So now, when you backspace, you don’t go through the ignored areas, you only backspace on the actual content.

    Thanks again for this great solution.

  2. blindmeis schreibt:

    Thanks Hugh.

  3. Phil Sandler schreibt:

    Hugh’s code has a small problem when I tested it–if you are on the last character and hit backspace, it deletes the second to last character. Not a huge deal but worth noting.

  4. Hugh schreibt:

    Hi Phil, I noticed that too, and fixed it in a subsequent version. Below is my final solution. I ended up taking all the code from the Behavior, and put it into a Custom Control named MaskedTextBox. With this implementation you do not need a reference to System.Windows.Interactivity.

    A huge benefit of this method is that I’ve created a property for UnmaskedText so you can get to the text without the Prompt characters. I databind to/from the UnmaskedText property.


    using System;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Input;
    using System.ComponentModel;
    using System.Globalization;

    namespace Presentation.Controls
    {
    public class MaskedTextBox : TextBox
    {
    #region DependencyProperties

    public string UnmaskedText
    {
    get { return (string)GetValue(UnmaskedTextProperty); }
    set{ SetValue(UnmaskedTextProperty, value);
    }
    }

    public static readonly DependencyProperty UnmaskedTextProperty =
    DependencyProperty.Register("UnmaskedText", typeof(string),
    typeof(MaskedTextBox), new UIPropertyMetadata(""));

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

    public string InputMask
    {
    get { return (string)GetValue(InputMaskProperty); }
    set { SetValue(InputMaskProperty, value); }
    }

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

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

    #endregion

    private MaskedTextProvider Provider;

    public MaskedTextBox()
    {
    Loaded += new RoutedEventHandler(MaskedTextBox_Loaded);
    PreviewTextInput += new TextCompositionEventHandler(MaskedTextBox_PreviewTextInput);
    PreviewKeyDown += new KeyEventHandler(MaskedTextBox_PreviewKeyDown);

    }

    void MaskedTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
    {
    if (e.Key == Key.Space)
    {
    this.TreatSelectedText();

    var position = this.GetNextCharacterPosition(SelectionStart, true);

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

    e.Handled = true;
    }

    if (e.Key == Key.Back)
    {
    this.TreatSelectedText();

    e.Handled = true;

    if (SelectionStart 0)
    {
    if (this.Provider.RemoveAt(position))
    {
    position = this.GetNextCharacterPosition(position, false);
    }
    }

    this.RefreshText(position);

    e.Handled = true;
    }

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

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

    }

    e.Handled = true;
    }
    }

    void MaskedTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
    {
    this.TreatSelectedText();

    var position = this.GetNextCharacterPosition(SelectionStart, true);

    if (Keyboard.IsKeyToggled(Key.Insert))
    {
    if (this.Provider.Replace(e.Text, position))
    position++;
    }
    else
    {
    if (this.Provider.InsertAt(e.Text, position))
    position++;
    }

    position = this.GetNextCharacterPosition(position, true);

    this.RefreshText(position);

    e.Handled = true;
    }

    void MaskedTextBox_Loaded(object sender, RoutedEventArgs e)
    {
    this.Provider = new MaskedTextProvider(InputMask, CultureInfo.CurrentCulture);

    if (String.IsNullOrWhiteSpace(UnmaskedText))
    this.Provider.Set(String.Empty);
    else
    this.Provider.Set(UnmaskedText);

    this.Provider.PromptChar = PromptChar;
    Text = this.Provider.ToDisplayString();

    var textProp = DependencyPropertyDescriptor.FromProperty(MaskedTextBox.TextProperty, typeof(MaskedTextBox));
    if (textProp != null)
    {
    textProp.AddValueChanged(this, (s, args) => this.UpdateText());
    }
    DataObject.AddPastingHandler(this, Pasting);
    }

    private void Pasting(object sender, DataObjectPastingEventArgs e)
    {
    if (e.DataObject.GetDataPresent(typeof(string)))
    {
    var pastedText = (string)e.DataObject.GetData(typeof(string));

    this.TreatSelectedText();

    var position = GetNextCharacterPosition(SelectionStart, true);

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

    e.CancelCommand();
    }

    private void UpdateText()
    {
    if (this.Provider.ToDisplayString().Equals(Text))
    return;

    var success = this.Provider.Set(Text);

    this.SetText(success ? this.Provider.ToDisplayString() : Text, this.Provider.ToString(false, false));
    }

    private bool TreatSelectedText()
    {
    if (SelectionLength > 0)
    {
    return this.Provider.RemoveAt(SelectionStart,
    SelectionStart + SelectionLength - 1);
    }
    return false;
    }

    private void RefreshText(int position)
    {
    SetText(this.Provider.ToDisplayString(), this.Provider.ToString(false, false));
    SelectionStart = position;
    }

    private void SetText(string text, string unmaskedText)
    {
    UnmaskedText = String.IsNullOrWhiteSpace(unmaskedText) ? null : unmaskedText;
    Text = String.IsNullOrWhiteSpace(text) ? null : text;
    }

    private int GetNextCharacterPosition(int startPosition, bool goForward)
    {
    var position = this.Provider.FindEditPositionFrom(startPosition, goForward);

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

    And here is how I use it in XAML:

  5. Hugh schreibt:

    (My previous comment got cut off)

    Here is how I use the control in XAML:


    <ctrl:MaskedTextBox x:Name="PrimaryPhoneInput" Width="100" InputMask="(000) 000-0000" PromptChar="_"
    UnmaskedText="{Binding PrimaryPhone, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnDataErrors=True, ValidatesOnExceptions=True}" />

  6. blindmeis schreibt:

    @Hugh
    i think using a behavior or creating a custom control its a kind of personally preference and i like behaviors more, because you are a way more independent.
    btw, i handle the umasked text property with a converter 🙂

  7. Christoph Dreßler schreibt:

    Hallo blindmeis,

    eine gute Lösung, vielen Dank!

    Ein Problem habe ich: Ich habe die MaskedTextBox an einer Liste von UI-Objekten hängen. Für das erste Element der Liste ist die Textbox leer, obwohl das gebundene Property gefüllt ist. Die Folgeelemente der Collection werden richtig angezeigt.
    Es scheint hier also irgend ein Init-Problem zu geben.
    Hättest du eine Idee?

    -christoph

  8. Christoph Dreßler schreibt:

    Habs gefunden:

    in
    MaskedTextBox_Loaded
    stört diese Zeile:
    Text = this.Provider.ToDisplayString();

    -christoph

  9. RivkA schreibt:

    BlindMeis – Thank you very much for great code. It is a great help!

  10. Xarkam (@Xarkam) schreibt:

    Great code, but you cannot use „IsReadOnly“ on masked textbox 😦

  11. ragra2 schreibt:

    Great solution, thank you. How it is possible to fix the bug in the first Key.Back variant from Hugh (the behavior, not the usercontrol)

  12. Christian schreibt:

    Hiho. Schönes Beispiel (I’m going to continue in English, so that maybe others can use my information).
    I found a memory-leak:

    //seems the only way that the text is formatted correct, when source is updated
    var textProp = DependencyPropertyDescriptor.FromProperty(TextBox.TextProperty, typeof(TextBox));
    if (textProp != null)
    {
    text

    This reference is never dropped and therefore whatever you attach the behavior to is piling up and staying in memory.

    I solved this by also hooking up AssociatedObject.Unloaded += AssociatedObjectUnloaded; in OnAttached() and retaining references to the DependencyPropertyDescriptor and the used EventHandler. You can then call

    .RemoveValueChanged(AssociatedObject,); during AssociatedObjectUnloaded finally, everything gets garbage-collected as it should.

    Hope this is useful.

    Greetings, Christian.

  13. blindmeis schreibt:

    hi Christain, check out my last version of the behavior on the end of the blog entry. i changed the subscribing to a weak subscription – so no more memoryleak 🙂

Hinterlasse einen Kommentar