본문 바로가기
👨‍💻 프로그래밍/🍎 iOS 개발

🍎 SwiftUI에서 숫자 입력하면 실시간으로 구분자(,) 붙이기

by 개발자 진개미 2024. 7. 21.
반응형

하려는 것

  • 숫자가 길어지면 읽기가 힘듭니다.
  • 10000000001,000,000,000가 있다면 전자는 일십백천만... 하면서 1개씩 세야 겠지만 숫자를 자주 다루신 분들이면 후자가 10억이라는걸 바로 알 수 있습니다.
  • ,를 붙여서 보여주는건 간단합니다. 문제는 유저가 숫자를 입력할 때 입니다. 입력할 때는 어떻게 ,를 넣을 수 있을까요?


문제를 나눠서 생각해 보자

  • 우선, 유저가 입력하는 값은 모두 숫자일지 모르겠지만 보여야 되는 값은 숫자 뿐만 아니라 ,를 포함하기 때문에 문자입니다.
  • 가장 단순한 해결책은 유저가 보는 값을 저장하는 변수와 유저가 입력하는 값을 저장하는 변수를 따로 두고, 각자 업데이트를 하면 됩니다. 유저가 입력하면 입력값 저장하는 변수 업데이트 -> 필요하면 , 삽입  후 유저가 보는 값 업데이트
  • 하지만 잘 생각해 보면 굳이 따로 변수를 둘 필요는 없습니다. 유저가 입력하자 마자 ,를 삽입하는 동작을 하면 변수 1개로 충분합니다. 바로 바로 업데이트 한다면 ,는 맨 뒤에 올 수 없기 때문에 유저가 ,를 지울 일이 없습니다.
  • 이를 간단히 코드로 나타내면 아래과 같습니다.
@State private var number: String = ""

TextField("", text: $number)
	.keyboardType(.decimalPad)
	.onChange(of: number) { number = number.formattedAsCommaSeparatedNumber }
	
extension String {
    var formattedAsCommaSeparatedNumber: String {
        let digitsOnly = self.filter { $0.isNumber }
        
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        numberFormatter.groupingSeparator = ","
        
        if let number = Int(digitsOnly) {
            return numberFormatter.string(from: NSNumber(value: number)) ?? ""
        }
        
        return self
    }
}
  • 우선 유저의 입력값을 저장할 변수 number를 선언했습니다. number는 값의 변화에 따라 UI가 편화해야 하니 @State로 선언했습니다. 
  • 간단한 TextField를 선언하고, 숫자만 입력할 수 있게 하기 위해 .keyboardType(.decimalPad) 옵션을 줬습니다. 이렇게 하면 일반적인 키보드 대신 아래와 같이 숫자만 입력할 수 있는 키보드가 됩니다. 주의할 점은 이렇게 한다고 숫자만 입력할 수 있는건 아니라는 겁니다. 키보드를 연결하면 .keyboardType에 상관없이 문자도 입력 가능합니다.

  • 문자가 입력될 때를 대비해서 digitsOnly에 숫자만 남기게 했습니다. 이건 ,를 제거하고자 하는 의도도 있지만, 숫자 외의 문자가 입력되면 값이 그냥 무시 돼 버립니다. (입력하자마자 삭제됨)
  • 다음으로 3번째 자리마다 ,를 삽입해야 하는데 직접 구현해도 되지만 NumberFormatter()를 사용해 구현했습니다.
  • 이 함수는 .onChange에서 number가 바뀔 때 마다 호출됩니다.

소수점도 고려하자

  • 저 구현은 소수점이 없을때는 잘 동작하지만 소수점이 등장하면 좀 복잡해 집니다. 아래 코드에서 소수점의 .이 걸리기 때문에 조금 다른 방식으로 접근해야 합니다.
let digitsOnly = self.filter { $0.isNumber }

 

  • 일단 정수 부분소수점 부분을 나눠서 생각해 봅시다! 소수점 부분은 ,가 필요 없습니다. 즉, 정수 부분은 기존과 똑같은 로직을 적용하고, 소수점 부분은 그래로 붙이면 됩니다.
var formatted: String {
    let parts = self.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
    let integerPart = parts.first?.filter { $0.isNumber } ?? ""
    let decimalPart = (parts.count > 1 ? parts.last?.filter { $0.isNumber } : nil) ?? ""

    let numberFormatter = NumberFormatter()
    numberFormatter.numberStyle = .decimal
    numberFormatter.groupingSeparator = ","

    if let number = Int(integerPart) {
        let integerPartFormatted = numberFormatter.string(from: NSNumber(value: number)) ?? ""
        return "\(integerPartFormatted)\(self.contains(".") ? "." : "")\(decimalPart)"
    }

    return self
}
  • parts.을 기준으로 앞부분, 뒷부분을 저장하고 앞부분은 정수 부분 (integerPart), 뒷부분은 소수점 부분(decimalPart)로 나눴습니다.
  • 기존과 똑같이 numberFormatter를 사용해서 ,를 넣어주고 소수점 부분은 그대로 뒤에 붙여 줍니다. 정수 부분 뒤에 원래 문자에 .이 있으면 .을 1개 넣고 없으면 빈 문자열을 넣습니다. 이렇게 하면 유저가 ..을 입력했을 때 .이 들어 있어서 contains가 true지만 true여도 .은 1개만 넣기 때문에 .이 여러개인 이상한 숫자가 들어가지 않습니다.

리팩토링?

이건 굉장히 자주 쓸 거 같아서 바로 분리해서 리팩토링 했습니다.

struct NumberField: View {
    var label: String
    @Binding var number: String
    
    init(_ label: String, number: Binding<String>) {
        self.label = label
        self._number = number
    }
    
    var body: some View {
        TextField(label, text: $number)
            .keyboardType(.decimalPad)
            .onChange(of: number) {
                number = number.formatted
            }
    }
}

extension String {
    var formatted: String {
        let parts = self.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
        let integerPart = parts.first?.filter { $0.isNumber } ?? ""
        let decimalPart = (parts.count > 1 ? parts.last?.filter { $0.isNumber } : nil) ?? ""
        
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .decimal
        numberFormatter.groupingSeparator = ","
        
        if let number = Double(integerPart) {
            let integerPartFormatted = numberFormatter.string(from: NSNumber(value: number)) ?? ""
            return "\(integerPartFormatted)\(self.contains(".") ? "." : "")\(decimalPart)"
        }
        
        return self
    }
    
    func toDouble() -> Double {
        return Double(self.replacingOccurrences(of: ",", with: "")) ?? 0.0
    }
}
  • SwiftUI에서 기본 제공되는 View들의 이름이 TextField, SecureField 등이여서 비슷하게 NumberField로 했습니다. 이런 사소한 이름이 별로 중요해 보이지 않지만 인지 부하를 줄이는데 매우 도움이 됩니다.
  • Parameter의 이름들도 TextField와 똑같이 label, number로 했고 label은 unnamed parameter로 처리했습니다.
  • 저는 종류별로 따로 모아 놓는 것 보다 (String Extension을 따로 놓는다거나) 관련 있는 것들 끼리 모아 놓는걸 좋아해서 ,를 삽입하는데 필요한 함수,가 삽입된 String을 다시 Double로 변환 해 주는 함수를 같은 파일에 놨습니다.

 

반응형