Project/ReceiptMind

🧾 계산기 구현하기

밤새는 탐험가89 2025. 7. 17. 23:06
import UIKit

final class AddAmountViewController: UIViewController {
    
    // MARK: - Variable
    // 계산한 값을 전달할 클로저
    var onAmountSelected: ((Int) -> Void)?
    private var rawExpression: String = "" {
        didSet {
            updateDisplay()
        }
    }
    
    
    // MARK: - UI Component
    private let displayLabel: UILabel = {
        let label = UILabel()
        label.font = .monospacedDigitSystemFont(ofSize: 28, weight: .bold)
        label.textColor = .label
        label.textAlignment = .right
        label.text = "₩ 0 원"
        label.numberOfLines = 1
        return label
    }()
    
    private let keypadRows: [[String]] = [
        ["(", ")", "⌫", "C"],
        ["7", "8", "9", "÷"],
        ["4", "5", "6", "×"],
        ["1", "2", "3", "−"],
        ["0", ".", "=", "+"],
        ["완료"]
    ]
    
    private lazy var keypadStack: UIStackView = {
        let stack = UIStackView()
        stack.axis = .vertical
        stack.spacing = 8
        stack.translatesAutoresizingMaskIntoConstraints = false
        return stack
    }()
    
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .secondarySystemBackground
        setupUI()
        generateButtons()
    }
    
    private func setupUI() {
        view.addSubview(displayLabel)
        view.addSubview(keypadStack)
        
        displayLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            displayLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            displayLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            displayLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            
            keypadStack.topAnchor.constraint(equalTo: displayLabel.bottomAnchor, constant: 24),
            keypadStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            keypadStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }
    
    private func generateButtons() {
        for row in keypadRows {
            let hStack = UIStackView()
            hStack.axis = .horizontal
            hStack.spacing = 8
            hStack.distribution = .fillEqually
            
            for key in row {
                let button = UIButton(type: .system)
                button.setTitle(key, for: .normal)
                button.setTitleColor(.label, for: .normal)
                button.titleLabel?.font = .systemFont(ofSize: 20, weight: .semibold)
                button.layer.cornerRadius = 8
                button.backgroundColor = .secondarySystemFill
                button.heightAnchor.constraint(equalToConstant: 48).isActive = true
                button.addTarget(self, action: #selector(keyPressed(_:)), for: .touchUpInside)
                hStack.addArrangedSubview(button)
            }
            
            keypadStack.addArrangedSubview(hStack)
        }
    }
    
    private func updateDisplay() {
        let formatted = formatExpression(rawExpression)
        displayLabel.text = formatted
    }
    
    // 입력값: 계산기 또는 금액 입력 필드로부터 받은 String  (예: "123456", "123×2" 등)
    // 반환값: 사용자에게 보여줄 포맷된 문자열 (예: "₩ 123,456 원")
    private func formatExpression(_ input: String) -> String {
        
        // 계산식 입력 시 사용되는 기호들을 Swift가 인식 가능한 연산자로 변환 (예: "123×2" → "123*2")
        let sanitized = input
            .replacingOccurrences(of: "×", with: "*")
            .replacingOccurrences(of: "÷", with: "/")
            .replacingOccurrences(of: "−", with: "-")
        
        // Double(sanitized)로 입력값이 숫자일 경우에만 포맷을 적용
        // 단, +, -, *, / 등의 연산자가 입력값에 포함되지 않은 경우에만 해당
        // 사용자가 "123456"처럼 단순 숫자를 입력했을 때만 처리
        // "123×2" 같이 수식이 포함되면 포맷하지 않음
        if let result = Double(sanitized),
           !input.contains("+") && !input.contains("-") &&
            !input.contains("*") && !input.contains("/") {
            
            // 세 자리마다 쉼표(,)를 추가한 문자열로 변환
            // 예: 123456 → "₩ 123,456 원"
            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            let numberText = formatter.string(from: NSNumber(value: result)) ?? "0"
            return "₩ \(numberText) 원"
        }
        
        // 계산식 등 숫자가 아닐 경우 그대로 반환
        return input
    }
    
    
    // 입력값: 계산식 문자열 (예: "1,200 + 300", "₩ 1,000 × 3 원", "((200+300)×2)")
    // 반환값: 계산된 결과를 Double로 리턴 (예: 1500.0)
    func evaluateExpression(from rawExpression: String) -> Double {
        
        // 입력된 문자열을 계산 가능한 형태로 정리
        // "₩", "원", ",", " " → 모두 제거 (숫자 계산에 필요 없는 것들)
        // 예: "₩ 1,000 × 3 원" → "1000*3" / "2,000 − 500" → "2000-500"
        let sanitized = rawExpression
            .replacingOccurrences(of: "×", with: "*")
            .replacingOccurrences(of: "x", with: "*")
            .replacingOccurrences(of: "÷", with: "/")
            .replacingOccurrences(of: "−", with: "-")
            .replacingOccurrences(of: "₩", with: "")
            .replacingOccurrences(of: ",", with: "")
            .replacingOccurrences(of: "원", with: "")
            .replacingOccurrences(of: " ", with: "")

        
        // 빈 문자열은 바로 0 리턴 (예: 입력: "" 또는 "₩ 원" → 출력: 0)
        guard !sanitized.isEmpty else {
            print("❌ 빈 수식")
            return 0
        }
        

        // 괄호 쌍 검사 (오류 방지용)
        // 괄호 쌍이 맞지 않으면 계산 불가능하므로 0 리턴 (예: "((200+300)*2" → ❌)
        let openCount = sanitized.filter { $0 == "(" }.count
        let closeCount = sanitized.filter { $0 == ")" }.count
        guard openCount == closeCount else {
            print("❌ 괄호 쌍 불일치: '\(sanitized)'")
            return 0
        }
        

        // NSExpression으로 계산 시도
        // 성공 시 NSNumber 타입으로 결과 반환 → Double로 변환
        // 실패 시 0 리턴
        // 예: "1000+200*3" → 1600.0 / "2000/(2+3)" → 400.0
        do {
            let expression = NSExpression(format: sanitized)
            if let result = expression.expressionValue(with: nil, context: nil) as? NSNumber {
                return result.doubleValue
            } else {
                print("❌ 계산 실패: \(sanitized)")
                return 0
            }
        } catch {
            print("❌ NSExpression 예외: \(error.localizedDescription)")
            return 0
        }
    }


    
    // MARK: - Action Method
    // 키패드 버튼이 눌렸을 때 실행됨
    // 버튼의 title(for: .normal)을 가져와 어떤 키가 눌렸는지 판단
    @objc private func keyPressed(_ sender: UIButton) {
        
        
        // 버튼에 표시된 글자를 가져옴 (예: "1", "+", "=", "C", "⌫" 등)
        // 값이 없으면 아무것도 하지 않음 (return)
        guard let key = sender.title(for: .normal) else { return }

        switch key {
            
        // 입력된 계산식 전부 지움
        case "C":
            rawExpression = ""

        // 비어있지 않으면 마지막 글자 삭제 (예: 예: "1200+" → "1200")
        case "⌫":
            if !rawExpression.isEmpty {
                rawExpression.removeLast()
            }

        // 수식 계산
        // 핵심 포인트: guard !rawExpression.isEmpty else { return } ← ✅ 이 부분이 앱 강제 종료 방지 핵심
        case "=":
            guard !rawExpression.isEmpty else { return }
            let result = evaluateExpression(from: rawExpression)
            rawExpression = result == 0 ? "" : String(result)

        // 값 전달 및 종료
        // 수식이 비어있으면 0 전달하고 dismiss
        // 그렇지 않으면 계산된 결과(Int) 전달하고 dismiss
        // 예: rawExpression = "1000+500" → 결과 1500 → onAmountSelected?(1500)
        case "완료":
            guard !rawExpression.isEmpty else {
                onAmountSelected?(0)
                dismiss(animated: true)
                return
            }
            let result = evaluateExpression(from: rawExpression)
            onAmountSelected?(Int(result))
            dismiss(animated: true)

        default:
            rawExpression += key
        }

        updateDisplay()
    }

}

 

