[Localized Number] 4편: LocalizedNumberFormatter 구현하기

baecheese·2021년 3월 3일
0
post-thumbnail

목차

🌎 목적

  • 버그 발생 조건을 차단하여 NumberFormatter를 이용할 수 있도록 하기 위함
  • LocalizedNumberFormatter를 이용한 Textfield 적용 예제 구현

💛 LocalizedNumberFormatter 구현하기

  1. input과 output의 Locale 설정을 분리합니다.
  2. Locale 설정을 생략하지 못하도록 해야합니다.
  3. 에러를 리턴할 수 있어야 합니다.

🧩 변환 가능한 숫자 포맷

  1. 지정 국가의 숫자로 이루어진 경우
    • 🇺🇸 en_US ) 123
    • 🇺🇸 en_US ) 12000
    • 🇳🇵 ne ) १२३
  2. 1이면서 소수점 구분 문자(decimal separator)가 있는 경우
    • 🇺🇸 en_US ) 1.23
    • 🇻🇳 vi ) 1,23
  3. 1이면서 그룹 구분 문자(grouping separator)가 있는 경우
    • 🇺🇸 en_US ) 1,200
    • 🇻🇳 vi ) 1.200
  4. 1이면서 소숫점 구분 문자, 천단위 구분 문자가 모두 있는 경우
    • 🇺🇸 en_US ) 1,203.3
    • 🇻🇳 vi ) 1.203,3
  5. 빈문자열(""), 점(".") 은 0으로 처리
  6. first 또는 last character가 decimalSeparator면 0 처리
    • 🇺🇸 en_US ) ".134" == 0.134
    • 🇺🇸 en_US ) "123." == 123.0
  7. 음수일 경우
  8. 특수 숫자가 섞여있을 경우
    • 123१२३۱۲۳

💎 구조

상세 코드

에러

enum LocalizedNumberFormatterError: Error {
    case invaildCharacters
    case moreThanOneDecimalSeparator
    case reverseSeparator
    case notConvertedNSNumberToLocalizedString
    case unknown
   
    var message: String {
        switch self {
        case .invaildCharacters:
            return "올바르지 않은 문자가 포함되어있습니다."
        case .moreThanOneDecimalSeparator:
            return "소숫점 기호가 한 개 이상입니다."
        case .reverseSeparator:
            return "그룹 구분 기호와 소숫점 기호가 반대로 되어있습니다."
        case .notConvertedNSNumberToLocalizedString:
            return "현지화 문자로 변환 되지 않는 숫자입니다."
        case .unknown:
            return "알 수 없는 에러입니다."
        }
    }
}

Protocol

protocol LocalizedNumberFormatterDataSource {
    
    var fromLocale: Locale { get }
    var toLocale: Locale { get }
    
    // input String -> NSNumber
    func number(from value: String) throws -> NSNumber
    // input string -> output localized number string
    func localizedNumberString(from value: String, style: NumberFormatter.Style) throws -> String
}
  • input, output locale을 필수로 넣도록 합니다.
  • 숫자 변환 메소드를 만듭니다.

LocalizedNumberFormatter 상세 구현

class LocalizedNumberFormatter: LocalizedNumberFormatterDataSource {
    
    typealias ConvertedError = LocalizedNumberFormatterError
    
    var fromLocale: Locale
    var toLocale: Locale
    
    private let log: Logger = Logger(logPlace: LocalizedNumberFormatter.self)
    
    init(from: Locale, to: Locale) {
        self.fromLocale = from
        self.toLocale = to
    }
    
}

extension LocalizedNumberFormatter {
    
    func number(from value: String) throws -> NSNumber {
        let numberFormatter: NumberFormatter = NumberFormatter()
        numberFormatter.locale = fromLocale
        // 
        let splitValues: (integer: String, decimal: String) = try splitToDecimal(locale: fromLocale, value: value)
        let integerValue: String = splitValues.integer.isEmpty
            ? "0" // ".12" -> "0.12"
            : splitValues.integer.replacingOccurrences(of: numberFormatter.groupingSeparator, with: "")
        let decimalValue: String = splitValues.decimal.isEmpty
            ? "0" // "1." -> "1.0"
            : splitValues.decimal
        guard false == (integerValue.contains(numberFormatter.decimalSeparator))
                && false == (decimalValue.contains(numberFormatter.groupingSeparator)) else {
            log.error(message: " - value : \(value)\n - result : \(ConvertedError.reverseSeparator)")
            throw ConvertedError.reverseSeparator
        }
        guard let result = numberFormatter.number(from: integerValue + numberFormatter.decimalSeparator + decimalValue) else {
            log.error(message: " - value : \(value)\n - result : \(ConvertedError.invaildCharacters)")
            throw ConvertedError.invaildCharacters
        }
        log.info(message: " - value : \(value)\n - result : \(result)")
        return result
    }
    
