How to make it so that user enters currency only with numbers in SwiftUI textfield, while preserving the $ and the .?
Asked Answered
D

2

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?.

Dwyer answered 18/1, 2021 at 22:37 Comment(0)
I
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 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.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.

Intoxication answered 18/1, 2021 at 23:57 Comment(24)
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?Dwyer
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.Intoxication
@Dwyer If you still need help with using Decimal type instead of Double let me knowIntoxication
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.Dwyer
Yes. Double it is not precise as Decimal. Check this post 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.Intoxication
extension Decimal { var number: NSDecimalNumber { self as NSDecimalNumber } var double: Double { number.doubleValue } var integer: Int { number.intValue } }Intoxication
is there a problem with bindingManager being marked as @ObservedObject instead of @StateObject? Who will retain the same BindingManager?Hyp
@Hyp not sure what is your question.Intoxication
I plan to use your solution for my currency field, but I am not sure if this should be StateObject instead of ObservedObjectHyp
You should use ObservedObject. I will update the answer to illustrate how to use different currenciesIntoxication
@Hyp check my last edit. If you need a different number formatter just create a similar manager based on the CurrencyManagerIntoxication
@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 hackingwithswift.com/quick-start/swiftui/…Hyp
I just tried StateObject and it worked too. Just wanted to double checkHyp
@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!Dawkins
@Dawkins That's up to you the maximum amount. I've specified 100 just for testing purposes. Check the currencyManagerBR initialization.Intoxication
@Dawkins 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 UIViewRepresentableIntoxication
@Dawkins 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.Intoxication
@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.Dawkins
@Dawkins unfortunately there is no way to detect deleteBackwards using SwiftUI AFAIK but you can try to detect that comparing the lastValue and the current.Intoxication
@Dawkins try adding this inside valueChanged method var value = value for index in lastValue.indices { if value == lastValue[..<index] + lastValue[lastValue.index(after: index)...] { value = lastValue.digits.dropLast().string break } }Intoxication
And add self.lastValue = string to the CurrencyManager initIntoxication
This will force always deleting the last digit regardless of where the cursor/caret is locatedIntoxication
@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.Dawkins
It was just a test. I wanted to check if the limit and the initial value were working properlyIntoxication
C
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

Cahoon answered 19/1, 2021 at 0:6 Comment(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.Intoxication

© 2022 - 2024 — McMap. All rights reserved.