Saturday, March 6, 2010

Customizing The UITextField Format For Currency

** Update! **
Make sure to read part two of this article to learn about how this solution was optimized.

When I was building iAutoCalc for the iPhone I was looking for an easy way to validate and format the data as it was entered into a UITextField.  I found plenty of articles to do the validation, but nothing that showed a way to arbitrarily format the field as data was entered.  After tinkering around I came up with a pretty neat method of providing a locale correct format for currency data.  I haven't played with it anymore yet for other format styles, but I think it could be easily adopted for any format style.

The exact problem I wanted to solve was to have the user only be allowed to enter numeric data and have that data formatted as a local specific currency with proper grouping separators.  For example, if the user entered 20000 the UITextField would display $20,000.  I also wanted the formatting to happen real time as the user entered the data.

First we need to create a currency formatter:

The above creates a basic, local specific currency formatter without any decimal places.

We also need to create a set to help us reject all non-numbers:

I create both the currencyFormatter and nonNumberSet in the init function for use in the text change callback method below.

Now we need a hook into the UITextField that will tell our controller know when the text has been updated. The UITextFieldDelegate does just that by providing the callback method:

Each time a character is typed into the UITextField the above function will execute prior to the character being entered. If the function returns YES the character will be entered into the UITextField. For more information on this method see the Apple Developer Documentation.

Below is the full listing of the completed function:


The things to note about the finished function is that I am always returning NO from the function. I do this because I am manually managing what characters end up in the text field.

Next, notice the odd empty string case where I put in the localized currency symbol. The problem is that the currency formatter will not convert a string to a number without a currency symbol. It doesn't care about grouping symbols, but the lack of a currency symbol will cause it to return nothing. This case makes sure that the currency symbol is always the first character in the text field.

Finally is the trick that properly places the localized grouping separators. After making the correct string by inserting or removing characters based on the information the callback passes in, we turn that string into its number representation and then turn around and format it right back into a string. This process removes any grouping symbols (localized of course) and then puts them back in in the correct locations.