    private func splitToDecimal(locale: Locale, value: String) throws -> (integer: String, decimal: String) {
        guard false == value.isEmpty && "." != value else {
            return (integer: "0", decimal: "0")
        }
        let numberFormatter: NumberFormatter = NumberFormatter()
        numberFormatter.locale = locale
        let splitedValues: [String] = value.components(separatedBy: numberFormatter.decimalSeparator)
        guard splitedValues.count <= 2 else { // DecimalSeparator 두개 이상이면 에러
            log.error(message: " - value: \(value)\n - error: \(ConvertedError.moreThanOneDecimalSeparator)")
            throw ConvertedError.moreThanOneDecimalSeparator
        }
        if 1 == splitedValues.count {
            let result = (integer: splitedValues.first ?? "", decimal: "")
            return result
        }
        guard 2 == splitedValues.count else {
            // 유효하지 않은 포맷
            log.error(message: " - value: \(value)\n - error: \(ConvertedError.unknown)")
            throw ConvertedError.unknown
        }
        let integerValue = splitedValues.first!.replacingOccurrences(of: numberFormatter.groupingSeparator, with: "")
        let decimalValue = splitedValues.last!
        return (integer: integerValue, decimal: decimalValue)
    }
    
}

extension LocalizedNumberFormatter {
    
    func localizedNumberString(from value: String, style: NumberFormatter.Style) throws -> String {
        do {
            let number: NSNumber = try self.number(from: value)
            let numberFormatter: NumberFormatter = NumberFormatter()
            numberFormatter.locale = toLocale
            numberFormatter.numberStyle = style
            guard let localizedString = numberFormatter.string(from: number) else {
                log.error(message: " - value : \(value)\n - error : \(ConvertedError.notConvertedNSNumberToLocalizedString)")
                throw ConvertedError.notConvertedNSNumberToLocalizedString
            }
            log.info(message: " - value : \(value)\n - result : \(localizedString)")
            return localizedString
        } catch {
            throw error
        }
    }
    
}
  • grouping separator가 포함되어있는 경우, NumberFormatteropen func number(from string: String) -> NSNumber?nil이 됩니다.
<예시>

let value = "-1,000.023"
let numberFormatter: NumberFormatter = NumberFormatter()
numberFormatter.locale = Locale(identifier: "en_US")

(lldb) po numberFormatter.number(from: value)
nil
  • 때문에 정수와 소수를 분리해서 유효한 포맷인지 체크합니다.
    • func splitToDecimal(locale: Locale, value: String) throws -> (integer: String, decimal: String)

✅ Test Code

import XCTest
@testable import LocalizedNumberField

class LocalizedNumberFormatterTest: XCTestCase {
    
    private let log = Logger(logPlace: LocalizedNumberFormatterTest.self)
    private let numberFormatter: NumberFormatter = NumberFormatter()
    private let locale_enUS = Locale.en_US
    
    // "."은 0.0 으로 처리할 것
    let vaildAmounts = [
        "1234",
        "1,234",
        "1.2",
        "1,200.13",
        // 0으로 처리
        "",
        ".",
        //first or last가 decimalSeparator면 0 처리
        ".134", // 0.134
        "123.", // 123.0
        // 음수
        "-101010",
        "-1010.10",
        "-1,000.023",
        // 특수 숫자 섞일 경우
        "123१२३۱۲۳",
        "123१२३.۱۲۳",
        "123,१२३.۱۲۳"
    ]
    let invaildAmounts = [
        "0.0.1",
        "10️⃣0️⃣",
        "loo",
        "lzoo",
        "123.456,789"
    ]
    
    func testValueToNumber() throws {
        for vaild in vaildAmounts {
            let formatter = LocalizedNumberFormatter(from: locale_enUS, to: locale_enUS)
            XCTAssertNoThrow(try formatter.number(from: vaild))
        }
        for invaild in invaildAmounts {
            let formatter = LocalizedNumberFormatter(from: locale_enUS, to: locale_enUS)
            XCTAssertThrowsError(try formatter.number(from: invaild))
        }
    }
    
