3

Right now, I have the following:

private var currencyFormatter: NumberFormatter = {
    let f = NumberFormatter()
    // allow no currency symbol, extra digits, etc
    f.isLenient = true
    f.numberStyle = .currency
    return f
}()

TextField("Total", value: $totalInput, formatter: currencyFormatter)
    .font(.largeTitle)
    .padding()
    .background(Color.white)
    .foregroundColor(Color.black)
    .multilineTextAlignment(.center)

I want the textfield to start with $0.00 as a placeholder, but when the user starts entering, the first two inputs will be populated in the cents... so 5055 would progressively show as:

Step 1 (user hits 5): $0.05
Step 2 (user hits 0): $0.50
Step 3 (user hits 5): $5.05
Step 4 (user hits 5): $50.55

If the amount becomes greater than $999, then commas would be inserted.

How would one accomplish this? Right now my totalInput is type Double?.

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
Rolando
  • 58,640
  • 98
  • 266
  • 407

2 Answers2

8

To create a currency field that allow the user to type an amount from right to left you would need an observable object (binding manager), a currency number formatter and observe every time the value changes using onChange method:

import SwiftUI

struct ContentView: View {
    @ObservedObject private var currencyManager = CurrencyManager(amount: 0)
    @ObservedObject private var currencyManagerUS = CurrencyManager(
        amount: 0,
        locale: .init(identifier: "en_US")
    )
    @ObservedObject private var currencyManagerUK = CurrencyManager(
        amount: 0,
        locale: .init(identifier: "en_UK")
    )
    @ObservedObject private var currencyManagerFR =  CurrencyManager(
        amount: 0,
        locale: .init(identifier: "fr_FR")
    )
    @ObservedObject private var currencyManagerBR =  CurrencyManager(
        amount: 100,
        maximum: 100,
        locale: .init(identifier: "pt_BR")
    )
    var body: some View {
        VStack(alignment: .trailing, spacing: 0) {
            Spacer()
            Group {
                Text("Locale currency")
                TextField(currencyManager.string, text: $currencyManager.string)
                    .keyboardType(.numberPad)
                    .multilineTextAlignment(.trailing)
                    .onChange(of: currencyManager.string, perform: currencyManager.valueChanged)
                Spacer()
            }
            Group {
                Text("American currency")
                TextField(currencyManagerUS.string, text: $currencyManagerUS.string)
                    .keyboardType(.numberPad)
                    .multilineTextAlignment(.trailing)
                    .onChange(of: currencyManagerUS.string, perform: currencyManagerUS.valueChanged)
                Spacer()
            }
            Group {
                Text("British currency")
                TextField(currencyManagerUK.string, text: $currencyManagerUK.string)
                    .keyboardType(.numberPad)
                    .multilineTextAlignment(.trailing)
                    .onChange(of: currencyManagerUK.string, perform: currencyManagerUK.valueChanged)
                Spacer()
            }
            Group {
                Text("French currency")
                TextField(currencyManagerFR.string, text: $currencyManagerFR.string)
                    .keyboardType(.numberPad)
                    .multilineTextAlignment(.trailing)
                    .onChange(of: currencyManagerFR.string, perform: currencyManagerFR.valueChanged)
                Spacer()
            }
            Group {
                Text("Brazilian currency")
                TextField(currencyManagerBR.string, text: $currencyManagerBR.string)
                    .keyboardType(.numberPad)
                    .multilineTextAlignment(.trailing)
                    .onChange(of: currencyManagerBR.string, perform: currencyManagerBR.valueChanged)
                
            }
            Spacer()
        }.padding(.trailing, 25)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

class CurrencyManager: ObservableObject {
    
    @Published var string: String = ""
    private var amount: Decimal = .zero
    private let formatter = NumberFormatter(numberStyle: .currency)
    private var maximum: Decimal = 999_999_999.99
    private var lastValue: String = ""
    
    init(amount: Decimal, maximum: Decimal = 999_999_999.99, locale: Locale = .current) {
        formatter.locale = locale
        self.string = formatter.string(for: amount) ?? "$0.00"
        self.lastValue = string
        self.amount = amount
        self.maximum = maximum
    }
    
    func valueChanged(_ value: String) {
        let newValue = (value.decimal ?? .zero) / pow(10, formatter.maximumFractionDigits)
        if newValue > maximum {
            string = lastValue
        } else {
            string = formatter.string(for: newValue) ?? "$0.00"
            lastValue = string
        }
    }
}

extension NumberFormatter {
    
    convenience init(numberStyle: Style, locale: Locale = .current) {
        self.init()
        self.locale = locale
        self.numberStyle = numberStyle
    }
}

extension Character {
    
    var isDigit: Bool { "0"..."9" ~= self }
}

extension LosslessStringConvertible {
    
    var string: String { .init(self) }
}

extension StringProtocol where Self: RangeReplaceableCollection {
    
    var digits: Self { filter (\.isDigit) }
    
    var decimal: Decimal? { Decimal(string: digits.string) }
}

This is the SwiftUI equivalent to the custom CurrencyField I have implemented for UIKit.

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
  • Why use the "fileprivate" in the extension? Is this intended to be put in it's own view as opposed to putting this in a ContentView for use in an app? – Rolando Jan 19 '21 at 00:42
  • This is to avoid changing the formatter settings somewhere else in your code. You can remove it to use it somewhere else in your app as long as you keep the currency settings. – Leo Dabus Jan 19 '21 at 00:59
  • @Rolando If you still need help with using Decimal type instead of Double let me know – Leo Dabus Jan 19 '21 at 03:12
  • Is it better to use Decimal vs Double when representing this sort of thing? Transformations to do calculation with other variables wtih Int/Double appear confusing. – Rolando Jan 19 '21 at 03:22
  • Yes. Double it is not precise as `Decimal`. Check this post [Is floating point math broken?](https://stackoverflow.com/questions/588004/is-floating-point-math-broken). Make sure to always use the string initializer otherwise your value will be interpreted as Double before being coerced to Decimal. As I've already commented in your other post if you need to coerce your `Decimal` to `Double` or `Int` you need to cast it first to `NSDecimalNumber`. I would just use `Decimal` all the way. – Leo Dabus Jan 19 '21 at 03:30
  • `extension Decimal {` `var number: NSDecimalNumber { self as NSDecimalNumber }` `var double: Double { number.doubleValue }` `var integer: Int { number.intValue }` `}` – Leo Dabus Jan 19 '21 at 03:33
  • is there a problem with bindingManager being marked as @ObservedObject instead of @StateObject? Who will retain the same BindingManager? – OMGPOP Feb 23 '22 at 03:01
  • @OMGPOP not sure what is your question. – Leo Dabus Feb 23 '22 at 03:09
  • I plan to use your solution for my currency field, but I am not sure if this should be StateObject instead of ObservedObject – OMGPOP Feb 23 '22 at 04:39
  • You should use ObservedObject. I will update the answer to illustrate how to use different currencies – Leo Dabus Feb 23 '22 at 04:44
  • @OMGPOP check my last edit. If you need a different number formatter just create a similar manager based on the CurrencyManager – Leo Dabus Feb 23 '22 at 04:50
  • @LeoDabus could you explain a bit more why you are using ObservedObject? My understanding is that eventually there has to be a StateObject in the code that is responsible for creating it. Based on https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject – OMGPOP Feb 23 '22 at 05:00
  • I just tried StateObject and it worked too. Just wanted to double check – OMGPOP Feb 23 '22 at 05:12
  • @LeoDabus - I really like your approach but for some reason deleting digits for `French currency` or euros in general, is not working for me, is it working for you? Also, why is it that for Brazilian currency the highest number I can enter is R$99.99? Thanks a lot! – fs_tigre Feb 25 '22 at 16:59
  • @fs_tigre That's up to you the maximum amount. I've specified 100 just for testing purposes. Check the `currencyManagerBR` initialization. – Leo Dabus Feb 25 '22 at 17:02
  • @fs_tigre Regarding the deleteBackwards I suspect the issue is the location of the currency symbol at the right side. I think the bets approach would probably be to get the UIKit implementation of my other post and create a UIViewRepresentable – Leo Dabus Feb 25 '22 at 17:06
  • 1
    @fs_tigre Btw AFAIR 100 would work as well when setting the maximum to 100. IMO SwiftUI still lacks a lot of functionalities and in the end we have to rely on UIKit for more advanced features. – Leo Dabus Feb 25 '22 at 17:09
  • @LeoDabus - Actually, if you change your region to Brazil in the Settings, the `Locale currency` works just fine and you can enter any amount. The only gray area at this point is deleting digits with currencies that have the currency symbol on the right side, this is a good start. Thanks a lot for the info. – fs_tigre Feb 25 '22 at 17:49
  • 1
    @fs_tigre unfortunately there is no way to detect deleteBackwards using SwiftUI AFAIK but you can try to detect that comparing the lastValue and the current. – Leo Dabus Feb 25 '22 at 17:58
  • 1
    @fs_tigre try adding this inside `valueChanged` method `var value = value` `for index in lastValue.indices {` `if value == lastValue[.. – Leo Dabus Feb 25 '22 at 17:59
  • And add `self.lastValue = string` to the `CurrencyManager` init – Leo Dabus Feb 25 '22 at 18:01
  • 1
    This will force always deleting the last digit regardless of where the cursor/caret is located – Leo Dabus Feb 25 '22 at 18:02
  • @LeoDabus Just out of curiosity, why did you add `amount: 100` and `maximum: 100` to the Brazilian currency? Why not just `amount:0` as the rest? Not a problem I'm just curious. – fs_tigre Feb 25 '22 at 18:13
  • 1
    It was just a test. I wanted to check if the limit and the initial value were working properly – Leo Dabus Feb 25 '22 at 18:15
0

I have created a component that wraps around a UITextfield.

You can check it out here https://github.com/youjinp/SwiftUIKit

Here's the demo

currency text field demo

youjin
  • 2,147
  • 1
  • 12
  • 29
  • 1
    This doesn't work as OP asked. OP wants to enter the value from right to left. It also doesn't display `$0.00`. – Leo Dabus Jan 19 '21 at 00:21