Skip to content

Instantly share code, notes, and snippets.

@dimitris-c
Last active December 20, 2017 14:17
Show Gist options
  • Save dimitris-c/159b9edd3c82f877f4a21bc882e6ab74 to your computer and use it in GitHub Desktop.
Save dimitris-c/159b9edd3c82f877f4a21bc882e6ab74 to your computer and use it in GitHub Desktop.
MoneyTextField - A textfield that enforces rules based an inputting amount of money
import Foundation
public protocol AmountType {
var minorUnits: Int { get }
var currency: Currency { get }
init(amount: Int, currency: Currency)
init(amount: Float, currency: Currency)
init(amount: String, currency: Currency)
init(minorUnits: String, currency: Currency)
/// Retuns a `String` representing the amount using the passed NumberFormatter
func formatted(using formatter: NumberFormatter) -> String
}
public struct Amount: AmountType {
public var currency: Currency
public let minorUnits: Int
public init(amount: Int, currency: Currency) {
let decimal = NSDecimalNumber(value: amount)
self.init(decimal: decimal, currency: currency)
}
public init(amount: Float, currency: Currency) {
let decimal = NSDecimalNumber(value: amount)
self.init(decimal: decimal, currency: currency)
}
public init(amount: String, currency: Currency) {
let decimal = NSDecimalNumber(string: amount)
self.init(decimal: decimal, currency: currency)
}
private init(decimal: NSDecimalNumber, currency: Currency) {
let fmt = NumberFormatter()
fmt.numberStyle = .currency
fmt.currencyCode = currency.rawValue
let scale = fmt.maximumFractionDigits
self.currency = currency
let behavior = NSDecimalNumberHandler(roundingMode: .plain, scale: Int16(scale), raiseOnExactness: true, raiseOnOverflow: true, raiseOnUnderflow: true, raiseOnDivideByZero: true)
self.decimal = decimal.rounding(accordingToBehavior: behavior)
self.minorUnits = self.decimal.multiplying(byPowerOf10: Int16(scale)).intValue
}
public init(minorUnits: String, currency: Currency) {
let fmt = NumberFormatter()
fmt.numberStyle = .currency
fmt.currencyCode = currency.rawValue
let scale = fmt.maximumFractionDigits
self.currency = currency
let behavior = NSDecimalNumberHandler(roundingMode: .plain, scale: Int16(scale), raiseOnExactness: true, raiseOnOverflow: true, raiseOnUnderflow: true, raiseOnDivideByZero: true)
let decimal = NSDecimalNumber(string: minorUnits).multiplying(byPowerOf10: -Int16(scale), withBehavior: behavior)
self.decimal = decimal.rounding(accordingToBehavior: behavior)
self.minorUnits = self.decimal.multiplying(byPowerOf10: Int16(scale), withBehavior: behavior).intValue
}
public func formatted(using formatter: NumberFormatter) -> String {
formatter.currencyCode = self.currency.rawValue
guard let string = formatter.string(from: self.decimal) else {
print("This shouldn't happen! Investigate!")
return ""
}
return string
}
// MARK: Private
fileprivate let decimal: NSDecimalNumber
}
// MARK: Equatable
extension Amount: Equatable {
public static func == (lhs: Amount, rhs: Amount) -> Bool {
return lhs.minorUnits == lhs.minorUnits
&& lhs.decimal.intValue == rhs.decimal.intValue
&& lhs.currency == rhs.currency
}
}
extension Double {
public static func == (lhs: Amount?, rhs: Double) -> Bool {
guard let amount = lhs else {
return false
}
return abs(amount.decimal.doubleValue - rhs) < 0.00001
}
public static func == (lhs: Double, rhs: Amount?) -> Bool {
guard let amount = rhs else {
return false
}
return abs(amount.decimal.doubleValue - lhs) < 0.00001
}
}
// MARK: Comparable
extension Amount: Comparable {
public static func < (lhs: Amount, rhs: Amount) -> Bool {
return lhs.minorUnits < rhs.minorUnits
&& lhs.currency == rhs.currency
}
public static func > (lhs: Amount, rhs: Amount) -> Bool {
return lhs.minorUnits > rhs.minorUnits
&& lhs.currency == rhs.currency
}
public static func >= (lhs: Amount, rhs: Amount) -> Bool {
return lhs.minorUnits >= rhs.minorUnits
&& lhs.currency == rhs.currency
}
public static func <= (lhs: Amount, rhs: Amount) -> Bool {
return lhs.minorUnits <= rhs.minorUnits
&& lhs.currency == rhs.currency
}
}
public enum Currency: String {
case EUR, ALL, USD, XCD, AMD
case AWG, SHP, AUD, AZN, BSD
case BHD, BDT, BBD, BYN, BZD
case BMD, BTN, BAM, BWP, BRL
case BND, BGN, BIF, CVE, XAF
case CAD, KYD, NZD, CLP, COP
case KMF, CDF, CRC, HRK, CUP
case ANG, CZK, DKK, DJF, EGP
case ERN, ETB, FKP, FJD, XPF
case GEL, GIP, GTQ, GGP, GNF
case HNL, HKD, HUF, ISK, INR
case XDR, IQD, IMP, ILS, JPY
case JEP, JOD, KWD, LYD, CHF
case MOP, MKD, MGA, MWK, MYR
case MVR, MUR, MXN, MDL, MNT
case KPW, NOK, OMR, PGK, PEN
case PHP, PLN, QAR, RON, WST
case SAR, RSD, SCR, SGD, SBD
case SOS, ZAR, GBP, KRW, SSP
case SRD, SZL, SEK, SYP, TWD
case TOP, TMT, AED, UYU, UZS
public func name() -> String {
switch self {
case .EUR: return "European euro"
case .ALL: return "Albanian lek"
case .USD: return "United States dollar"
case .XCD: return "East Caribbean dollar"
case .AMD: return "Armenian dram"
case .AWG: return "Aruban florin"
case .SHP: return "Saint Helena pound"
case .AUD: return "Australian dollar"
case .AZN: return "Azerbaijan manat"
case .BSD: return "Bahamian dollar"
case .BHD: return "Bahraini dinar"
case .BDT: return "Bangladeshi taka"
case .BBD: return "Barbadian dollar"
case .BYN: return "Belarusian ruble"
case .BZD: return "Belize dollar"
case .BMD: return "Bermudian dollar"
case .BTN: return "Bhutanese ngultrum"
case .BAM: return "Bosnia and Herzegovina convertible mark"
case .BWP: return "Botswana pula"
case .BRL: return "Brazilian real"
case .BND: return "Brunei dollar"
case .BGN: return "Bulgarian lev"
case .BIF: return "Burundi franc"
case .CVE: return "Cape Verdean escudo"
case .XAF: return "Central African CFA franc"
case .CAD: return "Canadian dollar"
case .KYD: return "Cayman Islands dollar"
case .NZD: return "New Zealand dollar"
case .CLP: return "Chilean peso"
case .COP: return "Colombian peso"
case .KMF: return "Comorian franc"
case .CDF: return "Congolese franc"
case .CRC: return "Costa Rican colon"
case .HRK: return "Croatian kuna"
case .CUP: return "Cuban peso"
case .ANG: return "Netherlands Antillean guilder"
case .CZK: return "Czech koruna"
case .DKK: return "Danish krone"
case .DJF: return "Djiboutian franc"
case .EGP: return "Egyptian pound"
case .ERN: return "Eritrean nakfa"
case .ETB: return "Ethiopian birr"
case .FKP: return "Falkland Islands pound"
case .FJD: return "Fijian dollar"
case .XPF: return "CFP franc"
case .GEL: return "Georgian lari"
case .GIP: return "Gibraltar pound"
case .GTQ: return "Guatemalan quetzal"
case .GGP: return "Guernsey Pound"
case .GNF: return "Guinean franc"
case .HNL: return "Honduran lempira"
case .HKD: return "Hong Kong dollar"
case .HUF: return "Hungarian forint"
case .ISK: return "Icelandic krona"
case .INR: return "Indian rupee"
case .XDR: return "SDR (Special Drawing Right)"
case .IQD: return "Iraqi dinar"
case .IMP: return "Manx pound"
case .ILS: return "Israeli new shekel"
case .JPY: return "Japanese yen"
case .JEP: return "Jersey pound"
case .JOD: return "Jordanian dinar"
case .KWD: return "Kuwaiti dinar"
case .LYD: return "Libyan dinar"
case .CHF: return "Swiss franc"
case .MOP: return "Macanese pataca"
case .MKD: return "Macedonian denar"
case .MGA: return "Malagasy ariary"
case .MWK: return "Malawian kwacha"
case .MYR: return "Malaysian ringgit"
case .MVR: return "Maldivian rufiyaa"
case .MUR: return "Mauritian rupee"
case .MXN: return "Mexican peso"
case .MDL: return "Moldovan leu"
case .MNT: return "Mongolian tugrik"
case .KPW: return "North Korean won"
case .NOK: return "Norwegian krone"
case .OMR: return "Omani rial"
case .PGK: return "Papua New Guinean kina"
case .PEN: return "Peruvian sol"
case .PHP: return "Philippine peso"
case .PLN: return "Polish zloty"
case .QAR: return "Qatari riyal"
case .RON: return "Romanian leu"
case .WST: return "Samoan tala"
case .SAR: return "Saudi Arabian riyal"
case .RSD: return "Serbian dinar"
case .SCR: return "Seychellois rupee"
case .SGD: return "Singapore dollar"
case .SBD: return "Solomon Islands dollar"
case .SOS: return "Somali shilling"
case .ZAR: return "South African rand"
case .GBP: return "Pound sterling"
case .KRW: return "South Korean won"
case .SSP: return "South Sudanese pound"
case .SRD: return "Surinamese dollar"
case .SZL: return "Swazi lilangeni"
case .SEK: return "Swedish krona"
case .SYP: return "Syrian pound"
case .TWD: return "New Taiwan dollar"
case .TOP: return "Tongan pa’anga"
case .TMT: return "Turkmen manat"
case .AED: return "UAE dirham"
case .UYU: return "Uruguayan peso"
case .UZS: return "Uzbekistani som"
}
}
public func nameAndCode() -> String {
return self.name() + " (\(self.rawValue))"
}
public static func allCurrencies() -> [Currency] {
let all: [Currency] = [
.EUR, .ALL, .USD, .XCD, .AMD,
.AWG, .SHP, .AUD, .AZN, .BSD,
.BHD, .BDT, .BBD, .BYN, .BZD,
.BMD, .BTN, .BAM, .BWP, .BRL,
.BND, .BGN, .BIF, .CVE, .XAF,
.CAD, .KYD, .NZD, .CLP, .COP,
.KMF, .CDF, .CRC, .HRK, .CUP,
.ANG, .CZK, .DKK, .DJF, .EGP,
.ERN, .ETB, .FKP, .FJD, .XPF,
.GEL, .GIP, .GTQ, .GGP, .GNF,
.HNL, .HKD, .HUF, .ISK, .INR,
.XDR, .IQD, .IMP, .ILS, .JPY,
.JEP, .JOD, .KWD, .LYD, .CHF,
.MOP, .MKD, .MGA, .MWK, .MYR,
.MVR, .MUR, .MXN, .MDL, .MNT,
.KPW, .NOK, .OMR, .PGK, .PEN,
.PHP, .PLN, .QAR, .RON, .WST,
.SAR, .RSD, .SCR, .SGD, .SBD,
.SOS, .ZAR, .GBP, .KRW, .SSP,
.SRD, .SZL, .SEK, .SYP, .TWD,
.TOP, .TMT, .AED, .UYU, .UZS,
]
// In the list of all currencies, EUR should be first
return all.sorted(by: { l, r in
if l == .EUR {
return true
} else if r == .EUR {
return false
} else {
return l.name() < r.name()
}
})
}
}
extension Currency {
/// Returns the currencySymbol based on the currency code, or empty if no default currencySymbol was found.
public func currencySymbol() -> String {
let idFromComponents = NSLocale.localeIdentifier(fromComponents: [NSLocale.Key.currencyCode.rawValue: self.rawValue])
let canonical = NSLocale.canonicalLocaleIdentifier(from: idFromComponents)
let nslocale = NSLocale(localeIdentifier: canonical)
let currencySymbol = nslocale.object(forKey: .currencySymbol) as? String
return currencySymbol ?? ""
}
}
extension Currency: Equatable {
public static func == (lhs: Currency, rhs: Currency) -> Bool {
return lhs.rawValue == rhs.rawValue
}
}
import Foundation
import UIKit
/**
A Custom TextField that acts as a money input and enforces several rules
*/
final class MoneyTextField: UITextField {
private var currencySymbol: String {
return self.viewModel.currencySymbol
}
public var viewModel: MoneyTextFieldViewModel
init(with viewModel: MoneyTextFieldViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
self.delegate = viewModel
self.keyboardType = .decimalPad
self.autocapitalizationType = .none
self.autocorrectionType = .no
self.returnKeyType = .next
self.viewModel.textDidChange = { [weak self] text in
self?.text = text
self?.correctCaretPosition()
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Overriding to stop the caret from moving past the currency symbol, if any, while dragging
override func closestPosition(to point: CGPoint) -> UITextPosition? {
guard !self.currencySymbol.isEmpty else {
return super.closestPosition(to: point)
}
if let positionWithCurrencySymbol = self.position(from: self.beginningOfDocument, offset: self.currencySymbol.characters.count) {
if let rangeWithCurrencySymbol = textRange(from: beginningOfDocument, to: positionWithCurrencySymbol) {
let caretRectForEndOfSymbol = caretRect(for: rangeWithCurrencySymbol.end)
if point.x < caretRectForEndOfSymbol.origin.x {
return rangeWithCurrencySymbol.end
}
}
}
return super.closestPosition(to: point)
}
// Overriding to stop the caret from moving past the currency symbol, if any, while pressing left/right keys
override func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? {
if let positionWithCurrencySymbol = self.position(from: self.beginningOfDocument, offset: self.currencySymbol.characters.count) {
if self.compare(position, to: positionWithCurrencySymbol) == .orderedSame && direction == .left {
return positionWithCurrencySymbol
}
}
return super.position(from: position, in: direction, offset: offset)
}
private func correctCaretPosition() {
if let position = self.viewModel.caretPositionBeforeFormatting {
self.selectedTextRange = self.textRange(from: position, to: position)
}
}
}
import Foundation
import UIKit
final public class MoneyTextFieldViewModel: NSObject, MoneyTextFieldInput {
public var currencySymbol: String = ""
public var decimalSeparator: String = ""
public var textDidChange: ((_ text: String) -> Void)?
public var validateWhileTyping: (() -> Void)?
public var validateOnEndEditing: (() -> Void)?
public var amountValue: String = ""
public var amount: Amount? {
if amountValue.isEmpty || !amountValue.containsOnly(characters: CharacterSet(charactersIn: "1234567890.,")) {
return nil
}
return Amount(amount: amountValue, currency: self.currency)
}
/// Used to hold a reference of the caret position before any formatting.
fileprivate(set) public var caretPositionBeforeFormatting: UITextPosition?
public init(currency: Currency) {
self.currency = currency
self.currencySymbol = self.currency.currencySymbol()
let formatter = NumberFormatter()
formatter.currencyCode = currency.rawValue
formatter.numberStyle = .currency
formatter.currencySymbol = self.currencySymbol
self.decimalSeparator = formatter.currencyDecimalSeparator
}
public func shouldUpdateText(currentText: String, newText: String, replacement: String) -> Bool {
guard newText.containsOnly(characters: self.allowedCharacters) else { return false }
if currentText == self.currencySymbol && replacement.isEmpty {
self.textDidChange?("")
self.updateAmountValue(string: "")
return false
}
if self.restrictDeletionOfCurrencySymbol(newText: newText, replacement: replacement) {
return false
}
let newText = self.removeCurrencyPrefix(text: newText)
let currentText = self.removeCurrencyPrefix(text: currentText)
if self.restrictToSingleDecimalCharacter(currentText: currentText, replacement: replacement) {
return false
}
if self.restrictToMaxCharacters(text: newText) {
return false
}
if self.restrictToTwoDecimalDigits(currentText: currentText, newText: newText, replacement: replacement) {
return false
}
self.updateAmountValue(string: newText)
self.validateWhileTyping?()
if self.shouldReplaceFirstDigitIfZero(currentText: currentText, replacement: replacement) {
self.textDidChange?(self.formattedText(string: replacement))
self.updateAmountValue(string: replacement)
self.validateWhileTyping?()
return false
}
else if self.shouldAddZeroIfDecimalSeparatorEntered(currentText: currentText, replacement: replacement) {
let decimalString = "0\(self.decimalSeparator)"
self.textDidChange?(self.formattedText(string: decimalString))
self.updateAmountValue(string: decimalString)
self.validateWhileTyping?()
return false
} else if !newText.isEmpty {
self.textDidChange?(self.formattedText(string: newText))
self.validateWhileTyping?()
return false
}
return true
}
public func hasValidAmount() -> Bool {
return self.amount != nil
}
// MARK: Private
private var currency: Currency
fileprivate func updateAmountValue(string: String) {
self.amountValue = string.replacingOccurrences(of: ",", with: ".")
}
fileprivate func formattedText(string: String) -> String {
return "\(self.currencySymbol)\(string)"
}
private var allowedCharacters: CharacterSet {
let characters = "1234567890\(self.currencySymbol)\(self.decimalSeparator)"
return CharacterSet(charactersIn: characters)
}
private func removeCurrencyPrefix(text: String) -> String {
var newText = text
if let range = text.range(of: self.currencySymbol) {
newText.removeSubrange(range)
return newText
}
return text
}
private func restrictToSingleDecimalCharacter(currentText: String, replacement: String) -> Bool {
return currentText.contains(self.decimalSeparator) && replacement == self.decimalSeparator
}
private func restrictToMaxCharacters(text: String) -> Bool {
return text.characters.count > 10
}
private func restrictToTwoDecimalDigits(currentText: String, newText: String, replacement: String) -> Bool {
if currentText.contains(self.decimalSeparator) || newText.contains(self.decimalSeparator) {
if let afterDecimal = newText.components(separatedBy: CharacterSet(charactersIn: self.decimalSeparator)).last, !replacement.isEmpty {
if afterDecimal.characters.count > 2 {
return true
}
}
}
return false
}
private func shouldReplaceFirstDigitIfZero(currentText: String, replacement: String) -> Bool {
if currentText == "0" && replacement.containsOnly(characters: CharacterSet(charactersIn: "1234567890")) && !replacement.isEmpty {
return true
}
return false
}
private func shouldAddZeroIfDecimalSeparatorEntered(currentText: String, replacement: String) -> Bool {
return currentText.isEmpty && replacement == self.decimalSeparator
}
private func restrictDeletionOfCurrencySymbol(newText: String, replacement: String) -> Bool {
return !newText.hasPrefix(self.currencySymbol) && !newText.isEmpty && replacement.isEmpty
}
}
extension MoneyTextFieldViewModel: UITextFieldDelegate {
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let newText: String = (textField.text as NSString?)?.replacingCharacters(in: range, with: string) ?? string
let currentText: String = textField.text ?? ""
let offset = range.location + string.characters.count
// extra care taken when trying to delete the currencySymbol, this prevents the caret from moving past the currencySymbol
if offset > 0 {
self.caretPositionBeforeFormatting = textField.position(from: textField.beginningOfDocument, offset: offset)
}
return self.shouldUpdateText(currentText: currentText, newText: newText, replacement: string)
}
public func textFieldDidEndEditing(_ textField: UITextField) {
if let text = textField.text?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) {
self.textDidChange?(text)
self.updateAmountValue(string: text)
}
self.validateOnEndEditing?()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment