mpatric.com

A while ago I needed to allow users to enter a monetary value into a text input in an iOS app. There seem to be a few approaches to doing this, such as:

  • Use a text field with a full keyboard and validate what the user entered, or only accept numeric input and a period or comma character
  • Use a text field with a numeric keypad, but don't allow minor units (cents/pence) as there is no period or comma character available on this keypad
  • Use spinners

To me, none of these options gives a good user experience; I find the best approach is what many ATMs do, which is to allow you to enter only the digits, but have a fixed decimal separator. So, to enter '123.45' you would type 1-2-3-4-5; to enter '18.00' you would type 1-8-0-0; and so on.

In this post, I am going to do this in iOS with a text input and a numeric keypad. The Objective-C code for this is available on github.

Decimal text input

To start, I used interface builder to place a UITextField control and a UIButton control on a view, then created a UIViewController and hooked up the text field to an instance variable in the view controller. I also declared the view controller as implementing the UITextFieldDelegate protocol:

@interface IHViewController : UIViewController   

I could then link up the view controller as the text field's delegate in interface builder. The textField:shouldChangeCharactersInRange:replacementString: callback to the text field delegate gives us a useful place to manipulate the data in the text field as the user interacts with it (through typing on the keypad, deleting, cutting or pasting).

The approach I've taken is:

  • Update the string in the textfield based on the user's input
  • Strip out the decimal separator
  • Convert the string to an integer value
  • Divide the integer value by the appropriate amount depending on the number of decimal places needed (for example, by 100 for 2 decimal places)
  • Use NSString.stringWithFormat to generate a formatted number

It's actually simpler than these steps suggest. Here's the code:

- (BOOL)textField:(UITextField*)textField shouldChangeCharactersInRange:(NSRange)range  
    replacementString:(NSString*)string {  
    // Update the string in the text input  
    NSMutableString* currentString = [NSMutableString stringWithString:textField.text];  
    [currentString replaceCharactersInRange:range withString:string];  
    // Strip out the decimal separator  
    [currentString replaceOccurrencesOfString:self.decimalSeparator withString:@""  
      options:NSLiteralSearch range:NSMakeRange(0, [currentString length])];  
    // Generate a new string for the text input  
    int currentValue = [currentString intValue];  
    NSString* format = [NSString stringWithFormat:@"%%.%df", self.maximumFractionDigits];  
    double minorUnitsPerMajor = pow(10, self.maximumFractionDigits);  
    NSString* newString = [NSString stringWithFormat:format, currentValue/minorUnitsPerMajor];  
    textField.text = newString;  
    return NO;  
}  

I decided to limit the length of the value too, by adding a check around the assignment to textField.text:

#define MAX_LENGTH 8

if (newString.length <= MAX_LENGTH) {  
    textField.text = newString;  
}  

I initialise the maximumFractionDigits and decimalSeparator instance variables in the constructor using a NSNumberFormatter, which can tell you what they should be for the specified locale or currency. If the user is entering a fixed precision number and not a currency, these could be hard-coded instead.

NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init];  
[numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];  
[numberFormatter setCurrencyCode:@"GBP"];  
self.maximumFractionDigits = numberFormatter.maximumFractionDigits;  
self.decimalSeparator = numberFormatter.decimalSeparator;  
[numberFormatter release];  

And that's it. Clone the code from github to see a full, working implementation of this.

Update:

As pointed out in a comment by Trevor, the cursor does not behave as expected when it's not at the end of the text in the control (such as when the user moves the cursor to the middle of the text and types). I've updated the code on github to keep the cursor in the correct position. In the textField:shouldChangeCharactersInRange:replacementString: method, before updating the text in the control, the current cursor position and the length of the current text are stored:

    // get current cursor position  
    UITextRange* selectedRange = [textField selectedTextRange];  
    UITextPosition* start = textField.beginningOfDocument;  
    NSInteger cursorOffset = [textField offsetFromPosition:start toPosition:selectedRange.start];  
    // ..  
    NSUInteger currentLength = currentString.length;  

After updating the text in the control, the cursor position is restored:

    // if the cursor was not at the end of the string being entered, restore cursor position  
    if (cursorOffset != currentLength) {  
        int lengthDelta = newString.length - currentLength;  
        int newCursorOffset = MAX(0, MIN(newString.length, cursorOffset + lengthDelta));  
        UITextPosition* newPosition = [textField positionFromPosition:textField.beginningOfDocument offset:newCursorOffset];  
        UITextRange* newRange = [textField textRangeFromPosition:newPosition toPosition:newPosition];  
        [textField setSelectedTextRange:newRange];  
    }  

Note: The methods I've used are from the UITextInput protocol, which UITextField implements in iOS5 and up. The cursor positioning code therefore only works on iOS5 and up.

