iOS Money TextField

Most financial apps at some point need to have a user input a monetary amount. A very common way this is handled is a widget that accepts only numeric input. As the user types, it adds the given digit to the cents field. For example, the user types 1 and it displays $0.01. Then they press 2, and the field changes to $0.12. Each number is appended to the string. Basically, this is implied decimal with a very nice presentation.

Some apps will create a custom widget with its own look and feel but which still fits with the overall app for this type of input. However, this isn’t always practical and doesn’t fit with apps that don’t try to have a heavily customized look and feel. For something that uses more of the default widget look and feel, it’s possible to more or less subclass UITextField and have it behave like a monetary input field.

MoneyField.h

#ifndef MoneyField_h
#define MoneyField_h

#import <UIKit/UIKit.h>

@interface MoneyField : UITextField

- (id)init;
- (id)initWithCoder:(NSCoder *)aDecoder;
- (id)initWithFrame:(CGRect)frame;

@end

#endif /* MoneyField_h */

Publicly this is just a subclass and can be still be used in Xcode. There is no need to programmatically add or create this widget. Just add a UITextField to a storyboard and associate it with this class.

These init functions are overrides and reimplemented because the widget needs to be set up properly to handle monetary input. These (most, not all) are required when using Xcode and are also helpful for programmatic creation.

MoneyField.m

/* This can only use up to 9 digits (N 7.2) because the money formatter needs
 * to convert to a double, limiting the range.
 *
 * Will stop adding digits once the maximum has been reached. Programmatically
 * setting more will result in truncation.
 *
 * When setting programmatically, non-numerics will be stripped and numerics will be used.
 */

#import <Foundation/Foundation.h>
#import "MoneyField.h"

/* Takes a string and turns it into X.XX with numerics.
 * Will strip non-numerics.
 */
static NSString *formatMoney(NSString *money) {
    NSString *numstr = [[money componentsSeparatedByCharactersInSet:[[NSCharacterSet decimalDigitCharacterSet] invertedSet]] componentsJoinedByString:@""];

    NSMutableString *mystr = [NSMutableString stringWithString:numstr];
    if (mystr.length > 9)
        [mystr deleteCharactersInRange:NSMakeRange(9, mystr.length-9)];

    int64_t val = [mystr intValue];
    return [NSString stringWithFormat:@"%lld.%02lld", val/100, val%100];
}

/* Delegate to handle internal delegate events but allow forwarding
 * to an outside delegate for events we don't care about. */
@interface MoneyFieldDelegate : NSObject <UITextFieldDelegate>

@property (weak, nonatomic) id<UITextFieldDelegate> delegate;

@end

@implementation MoneyFieldDelegate

/* Check our and the delegates' selectors and determine if either implements them. */
- (BOOL)respondsToSelector:(SEL)aSelector {
    if ([super respondsToSelector:aSelector])
        return YES;
    return [_delegate respondsToSelector:aSelector];
}

/* We didn't capture the selector so forward it along to the outside delegate. */
- (id)forwardingTargetForSelector:(SEL)aSelector {
    return _delegate;
}

/* This is called when text in the field is modified. */
- (BOOL)textField:(UITextField*)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString*)string {
    NSMutableString *mystr = [NSMutableString stringWithString:textField.text];

    /* backspace if no text added, or add the text and set it. */
    if (string.length == 0) {
        [mystr deleteCharactersInRange:NSMakeRange(mystr.length-1, 1)];
    } else {
        [mystr appendString:string];
    }

    [textField setText:mystr];
    return NO;
}

@end

@implementation MoneyField {
    /* Formatter makes the number look pretty by doing things
     * like putting $ in front. */
    NSNumberFormatter *_formatter;
    MoneyFieldDelegate *_money_delegate;
}

/* Inits for various situations. Internal one that always
 * gets called to setup the subclass variables. */
- (void)int_init {
    _formatter = [[NSNumberFormatter alloc] init];
    _formatter.locale = [NSLocale currentLocale];
    _formatter.numberStyle = NSNumberFormatterCurrencyStyle;
    _formatter.currencyCode = @"USD";
    _formatter.usesGroupingSeparator = YES;
    _formatter.maximumFractionDigits = 2;
    _formatter.minimumFractionDigits = 2;
    _formatter.maximumIntegerDigits = 7;

    _money_delegate = [[MoneyFieldDelegate alloc] init];
    [super setDelegate:_money_delegate];

    [self setKeyboardType:UIKeyboardTypeNumberPad];
    [self setSpellCheckingType:UITextSpellCheckingTypeNo];
    [self setAutocapitalizationType:UITextAutocapitalizationTypeNone];
    [self setAutocorrectionType:UITextAutocorrectionTypeNo];

    self.textAlignment = NSTextAlignmentRight;

    [self setText:@"0"];
}

- (id)init {
    self = [super init];
    if (!self)
        return nil;
    [self int_init];
    return self;
}

- (id)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (!self)
        return nil;
    [self int_init];
    return self;
}

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (!self)
        return nil;
    [self int_init];
    return self;
}

#if 0
/* Don't show the caret. */
- (CGRect)caretRectForPosition:(UITextPosition *)position {
    return CGRectZero;
}
#endif

/* Don't allow text selection. */
- (NSArray *)selectionRectsForRange:(UITextRange *)range {
    return nil;
}

