Last active
December 20, 2017 14:17
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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