37 comments:

  1. Noob question: I would like to use your currency formatter in an app, but I am too noob to know where to declare it.

    I am guessing that I need to put into my viewController.h so I can call it in my viewController.m where I need it, but the exact syntax escapes me.

    No I have not finished reading all the docs yet, but I am in kind of a hurry to bang something out this weekend to help push my boss into approving the developers program subscription.

    I hope you can point me in the right direction, and thx for the post. Just what I needed.

    ReplyDelete
  2. @Adam

    What part are you confused about? Yes, you could put all of the code in your viewController.m and it would work fine. You could actually create a new currency formatter object every time the textfield callback fires, but that wouldn't be very efficient.

    In my code what I have done is create the currency formatter one time in the view init and then use it over and over for formatting purposes. Does that help?

    ReplyDelete
  3. Hey, thx. It is a syntax issue. I am getting "undeclared (first use in this function)".

    I only want to use the snippet
    NSNumberFormatter* currencyFormatter = [[[NSNumberFormatter alloc] init] autorelease];
    [currencyFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
    [currencyFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
    [currencyFormatter setMaximumFractionDigits:0];
    [currencyFormatter setLocale:[NSLocale currentLocale]];

    and then call the function in the following line:
    [price setText:[currencyFormatter stringFromNumber:nPrice]];

    where price is declared in my .h file:
    IBOutlet UITextField *price;
    and nPrice is an int also declared in my .h file (which I did remember to #include)...

    So my problem is: where do I place the snippet, and can I paste it as is (which seems not to be the case) or do I need to prefix it in some way to declare it as a function (seems to me to be the case).

    'preciate the help.

    ReplyDelete
  4. The snippet should be able to be used as-is. What exact line is the error occurring on? It's possible a typo creeped in while formatting for the blog. Check here for a group of similar examples and see if that helps you get it working.

    http://mac-objective-c.blogspot.com/2009/04/nsnumberformatter-some-examples.html

    ReplyDelete
  5. Hi Michael,

    The error is on 08. "nonNumberSet" is not declared.

    Marc

    ReplyDelete
  6. I had not gotten to line 8 yet, :)

    I just figured out that line 01 of the object instantiation:
    NSNumberFormatter* currencyFormatter = [[[NSNumberFormatter alloc] init] autorelease];

    should actually be:
    NSNumberFormatter *currencyFormatter = [[[NSNumberFormatter alloc] init] autorelease];

    ReplyDelete
  7. @Marc

    Thanks! I had it declared as an instance variable and left it out of my cut and paste. I fixed the post and added a little more explanation on how it is used.

    ReplyDelete
  8. Hi Michael,

    Thanks. Builds fine now. Just cannot get it to fire through the code.

    I have several currency fields on my view. I have never done this before other than using an IBOutlet to connect methods. How do I connect so your method is fired up each time...do I need to create an IBOutlet?

    Sorry I am new to Xcode SDK and learning fast, so the obvious might not seem so.

    ReplyDelete
  9. @Marc,

    You probably want to read up on how delegates in obj-c work. You can put the code from the article in your controller if you want, but I actually broke it out into a text field delegate format class. Either way, for each text field you want to format you have to set its delegate.

    [myTextField setDelegate:self]; //if you are using the controller as the delegate. I believe you can also set it in IB

    or

    [myTextField setDelegate:[[MyDelegate alloc] init]]; //if you made a MyDelegate class to encapsulate the code

    If you use the separate delegate object make sure to retain/release properly so you don't get a memory leak or have your delegate released prematurely.

    ReplyDelete
  10. I do not know if it is a compiler setting I have that is different, or what, but all of the instantiations have the asterisk in the wrong place according to my compiler. in the snippet they read:

    id* object

    and I need to change them to:

    id *object

    to get it to compile without errors.

    ReplyDelete
  11. @Adam,

    That's odd. It shouldn't matter where you put the * when declaring a pointer type.

    ReplyDelete
  12. Hi Michael,
    I have not responded, because I have been reading like you said. Now know something.
    It is just that getting a complete example, which includes the @protocol declaration with a sample field will help. I have trawled the Internet and just cannot seem to find something specific for a currency conversion on a UItextfield, which I have as an IBoutlet.
    I added all the changes I picked up from the reading and the code still does not fire up, so there is something I am not doing.

    ReplyDelete
  13. @Marc,

    I was just reading up more on @protocols and delegates today. I think I'm going to put together post with my thoughts on how they work and gotchas to watch out for.

    As far as your problem still not working. The steps I would follow are:

    1) Verify that you can change the text in your text field from code. Set up a button or something that when you click it the text changes. This will verify that you have the IB connections set up properly.

    2) Once you know that the text field is setup properly in the IB, take the code above and add it to your controller and then set the text fields delegate to the controller in your controllers init. Work with the debugger or use NSLog to make sure that textFieldShouldChangeCharactersInRange callback is in fact getting called. To start I would just comment the whole function and add a simple logging line to verify. Remember, you have to set the text fields delegate to the whatever object has that method in it (in this case your controller).

    ReplyDelete
  14. Hi Michael,

    Worked...almost.

    I think writing something which is simple will be nice, with a sample application.

    I suspected that the problem was with IB and your 2nd point seemed to sort the link. Method is now triggered. I then commented the code and still have a problem when I build.

    I set the pointer in the calling method to my text field "CurrentAnnualSalaryField":

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

    On lines 16 and 43 you refer to "textField".

    when I build it says that this is not defined.

    So I assumed this was my text field and changed this to: "CurrentAnnualSalaryField", but then I get a warning when I build, which says that the "local declaration of [CurrentAnnualSalaryField] hides instance variable. If I run the code with the warning (hate doing this, but just wanted to bug where it crashes), then it seems to accept the waring on line 16, but terminates with "uncaught exception" on line 43.

    I have synthesized this field.

    ReplyDelete
  15. @Marc,

    It's not defined because you changed the parameter list in the function. The callback function passes in the textField that called the function.

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

    In the case above you named the parameter CurrentAnnualSalaryField instead of leaving it what I called it, textField. Then since you're passing in a parameter of the same name as an instance variable you're going to get a warning.

    What you need to do is think of this callback as completely self contained. You shouldn't need to reference the text field instance variable and instead should be able to do all of the formatting using the parameters that are given to you by the function. Look at the protocol reference here.

    Also, I wrote another post that better explains protocols and delegates specifically as they pertain to the formatter. Check it out.

    ReplyDelete
  16. Hi Michael,

    I started off with:
    2010-05-10 21:29:16.277 Ifa[41406:207] textField: $600,000
    2010-05-10 21:29:35.526 Ifa[41406:207] mstring: $600,000

    I then backspaced on the UITextfield, which correctly ends up doing deleteCharactersInRange and returns mstring:
    2010-05-10 21:29:52.085 Ifa[41406:207] mstring: $600,00
    Then the next line bombs on:
    NSNumber *number = [currencyFormatter numberFromString:mstring];
    with Program received signal: “EXC_BAD_ACCESS”
    I know that is likely a memory issue, but I have released all my synthesized variables and the ones where I actually allocate memory.

    ReplyDelete
  17. Hi Michael,

    That memory bug bothered me because I try avoid those coming from the old days when we only had 128kb to play with. Anyway I ran Leaks and also the Static Analyzer (CLANG), which is a great tool for finding memory leaks (I can really advise people to use these tools), and had only 2 leaks across the app (which is significantly large - 10 view controllers and 8 data entities and lots of actuarial math).

    I had initially declared my currencyFormatter in the ViewDidLoad with (autorelease). So I was too ambitious with managing memory because I needed the function in your method. Removing that sorted it and it all works. Kind of obvious...

    Just a question...I pop up the Keyboard when in the field and have set an IB action to close the keyboard with the default key set in IB as DONE to do this.

    Return is set to NO in your example and so it does not allow one to leave so the IB Action to drop the keyboard is obviously ignored. If I then set this to YES, the keyboard drops away, but then the format of the field is wrong. For example if I backspace one char with $600,000 it represents the number as $600,00 instead of $60,000. So the comma is in the wrong place. With the Return = YES, the keyboard closes because the IB action is called to drop the keyboard. So I get what I want, but then the format goes for a loop.

    ReplyDelete
  18. I'm happy you found your memory error. I was looking over your other post and was going to suggest doing what you did. One word of caution though is that I have found that the simulator can throw leaks that don't exist, especially on startup. I only check for leaks on the device itself after one time of chasing down ghost leaks for hours in the simulator.

    Check this post for why the keyboard isn't dropping away. You need to add one more callback function to your delegate.

    -(BOOL)textFieldShouldReturn:(UITextField *)textField

    ReplyDelete
  19. Hi Michael,

    Hey man...now I understood exactly what I had to do...seems like you are a great teacher! The logic of this also makes lots of sense.

    Works 100%!!!!!

    On the Leak Analyzer...you are also spot on! I eventually worked the ghost thing with the leaks out and ended up using a #pragma unused(variable) to get rid of one message, which was obviously not a leak. I guess they will eventually fix this kind of thing (hey and why not auto fix your code leaks). For now it works OK for those mere mortals who really do not understand that one has to release synthesized vars (bar the primitive data vars like integers) and anything else you init without autorelease. If you remember that simple rule, you will sort out 99% of the leaks while you code.

    I just want to say thanks for your pointers. I appreciate them and take them very seriously. It takes a lot for people like yourself to go to the trouble of sharing. Spoon feeding is not the answer and you have the right approach. I have learned a lot from your example and pointers...I can even explain how delegates and protocols work from now on and hopefully be as good a teacher as you are.

    ReplyDelete
  20. @Marc

    I'm glad you got it working, and many thanks for the compliments! Hopefully our discussion can also help others.

    ReplyDelete
  21. I was happily using your code in my app when I decided to upgrade my provisioning ipod to 4.0. Therefore i was forced to upgrade my SDK to the latest version as well (4). Now when i enter text into the text fields, everything works fine until i try to enter a number with more than 4 digits. When i enter the 5th digit, the text field "resets" and erases everything that was in there. eg:
    $5
    $50
    $500
    $5,000
    //now when i type the 4th zero it goes blank!

    Any clue why this is happening?

    Thanks! great work on the code, btw. i was so happy when it was working before i upgraded, now im so mad at apple!!!

    ReplyDelete
  22. Ok so after a while I found out that the currencyFormatter doesnt like to get commas in the number (i guess this is new, or a bug). So just add one line that removes the commas like this:
    [CODE]
    NSString *mString2 = [mstring stringByReplacingOccurrencesOfString:@"," withString:@""]; //currency formatter doesnt like the commas
    [/CODE]

    and use mString2 instead of mstring to create the NSNumber

    ReplyDelete
  23. Hey Adam,

    I was just debugging the issue and saw your post right as I figured out the problem. In the previous SDK it would take the commas and strip them out, so either it was a bug that they fixed or one that was introduced. Your fix does correct the problem. I'm going to look around in the SDK and see if it was noted as being changed.

    Thanks!

    ReplyDelete
  24. Adam,

    One more thing. Don't strip out the comma directly. In order to work with different locales you need to retrieve the locale specific grouping separator and strip that out.

    Look at line 19 in the code in the article and instead of retrieving the currency symbol use NSLocaleGroupingSeparator to retrieve the locale specific grouping separator.

    Hope that helps.

    ReplyDelete
  25. Hi Michael,

    Nice to see you are all about this with SDK 4. I had a few small new irritating bugs including this one.

    Can you do us all a favor and please update your code for this change using the NSLocaleGroupingSeparator. I guess it will help all those mere mortals like myself, looking to have this sorted.

    ReplyDelete
  26. Marc,

    I updated the code in the post.

    ReplyDelete
  27. Hi Michael,

    I have been testing other currencies and there remains a problem when NSLocaleGroupingSeparator is a space, instead of a comma or the like.

    For example, South African currency Rands is formatted as R1 000 000, whilst the USD $ is $1,000,000

    So to get around the problem I check the local currency symbol and if it is R, then instead of using the local separator I have used a space to identify the char that needs to be replaced.

    The problem is that this will only work for Rands or currencies that are not Rand, but cause problems where the currency format is the same as the Rand. I don't know why Apple decided the Rand has no commas in the format, because this is new to me. I would have simply just changed the symbol on the front, but ensured that all currency separators have a comma. I guess Apple has a very good and valid reason for determining this. It can't be because we are running the 2010 World Cup here...

    This is what I did (which again needs to work in 100% of the cases and will not - I just need a fix for now):

    NSString *localeSeparator = [[NSLocale currentLocale]
    objectForKey:NSLocaleGroupingSeparator];
    NSString *ccySymbol = [[NSLocale currentLocale]
    objectForKey:NSLocaleCurrencySymbol];

    NSNumber *number;

    if ([localeSeparator isEqualToString: @"R"]) {

    number = [currencyFormatter numberFromString:[mstring stringByReplacingOccurrencesOfString:@" " withString:@""]];

    }else {

    number = [currencyFormatter numberFromString:[mstring stringByReplacingOccurrencesOfString:localeSeparator withString:@""]];
    }


    [mstring release];

    ReplyDelete
  28. Marc,

    I just did some testing and it is all working for me. Are you sure that you're changing your locale in all places? It needs to be change in the first code section as well as anywhere else that [NSLocale currentLocale] is used.

    For example, to test SA change line 5 in the first code listing to:

    [currencyFormatter setLocale:[[[NSLocale alloc] initWithLocaleIdentifier:@"en_ZA"] autorelease]];

    You'll also have to make a similar change in the shouldChangeCharactersInRange callback function to completely simulate a different locale for testing.

    Hope that helps.

    ReplyDelete
  29. Hi, I'm getting this error when using your code:

    'NSInvalidArgumentException', reason: '*** -[NSCFString stringByTrimmingCharactersInSet:]: nil argument'

    Application builds no problem ,but as soon as I type a character in a textfield I get this... what am I doing wrong?

    ReplyDelete
  30. Hi, hope I can get some help. I tried using the code you posted, but couldn't get it to work, first I got nonNumberSet undeclared.

    Then after declaring it, the app would build but I couldn't type into my text fields at all, the app wouldn't crash, but the textfields were rejecting all input, please help!

    ReplyDelete
  31. Hey Steve,

    Where did you declare nonNumberSet? It should be declared as an instance variable.

    NSCharacterSet *nonNumberSet;

    In your class interface and define it as a property.

    @property(nonatomic, retain, readonly) NSCharacterSet *nonNumberSet;

    Hope that helps.

    ReplyDelete
  32. Thanks for the code - how can I make it accept numbers with 2 decimal places?

    Thanks,
    Callum

    ReplyDelete
  33. Hi Callum,

    Did you ever sort out the decimal inputs. I'm now using ccy fields that need 2 decimals.

    Tx ahead
    Marc

    ReplyDelete
  34. How do you handle negative numbers? Simply adding: [numberSet addCharactersInString:@"-"]; didn't do it for me. I'm guessing because NSNumberFormatterCurrencyStyle handles negatives with parentheses.

    ReplyDelete
  35. this post had a lot of errors, even after sorting them out they still didn't work.

    You should have stayed up with the code, and made more appropriate fixes.

    ReplyDelete