Check out the updated code in the github repo.

There is still one bit of behaviour which could be better: when the cursor is to the right of the decimal point and the user hits backspace, nothing happens. Ideally, the digit before the decimal point should be deleted. A future update will fix this.

Update 2:

As pointed out by Alexander Zubkov, the code wasn't working properly for locales with a decimal separator that is not a period. I was not taking the decimal separator into account everywhere. I've made some changes to the code to fix the issue.

Noavatar

Posted Fri 13 Jul 2012 by Michael Patricios , updated Thu 28 Nov 2013

Tags: Development, iOS, Objective-C

Comments

  • Great solution, thanks. And good call on the internationalization.

    Matthew Frederick commented Fri 20 Jul 2012
  • This works great, thanks!

    Ethan James commented Fri 08 Feb 2013
  • Unfortunately this does not work as the user expects if he positions the cursor in the middle of the text and then adds/deletes a character. In particular, the cursor jumps to the end of the field.

    Trevor commented Wed 20 Mar 2013
  • Trevor - indeed, the cursor jumping to the end when you manually place it in the middle of the text and type is one edge case which doesn't work quite right. I'll look at updating the code.

  • Trevor - I've updated the code to correct the cursor jumping to the end of the field.

  • Hi,

    Would you please be kind enough to have a look at my issue here, I believe you may be able to resolve what I am after.

    http://stackoverflow.com/questions/18264649/ios-typing-into-an-empty-uitextview-with-formatting-with-left-and-right-padding

    Basically I trying to type into an Empty UITextView with formatting With Left and Right Padding? I have worked out the formatting but I can't workout how I can use shouldChangeTextInRange to maintain the formatting and the cursor position.

    Thanking you in advance,

    Will

    Will commented Fri 16 Aug 2013
  • It won't give the desired result for users from Germany or Russia.
    I assume it's because of NSNumberFormatter that gives different decimal separator.

    When you push any digit it doesn't add it. Instead if you have any preloaded number (12.05) it will clean it up to 0.00.

    Alexander Zubkov commented Sun 03 Nov 2013
  • Alexander, you are right. Thanks for spotting that it didn't work when the decimal separator is not a period. I should have tested it for different locales. I have now fixed it so it does work (see commit in github for changes).

  • Nice, I would like ti know how will you approach the case if you want to update the String/Number that is unputed by the user for example 1000 to 1,000 adding the coma while the user is entering the number?

  • swift:

    var maximumFractionDigits: Int = 0  
    var decimalSeparator: NSString = NSString()
    
    override func viewDidLoad() {  
      var numberFormatter = NSNumberFormatter()  
      numberFormatter.numberStyle = NSNumberFormatterStyle.CurrencyStyle  
      numberFormatter.currencyCode = "USD"  
      maximumFractionDigits = numberFormatter.maximumFractionDigits  
      decimalSeparator = numberFormatter.decimalSeparator!  
    }
    
    func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool {  
      var selectedRange: UITextRange = textField.selectedTextRange!  
      var start: UITextPosition = textField.beginningOfDocument  
      var cursorOffset = textField.offsetFromPosition(start, toPosition: selectedRange.start)  
      // Update the string in the text input  
      var currentString: NSMutableString = NSMutableString(string: textField.text)  
      var currentLength: UInt = UInt(currentString.length)  
      currentString.replaceCharactersInRange(range, withString: string)  
      // Strip out the decimal seperator  
      let r = NSRange(location: 0, length: currentString.length)  
      currentString.replaceOccurrencesOfString(decimalSeparator, withString: "", options: NSStringCompareOptions.LiteralSearch, range: r)  
      // Generate a new string for the text input  
      var currentValue: Int = currentString.integerValue  
      var format: NSString = NSString(format: "%%.%df", maximumFractionDigits)  
      var minotUnitsPerMajor: Double = pow(10.00, Double(maximumFractionDigits))  
      var m = Double(currentValue) / minotUnitsPerMajor  
      var newString: NSString = NSString(format: format, m).stringByReplacingOccurrencesOfString(".", withString: decimalSeparator)  
      if newString.length <= MAX_LENGTH {  
        textField.text = newString  
        // if the cursor was not at the end of the string being entered, restore cursor position  
        if UInt(cursorOffset) != currentLength {  
          var lengthDelta: Int = UInt(newString.length) - currentLength  
          var newCursorOffset: Int = max(0, min(newString.length, cursorOffset + lengthDelta))  
          var newPosition: UITextPosition = textField.positionFromPosition(textField.beginningOfDocument, offset: newCursorOffset)!  
          var newRange = textField.textRangeFromPosition(newPosition, toPosition: newPosition)  
          textField.selectedTextRange = newRange  
        }  
      }  
    }  
    
    John commented Sun 16 Nov 2014

Post a comment