    let vaildAmountLocalizedStrings_enUS: [String: String] = [
        "1234": "1,234",
        "1,234": "1,234",
        "1.2": "1.2",
        "1,200.13": "1,200.13",
        // 0으로 처리
        "": "0",
        ".": "0",
        //first or last가 decimalSeparator면 0 처리
        ".134": "0.134",
        "123.": "123",
        // 음수
        "-101010": "-101,010",
        "-1010.10": "-1,010.1",
        "-1,000.023": "-1,000.023",
        // 특수 숫자 섞일 경우
        "123१२३۱۲۳": "123,123,123",
        "123१२३.۱۲۳": "123,123.123",
        "123,१२३.۱۲۳": "123,123.123"
    ]
    
    func testToLocalizedNumberString() throws {
        for vaildAmount in vaildAmounts {
            let formatter = LocalizedNumberFormatter(from: .current, to: .current)
            let localizedString = try formatter.localizedNumberString(from: vaildAmount, style: .decimal)
            XCTAssertEqual(vaildAmountLocalizedStrings_enUS[vaildAmount] ?? "", localizedString)
        }
        for invaildAmount in invaildAmounts {
            let formatter = LocalizedNumberFormatter(from: .current, to: .current)
            XCTAssertThrowsError(try formatter.localizedNumberString(from: invaildAmount, style: .decimal))
        }
        
    }
    
    func toNumber(value: String) throws -> NSNumber {
        numberFormatter.locale = locale_enUS
        let splitValues: (integer: String, decimal: String) = try splitToDecimal(to: locale_enUS, value: value)
        let integerValue: String = splitValues.integer.isEmpty
            ? "0" // ".12" -> "0.12"
            : splitValues.integer.replacingOccurrences(of: numberFormatter.groupingSeparator, with: "")
        let decimalValue: String = splitValues.decimal.isEmpty
            ? "0" // "1." -> "1.0"
            : splitValues.decimal
        guard false == (integerValue.contains(numberFormatter.decimalSeparator))
                && false == (decimalValue.contains(numberFormatter.groupingSeparator)) else {
            log.error(message: " value : \(value)\n - result : \(LocalizedNumberFormatterError.reverseSeparator)")
            throw LocalizedNumberFormatterError.reverseSeparator
        }
        guard let result = numberFormatter.number(from: integerValue + numberFormatter.decimalSeparator + decimalValue) else {
            log.error(message: " value : \(value)\n - result : \(LocalizedNumberFormatterError.invaildCharacters)")
            throw LocalizedNumberFormatterError.invaildCharacters
        }
        log.info(message: " value : \(value)\n - result : \(result)")
        return result
    }
    
    private func splitToDecimal(to locale: Locale, value: String) throws -> (integer: String, decimal: String) {
        guard false == value.isEmpty && "." != value else {
            return (integer: "0", decimal: "0")
        }
        let numberFormatter: NumberFormatter = NumberFormatter()
        numberFormatter.locale = locale
        let splitedValues: [String] = value.components(separatedBy: numberFormatter.decimalSeparator)
        guard splitedValues.count <= 2 else { // DecimalSeparator 두개 이상이면 에러
            log.error(message: " - value: \(value)\n - error: \(LocalizedNumberFormatterError.moreThanOneDecimalSeparator)")
            throw LocalizedNumberFormatterError.moreThanOneDecimalSeparator
        }
        if 1 == splitedValues.count {
            let result = (integer: splitedValues.first ?? "", decimal: "")
            return result
        }
        guard 2 == splitedValues.count else {
            // 유효하지 않은 포맷
            log.error(message: " - value: \(value)\n - error: \(LocalizedNumberFormatterError.unknown)")
            throw LocalizedNumberFormatterError.unknown
        }
        let integerValue = splitedValues.first!.replacingOccurrences(of: numberFormatter.groupingSeparator, with: "")
        let decimalValue = splitedValues.last!
        return (integer: integerValue, decimal: decimalValue)
    }
    
    
}

이렇게 LocalizedNumberFomatter가 완성되었습니다.
다음 편에서는 LocalizedNumberFomatter 이용한 예제에 대해 포스팅하겠습니다.


🔜 .. 5편에서 계속 .. 🔜

profile
iOS Developer

0개의 댓글