/* Don't allow selection menu actions. */
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if (action == @selector(cut:) || action == @selector(copy) || action == @selector(paste:) || action == @selector(select:) || action == @selector(selectAll:))
        return NO;
    return [super canPerformAction:action withSender:sender];
}

/* Store the delegate inside the internal delegate. */
- (void) setDelegate:(id<UITextFieldDelegate>)delegate {
    _money_delegate.delegate = delegate;
}

- (id<UITextFieldDelegate>)delegate {
    return _money_delegate.delegate;
}

/* Take any text input and turn it into a properly formatted money string. */
- (void)setText:(NSString *)text {
    [super setText:[_formatter stringFromNumber:[NSNumber numberWithDouble:[formatMoney(text) doubleValue]]]];
}

/* Take the input and return it as X.XX. This will strip things like $ at the front. */
- (NSString *)text {
    return formatMoney([super text]);
}

@end

As the comment states this field is limited to 9 digits total with 2 being cents. Making $9,999,999.99 is the maximum amount the field can hold but this shouldn’t be a problem for most applications. Needing $10 million or more probably needs to be handled a bit differently to begin with.

There are three parts to this file.

  1. The static C style (not really style it is a C function) function which is used in multiple places for formatting. This is static so it can be used in multiple classes.

  2. MoneyFieldDelegate which is a delegate that will be applied to the MoneyField widget.

  3. MoneyField which is the actuals subclass of UITextField.

static NSString *formatMoney(NSString *money) {

This takes a string, strips all nonnumeric characters, truncates to 9 digits, then returns a formatted string with a decimal. This just gets a string into a workable format for proper currency formatting later.

@interface MoneyFieldDelegate : NSObject <UITextFieldDelegate>

The MoneyField needs a custom delegate attached to it for handling text changes. We still want to allow attaching a delegate to the MoneyField class so this delegate will hold the real delegate and implement respondsToSelector and forwardingTargetForSelector to pass any calls it doesn’t implement to the real delegate.

- (BOOL)textField:(UITextField*)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString*)string {

Whenever text from the keyboard is added to the field, this delegate function is called and can change how the text is added. It gives some info about the change such as the range where the change takes place the new text. That said, this is an append-only widget so the range doesn’t matter and the new text will only ever be a single character. The caret will move to the end when this is called.

    if (string.length == 0) {
        [mystr deleteCharactersInRange:NSMakeRange(mystr.length-1, 1)];
    } else {
        [mystr appendString:string];
    }

If an empty string is passed to this function, it means the user pressed [Backspace] so the last digit will be removed. Otherwise the character will be appended. After the field is updated the setText is called with the new string to update it.

@implementation MoneyField
...
- (void)int_init {

A shared internal init is required and will be called by all init functions. This properly sets up the field. Specifically it creates an NSNumberFormatter which actually formats the currency based on the given parameters. USD is hardcoded but this could be changed or allowed to be set externally in some way. I only need to worry about USD so I hardcoded it in this implementation.

The MoneyFieldDelegate is also created and attached to the field. The keyboard is set so it will work properly and look right for monetary input. That said, the keyboard’s return key type is not set here. This way, it can be set in Xcode or programmatically, based on the project needs. For example, “Go” or “Next” could be chosen. Finally, the text alignment is set to right-justify so it looks like you’d expect.

- (CGRect)caretRectForPosition:(UITextPosition *)position {

Disabled from compiling is hiding the caret. I found this to be good and bad. It’s good because it’s less jarring when the caret moves to the end of the field if they’ve moved it. Also, [Backspace] doesn’t remove at the caret but at the end. It’s bad because you have no indication the field is selected.

- (NSArray *)selectionRectsForRange:(UITextRange *)range {
...
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {

Selection, copy, paste… are all disabled. This makes it function more like a true monetary input instead of just a formatted text field, and keeps use consistent with user expectations. It also makes it easier on the delegate because this guarantees multiple characters won’t be entered.

- (void) setDelegate:(id<UITextFieldDelegate>)delegate {
    _money_delegate.delegate = delegate;
}
...
- (id<UITextFieldDelegate>)delegate {
    return _money_delegate.delegate;
}

This, combined with the forwarding in the delegate itself, is what allows an external delegate to be used with the widget. All that happens is it sets the external delegate on the internal delegate so it knows where to forward calls. Of course, there are also the corresponding functions to get the external delegate.

- (void)setText:(NSString *)text {
    [super setText:[_formatter stringFromNumber:[NSNumber numberWithDouble:[formatMoney(text) doubleValue]]]];
}

Setting text programmatically (which is used by the delegate) is a bit odd. The formatter can take only limited types of input so we first need to format the text as a string without any additional characters such as $. Then, the money string is fed to an NSNumber formatted, being told the string is formatted as a double. Finally, the double value is returned from the NSNumber and passed to the formatter which expects a double to convert to a string.

This might seem like a convoluted process when we could just stick a $ on the front of the formatted string but we really want to use the formatter because it handles currency differences. Such as the correct currency character ($ or other), as well as using a . or , for the cents differentiator. This implementation does hardcode USD but overall this is meant to be useable by other currency types.

- (NSString *)text {
    return formatMoney([super text]);
}

When getting the text, only the money as a double string is returned. Most of the time when money is passed as a string, only the value is requested and other characters like the $ should be excluded. Other functions could be added to get the fully formatted string or return the value as a double or int for implied decimal.