✅ AddAmountViewController 를 호출하는 상위 뷰인 AddTransactionViewController에서 구현 

extension AddTransactionViewController: AddCustomCellDelegate {
    func valueLabelTapped(in cell: AddCustomCell) {
        guard let indexPath = addTableView.indexPath(for: cell),
              let section = AddSection(rawValue: indexPath.section) else { return }
        
        selectedIndexPath = indexPath
        
        switch section {
        case .date:
            print("날짜 valueLabel 눌림")
            presentCalendarPicker()
        case .amount:
            print("금액 valueLabel 눌림")
            presentAmountCalculator()
        case .category:
            print("분류 valueLabel 눌림")
        case .memo:
            print("메모 valueLabel 눌림")
        default:
            print("기타 valueLabel 눌림")
        }
        
        // ✅ 선택 효과를 위해 셀 리로드
        self.addTableView.reloadRows(at: [indexPath], with: .none)
        
    }
    
...
private func presentAmountCalculator() {
    let amountVC = AddAmountViewController()
    amountVC.modalPresentationStyle = .pageSheet

    amountVC.onAmountSelected = { [weak self] amount in
        guard let self = self else { return }
        self.selectedAmount = amount

        // 금액 셀 업데이트
        self.addTableView.reloadRows(
            at: [IndexPath(row: 0, section: AddSection.amount.rawValue)],
            with: .none
        )
    }

    if let sheet = amountVC.sheetPresentationController {
        sheet.detents = [.medium()]
        sheet.prefersGrabberVisible = true
    }

    present(amountVC, animated: true)
}

 

360