Avoid control inheritance.
I knew this rule. I’ve read it several times from many different sources. I’ve even given this advice to others. However, I’m also guilty of forgetting this rule.
Recently, I had the need for a MaskedTextBox in WPF. Marlon Grech, a fellow WPF Disciple, has a great article about how he created such a control for his AvalonControlsLibrary. Having read that post, my first inclination was to create my own MaskedTextBox (not a matter of NIH syndrome, but the lesser evil of simply not wanting to deal with the legal paperwork necessary to use Marlon’s code at work). I spent a few days perfecting this control (there’s a lot of corner cases you have to deal with, and I wanted full MaskedTextBox functionality, which makes it even more difficult). Once I was done, a coworker pointed out to me that we didn’t pick up the Aero styling that our application uses. It’s a simple fix at an application level, but not so easy to fix at a library level. Not a huge deal, but this got me to thinking about how to address the problem. Then it dawned on me… I shouldn’t have created a MaskedTextBox at all!
You see, if I’d followed the "first rule of WPF", I wouldn’t have had the styling issue.
So, what’s the better solution here, then? Well, you simply use attached properties and behaviors instead! Here’s a naive example of this concept. Please beware that this is NOT a complete solution, and won’t begin to work for you as is. It only is here for illustrative purposes. I can’t publish a complete solution since I developed this during work hours. However, with this illustration, it shouldn’t be too difficult to develop a complete solution. Anyway, on to the code.
public class MaskedText : IDisposable { private readonly TextBox _target; private MaskedTextProvider _provider; private MaskedText(TextBox target) { _target = target; _target.PreviewTextInput += OnPreviewTextInput; CreateProvider(); } public void Dispose() { _target.PreviewTextInput -= OnPreviewTextInput; } private void OnPreviewTextInput(object sender, System.Windows.Input.TextCompositionEventArgs e) { e.Handled = true; ReplaceText(e.Text); } private void CreateProvider() { string text = _target.Text; if (_provider != null) { text = _provider.ToString(false, false); } _provider = new MaskedTextProvider( GetMask(_target), CultureInfo.CurrentCulture, true, // allow prompt as input GetPromptChar(_target), '', false); // Ascii only int testPosition; MaskedTextResultHint hint; _provider.Replace(text, 0, _provider.Length, out testPosition, out hint); SetText(); } private void SetText() { int start = _target.SelectionStart; _target.Text = GetFormattedDisplayString(); _target.SelectionStart = start; _target.SelectionLength = 0; } private string GetFormattedDisplayString() { bool flag; if (_target.IsReadOnly) { flag = false; } else if (DesignerProperties.GetIsInDesignMode(_target)) { flag = true; } else { flag = !GetHidePromptOnLeave(_target) || _target.IsFocused; } return _provider.ToString(false, flag, true, 0, _provider.Length); } private void ReplaceText(string text) { int start = _target.SelectionStart; int end = start + _target.SelectionLength - 1; int testPosition; MaskedTextResultHint hint; if (end >= start) { _provider.Replace(text, start, end, out testPosition, out hint); } else { _provider.InsertAt(text, start, out testPosition, out hint); } if (hint == MaskedTextResultHint.Success || hint == MaskedTextResultHint.CharacterEscaped || hint == MaskedTextResultHint.NoEffect || hint == MaskedTextResultHint.SideEffect) { SetText(); _target.SelectionStart = testPosition + text.Length; _target.SelectionLength = 0; } else { //RaiseMaskInputRejectedEvent(hint, start); } } #region Instance private static readonly DependencyProperty InstanceProperty = DependencyProperty.RegisterAttached("Instance", typeof(MaskedText), typeof(MaskedText), new FrameworkPropertyMetadata((MaskedText)null)); private static MaskedText GetInstance(DependencyObject d) { return (MaskedText)d.GetValue(InstanceProperty); } private static void SetInstance(DependencyObject d, MaskedText value) { d.SetValue(InstanceProperty, value); } #endregion #region Mask public static readonly DependencyProperty MaskProperty = DependencyProperty.RegisterAttached( "Mask", typeof(string), typeof(MaskedText), new FrameworkPropertyMetadata((string)null, new PropertyChangedCallback(OnMaskChanged), new CoerceValueCallback(CoerceMaskValue)), new ValidateValueCallback(IsMaskValid)); public static string GetMask(DependencyObject d) { return (string)d.GetValue(MaskProperty); } public static void SetMask(DependencyObject d, string value) { d.SetValue(MaskProperty, value); } private static void OnMaskChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (e.NewValue == null) { Unattach(d); } else { Attach(d); } } private static object CoerceMaskValue(DependencyObject d, object value) { string mask = (string)value; if (string.IsNullOrEmpty(mask)) { return null; } return value; } private static bool IsMaskValid(object value) { string mask = (string)value; if (string.IsNullOrEmpty(mask)) { return true; } foreach (char ch in mask) { if (!MaskedTextProvider.IsValidMaskChar(ch)) { return false; } } return true; } #endregion #region PromptChar public static readonly DependencyProperty PromptCharProperty = DependencyProperty.RegisterAttached( "PromptChar", typeof(char), typeof(MaskedText), new FrameworkPropertyMetadata('_', FrameworkPropertyMetadataOptions.None, new PropertyChangedCallback(OnPromptCharChanged)), new ValidateValueCallback(IsPromptCharValid)); public static char GetPromptChar(DependencyObject d) { return (char)d.GetValue(PromptCharProperty); } public static void SetPromptChar(DependencyObject d, char value) { d.SetValue(PromptCharProperty, value); } private static void OnPromptCharChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { MaskedText maskedText = GetInstance(d); if (maskedText != null) { maskedText._provider.PromptChar = (char)e.NewValue; maskedText.SetText(); } } private static bool IsPromptCharValid(object value) { char ch = (char)value; return MaskedTextProvider.IsValidPasswordChar(ch); } #endregion #region IncludePrompt public static readonly DependencyProperty IncludePromptProperty = DependencyProperty.RegisterAttached( "IncludePrompt", typeof(bool), typeof(MaskedText), new FrameworkPropertyMetadata(false, new PropertyChangedCallback(OnIncludePromptChanged))); public static bool GetIncludePrompt(DependencyObject d) { return (bool)d.GetValue(IncludePromptProperty); } public static void SetIncludePrompt(DependencyObject d, bool value) { d.SetValue(IncludePromptProperty, value); } private static void OnIncludePromptChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { MaskedText maskedText = GetInstance(d); if (maskedText != null) { maskedText.CreateProvider(); } } #endregion #region HidePromptOnLeave public static readonly DependencyProperty HidePromptOnLeaveProperty = DependencyProperty.RegisterAttached( "HidePromptOnLeave", typeof(bool), typeof(MaskedText), new FrameworkPropertyMetadata(false, new PropertyChangedCallback(OnHidePromptOnLeaveChanged))); public static bool GetHidePromptOnLeave(DependencyObject d) { return (bool)d.GetValue(HidePromptOnLeaveProperty); } public static void SetHidePromptOnLeave(DependencyObject d, bool value) { d.SetValue(HidePromptOnLeaveProperty, value); } private static void OnHidePromptOnLeaveChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { MaskedText maskedText = GetInstance(d); if (maskedText != null) { maskedText.SetText(); } } #endregion private static void Attach(DependencyObject d) { TextBox textBox = d as TextBox; if (textBox != null) { MaskedText maskedText = GetInstance(d); if (maskedText == null) { maskedText = new MaskedText(textBox); SetInstance(d, maskedText); } else { maskedText.CreateProvider(); } } } private static void Unattach(DependencyObject d) { MaskedText maskedText = GetInstance(d); if (maskedText != null) { maskedText.Dispose(); SetInstance(d, null); } } }
Usage is quite simple.
<TextBox Margin="2" local:MaskedText.Mask="00/00/0000" local:MaskedText.PromptChar="?"/>
We’re no longer using a MaskedTextBox, but instead we use attached properties and behaviors to make existing TextBox controls behave appropriately.
With Styles, Templates, Decorators (check out the SpinDecorator by MindScape, for example) and attached properties and behaviors you can accomplish most of the things that you used to have to roll a new control for. In the end, what you get is usually a more reusable piece of functionality than what you could get through inheritance. For instance, the MaskedText class could be modified to support more than just a TextBox, giving you the same behavior for, say, a ComboBox, while inheritance limits how you can reuse the functionality.
WPF sure is powerful.