I'm exploring the phone more and more and came across the case of allowing the user to enter digits. One thing to keep in mind on the phone is that the form factor requires approaches to input that are different from the traditional desktop. A desktop application might handle numeric entry by simply filtering the text box, but that assumes a keyboard-centric model. On the phone, touch is key.
Of course, at the most basic level we could simply define an input scope on the phone and the SIP would give the user a number-friendly keyboard:
<TextBox Text="{Binding Weight,Mode=TwoWay,Converter={StaticResource DoubleConverter}}" Grid.Row="1"> <TextBox.InputScope> <InputScope> <InputScopeName NameValue="Digits"/> </InputScope> </TextBox.InputScope> </TextBox>
With those hints in place, you will get a keyboard like this:
It still isn't easy to hit the numbers and you will still have to validate the field. You could also use a slider like the infamous date control (I'll let you explore that one for yourself) but it's not the ideal experience. In fact, I thought about my favorite experience entering numbers on the phone and it had to be this:
Yes, it's the calculator. It's clean, easy, and famliar. So, I decided to build a control to allow a calculator-like experience for entering numbers.
Sample Data for the Control
I decided to go with a view model based control only because the application I'm working on uses both the Windows Phone 7 database called Sterling and my miniature MVVM framework called UltraLight.mvvm. This could easily use code-behind and dependency properties to be more "independent" but this approach is fine for me to build into my applications.
The interfaces for mocking and developing against are simple: a number that displays on the screen and a set of commands to enter digits, save, or cancel.
public interface INumberPadViewModelBase { string Number { get; } } public interface INumberPadViewModel : INumberPadViewModelBase, IViewModel { void SetValue(double value, string callbackTag); IActionCommand<object> DigitCommand { get; } IActionCommand<object> AcceptCommand { get; } IActionCommand<object> CancelCommand { get; } }
My design-time view model just provides a little piece of pi:
public class DesignNumberPadViewModel : INumberPadViewModelBase { public string Number { get { return "3.1415"; } } }
The View
This gives me enough to design the view. For this, I went with a fluid layout and mirrored the positions of the numbers on the calculator. Eventually I should probably replace the "delete" key with the backspace symbol, but for now the following layout gave me the digits and number display along with confirm and cancel options:
<Grid x:Name="LayoutRoot" d:DataContext="{d:DesignInstance design:DesignNumberPadViewModel, IsDesignTimeCreatable=True}" Background="Transparent"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock Text="{Binding Number}" HorizontalAlignment="Right" FontSize="72" Margin="5"/> <Grid Grid.Row="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> <Grid.RowDefinitions> <RowDefinition Height="1*"/> <RowDefinition Height="1*"/> <RowDefinition Height="1*"/> <RowDefinition Height="1*"/> <RowDefinition Height="1*"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="1*"/> <ColumnDefinition Width="1*"/> <ColumnDefinition Width="1*"/> </Grid.ColumnDefinitions> <Button Grid.Row="0" Grid.Column="0" MVVM:ButtonCommand.Command="{Binding DigitCommand}" MVVM:ButtonCommand.CommandParameter="7"> <Button.Content> <TextBlock Text="7" FontSize="56"/> </Button.Content> </Button> // lots of buttons omitted here <Button Grid.Row="3" Grid.Column="0" MVVM:ButtonCommand.Command="{Binding DigitCommand}" MVVM:ButtonCommand.CommandParameter="0"> <Button.Content> <TextBlock Text="0" FontSize="56"/> </Button.Content> </Button> <Button Grid.Row="3" Grid.Column="1" MVVM:ButtonCommand.Command="{Binding DigitCommand}" MVVM:ButtonCommand.CommandParameter="del"> <Button.Content> <TextBlock Text="DEL" FontSize="56"/> </Button.Content> </Button> <Button Grid.Row="3" Grid.Column="2" MVVM:ButtonCommand.Command="{Binding DigitCommand}" MVVM:ButtonCommand.CommandParameter="dot"> <Button.Content> <TextBlock Text="." FontSize="56"/> </Button.Content> </Button> <Button Grid.Row="4" Grid.ColumnSpan="3" MVVM:ButtonCommand.Command="{Binding DigitCommand}" MVVM:ButtonCommand.CommandParameter="clear"> <Button.Content> <TextBlock VerticalAlignment="Center" Text="CLEAR" FontSize="56"/> </Button.Content> </Button> </Grid> </Grid>
As you can probably tell from looking at this, we have our "upper display" and the rest is simply a bunch of buttons bound to the same command. The digits pass the digit pressed, while the delete, clear, and decimals pass special text snippets that will be processed by the view model. In the designer, the number pad looks like this:
The View Model
Now it's time to build the view model. I wanted to make it tombstone-friendly and because I'm using the MVVM framework, I decided to use messaging to transmit the number when the user confirms. First, I created a message to send:
public interface INumberPadResult { string CallbackTag { get; } double Result { get; } }
The result is self-explanatory. What about the callback tag? This is where it gets interesting. I'll be using a control to handle the numeric input. It will essentially pop out to the number view and then return back. This presents an interesting challenge with tombstoning because the control to return to isn't created yet (when the tombstone returns to the number pad, as I did not implement it as a pop-up) so there is nothing to listen for the value. Instead, the view model will have to pick it up.
Therefore, I decided to use the built-in Tag
property to give the text box a unique name, and let the number pad share the tag value when transmitting the result. You'll see in a minute how that ties together.
The basic view model implements a few interfaces and defines constants for the "commands" such as delete and clear. I also decided to keep the digits as a simple array so I use a static array as the "initial value".
public class NumberPadViewModel : BaseViewModel, INumberPadViewModel, INumberPadResult, ITombstoneFriendly { private const string DOT = "dot"; private const string CLEAR = "clear"; private const string DELETE = "del"; private bool _navigating; private int _pos; private string[] _number = new string[10]; private static readonly string[] _init = new[] { "0", string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty }; public NumberPadViewModel() { DigitCommand = new ActionCommand<object>(o => _ProcessDigit((string) o), o => _CanProcess((string) o)); AcceptCommand = new ActionCommand<object>(o => _ProcessResult()); CancelCommand = new ActionCommand<object>(o => _Cancel()); _init.CopyTo(_number, 0); } }
You can see some commands are wired up and the array for the numbers is initialized. Here are the properties on the view model:
public override IEnumerable<IActionCommand<object>> ApplicationBindings { get { return new[] {AcceptCommand, CancelCommand}; } } public bool HasDecimal { get { return (from n in _number where n == "." select 1).Any(); } } public string CallbackTag { get; set; } public string Number { get { return string.Join(string.Empty, _number); } } public double Result { get; set; } public IActionCommand<object> DigitCommand { get; private set; } public IActionCommand<object> AcceptCommand { get; private set; } public IActionCommand<object> CancelCommand { get; private set; }
To make it easy to set up the number pad, I created a method (you saw that in the interface defined earlier) to load it with the initial information:
public void SetValue(double value, string callbackTag) { CallbackTag = callbackTag; // remove any prior saved values PhitDatabaseService.Database.Delete(typeof(ValueModel), GetType().FullName); _init.CopyTo(_number, 0); // parse digits if (value != 0) { var digits = value.ToString(); if (digits.Length > 10) { digits = digits.Substring(0, 10); } for (var x = 0; x < digits.Length; x++) { _number[x] = digits.Substring(x, 1); } _pos = digits.Length; } else { _pos = 0; } RaisePropertyChanged(()=>Number); DigitCommand.RaiseCanExecuteChanged(); _navigating = true; }
The processing commands handle disabling the delete key when there is nothing to delete and the decimal key when one has already been entered, and here I picked an arbitrary length of 10 ... obviously a more reusable control will have that exposed as a setting:
private bool _CanProcess(IEquatable<string> s) { if (s.Equals(DOT)) { return !HasDecimal; } if (s.Equals(DELETE)) { return _pos > 0; } if (s.Equals(CLEAR)) { return true; } return _pos < 10; } private void _ProcessDigit(string s) { if (!_CanProcess(s)) return; if (s.Equals(DOT)) { _number[_pos++] = "."; } else if (s.Equals(DELETE)) { _pos--; _number[_pos] = _pos == 0 ? "0" : string.Empty; } else if (s.Equals(CLEAR)) { _init.CopyTo(_number, 0); _pos = 0; } else { int digit; if (int.TryParse(s, out digit)) { _number[_pos++] = s; } } RaisePropertyChanged(() => Number); DigitCommand.RaiseCanExecuteChanged(); }
The processing just loads the digits into the array and stops allowing the decimal once it is entered.
Here are the save and cancel commands. The control implements the message payload interface so it is able to pass itself. Both actions will clear the call back tag, delete the tombstone entry and go back to the screen that it was called from:
private void _ProcessResult() { Result = double.Parse(string.Join(string.Empty, _number)); UltraLightLocator.EventAggregator.Publish<INumberPadResult>(this); _Cancel(); } private void _Cancel() { CallbackTag = string.Empty; PhitDatabaseService.Database.Delete(typeof(ValueModel), GetType().FullName); GoBack(); }
The last piece is to handle tombstoning. I have a value model class that is simply a string-based key with an object value that allows me to save just about anything. In this case I create an array of the position, the digits, and the call back tag, and use that to save and load values. Sterling will simply parse whatever is provided in the object parameter. Notice if I am navigating in from another screen (that field is set in the SetValue
method) I won't clear the results but just pass through.
public void Deactivate() { var value = new ValueModel { Key = GetType().FullName, Value = new object[] { _pos, _number, CallbackTag } }; PhitDatabaseService.Database.Save(value); } public void Activate() { var value = PhitDatabaseService.Database.Load<ValueModel>(GetType().FullName) ?? new ValueModel(); if (value.Value is object[]) { var tuple = value.Value as object[]; _pos = (int)tuple[0]; _number = (string[]) tuple[1]; CallbackTag = (string) tuple[2]; } else { if (_navigating) { _navigating = false; } else { _pos = 0; _init.CopyTo(_number, 0); } } DigitCommand.RaiseCanExecuteChanged(); RaisePropertyChanged(()=>Number); }
That's it for the number pad.
The Numeric Textbox
We need a way to get to the number pad. The experience I think makes sense is to show the field on the screen as an editable field. However, when the user touches the field to begin editing, they are taken to the number pad. There, they can clear, edit, cancel, or confirm the value. To handle this I created the NumericTextBox
control to work in conjunction with the number pad. When it is loaded, it walks up the tree to the parent page to get to the navigation context so it can call out to the number pad view when needed.
public class NumericTextBox : TextBox { private NavigationService _navigation; public NumericTextBox() { MouseLeftButtonUp += _NumericTextBox; LostFocus += (o, e) => IsEnabled = true; GotFocus += (o, e) => IsEnabled = false; Loaded += _NumericTextBoxLoaded; } private void _NumericTextBoxLoaded(object sender, System.Windows.RoutedEventArgs e) { Loaded -= _NumericTextBoxLoaded; var parent = VisualTreeHelper.GetParent(this); while (parent != null) { if (parent is PhoneApplicationPage) { _navigation = ((PhoneApplicationPage) parent).NavigationService; break; } parent = VisualTreeHelper.GetParent(parent); } } private void _NumericTextBox(object sender, System.Windows.RoutedEventArgs e) { IsEnabled = false; var value = GetValue(TextProperty) as string; double valueParsed; if (!double.TryParse(value, out valueParsed)) { valueParsed = 0; } UltraLightLocator.GetViewModel<INumberPadViewModel>().SetValue(valueParsed, Tag.ToString()); _navigation.Navigate(new Uri("/Controls/NumberPad.xaml", UriKind.Relative)); } }
The first thing it does is stay enabled until focused, then disables so the user doesn't accidentally enter text using the SIP. Next, when the mouse left button event is fired (which is a simple tap on the phone) it copies the value in the text property to the view model, sets the tag, and navigates to the number pad.
Remember, we need the tag to listen for the right value when the user confirms, so I set up the weight text box like this:
<Controls:NumericTextBox Tag="WeightMessage" Text="{Binding Weight,Mode=TwoWay,Converter={StaticResource DoubleConverter}}" Grid.Row="1"/>
Notice that it is bound without any regard to the number pad - there is no knowledge in the view other than the control reference. It is bound to an actual property on the view model and the double converter simply converts from text to double and back. The last step is for the view model to listen for the tag. In this case, I added the tag directly. If you think this is too much of a "magic string" you can bind the tag to the view model as well, and use that when referencing the message.
The main view model implements IEventSink<INumberPadResult>
to receive the message. The method looks like this:
public void HandleEvent(INumberPadResult publishedEvent) { if (publishedEvent.CallbackTag.Equals("WeightMessage")) { Weight = publishedEvent.Result; return; } if (publishedEvent.CallbackTag.Equals("HeightMessage")) { Height = publishedEvent.Result; return; } }
Again, here is where you could have a local value instead that is databound to avoid the magic strings. Now the control is complete. The screen renders like this:
When you tap on one of the fields for entry, the number pad is popped up like this, making it very easy to enter your numeric value:
Of course, I could shade it like the calculator, add some more filters, etc. but for now it's a working control that is also 100% tombstone-friendly.