iOS) 복구가능한 계산기 어플리케이션 만들기 (1) :: CushyCalculator

2021. 8. 8. 02:17iOS

 

이전에 만들었던 어플리케이션을 회고하는겸 해서 리뷰해보려고 한다. 이 어플리케이션은 기본 계산기 기능 외에도 특별한 기능이 들어가 있는데 바로 삭제된 값을 복구하는 기능이다. 전체 코드는 https://github.com/naldal/CushyCalculator에 있다.

 

사용기술은 특별한것 없이 Storyboard, AutoLayout, Delegate 만으로 만들었다.

 

 

1. 기본 레이아웃 짜기

가장먼저 할것은 스토리보드로 레이아웃을 짜는거다.

 

왼쪽의 뷰컨트롤러는 계산기 기능을 수행할 화면이고, 오른쪽의 화면은 삭제했을때의 값들이 표시될 테이블뷰 화면화면이다.

버튼을 레이아웃하는데 있어서 힘들었던 점은 처음엔 일일히 하나하나 Constraint를 잡아가며 위치를 교정해주어야 했던것인데 나중에 스택뷰의 존재를 알게되면서 2시간동안 삽질한것을 20분만에 완성시켰던 기억이 난다.

 

 

버튼을 총 5개의 스택뷰로 만들고 적절하게 Constraint 만 넣어주면 기본 버튼 레이아웃 구현은 끝난다. 숫자가 표시된 입력필드도 넣어 모든 화면에서 알맞게 레이아웃이 됐는지 확인한다.

 


 

 

 

전체 프로젝트 구조는 이렇다. 여기에서 CustomViewController는 계산기 화면을, ModalViewController는 복구값 테이블 화면을 담당한다. 따라서 계산기능은 CustomViewController에서 구현한다.

 

 

2.1 계산기능 구현하기 - 숫자 버튼 누르기

먼저 숫자버튼만 눌렀을 때의 함수를 만들었다. 여기서는 따로 Target Action 을 코드로 구현하지 않고 클릭&드래그로 버튼과 함수를 이었다. 이제 0~9 버튼을 눌렀을 때 pressedButton이 호출될 것이다. 

 

전체 코드를 보자. 설명상 불필요한 코드는 '...' 으로 생략했다.

 

import UIKit

class CustomViewController: UIViewController {
    
    @IBOutlet weak var workField: UILabel!
    
    @IBOutlet var buttons: [UIButton]!
    
    ...
    var reloadTextField = false
    var isDotContains = false
    let InitialCalculator: String = "0"
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        workField.adjustsFontSizeToFitWidth = true
        workField.text = InitialCalculator
        
        ...
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        ...
    }
    
    @IBAction func pressedButton(_ sender: UIButton) {
        if let numberPad = sender.currentTitle {
            if numberPad == "." {
                guard isDotContains == false else {
                    return
                }
                isDotContains = true
            }
            if reloadTextField {
                workField.text! = String(numberPad)
                reloadTextField = false
                return
            }
            let currnetField = workField.text!
            if currnetField != InitialCalculator {
                workField.text! += String(numberPad)
            } else {
                if numberPad == "." {
                    workField.text! += String(numberPad)
                } else {
                    workField.text! = String(numberPad)
                }
            }
        }
    }
}

 

CustomViewController에서 처음실행될 viewDidLoad() 에서는 계산기 결과 값에 표시될 "0" 을 보여준다.

 

이제 본격적으로 pressedButton(sender:)를 보자. 숫자버튼을 tap할 때마다 sender에는 누른 버튼의 UIButton 인스턴스가 들어온다. 이때 해당 숫자의 currentTitle은 Optional이므로 이를 옵셔널 바인딩하여 numberPad라는 변수에 할당한다. 그런데 이때 소숫점을 위한 "." (이하 소숫점 표시)이 들어올 수도 있다.

 

소숫점 표시에 대해서는 두 가지 예외상황에 대처해야 한다. 

1. 소숫점 표시은 두개 이상 화면에 표시될 수 없다.

2. 현재 화면에 표시된 값이 아무것도 없을 때 누르면 아무런 표시도 하지 않아야 한다.

 

따라서 현재 화면에 소숫점 표시가 이미 존재한다면 isDotContains를 true 처리한다.

 

if numberPad == "." {
  guard isDotContains == false else {
  	return
  }
  isDotContains = true
}

코드를 보면 numberPad가 소숫점 표시이고, isDotContains false가 아니라면 (true 라면) 즉시 함수를 종료해야 한다. 반대로 isDotContains 가 false 라면 isDotContains를 true 처리한다.

 

 

if reloadTextField {
  workField.text! = String(numberPad)
  reloadTextField = false
  return
}

reloadTextField 는 나중에 사칙연산 버튼을 누르고 난 후 첫번째 숫자임을 판별할 때 쓰인다.

 

 

지금은 그냥 못본척 넘어가자..

 

let currnetField = workField.text!
if currnetField != InitialCalculator {
    workField.text! += String(numberPad)
} else {
    if numberPad == "." {
        workField.text! += String(numberPad)
    } else {
        workField.text! = String(numberPad)
    }
}

숫자버튼 파트에서 제일 중요한 부분이다. 먼저 현재 화면에 표시된 값을 currentField에 할당하고 이것이 "0"이 아니라면 (앞서 표시된 숫자가 있다면) 현재 화면에 표시된 숫자뒤에 새롭게 입력한 숫자를 붙힌다. 만약에 현재 표시된 숫자가 없이 화면에 "0"만 떠있다면, 입력된 버튼이 소숫점 표시일 때 "0." 으로 만들어야 하므로 현재 표시된 숫자(0)에 소숫점 표시를 붙힌다. 소숫점 표시가 아니라면, 0대신 입력된 숫자로 표시한다.

 

 

2.2 계산기능 구현하기 - 사칙연산버튼 누르기

사실상 이 어플리케이션의 가장 핵심이면서 가장 복잡한 부분이다. 

 

class CustomViewController: UIViewController {
    
    @IBOutlet weak var workField: UILabel!
    
    @IBOutlet var buttons: [UIButton]!
    
    var operStack: [String] = []
    var result: Int = 0
    var operation = ""
    var reloadTextField = false
    var isDotContains = false
    let InitialCalculator: String = "0"
    
    
    ...
    
    
    @IBAction func pressedButton(_ sender: UIButton) {
        ...
    }
    

@IBAction func operatorButton(_ sender: UIButton) {
        
    if reloadTextField {
        return
    }
    guard let operType = sender.restorationIdentifier else {
        return
    }
    
    reloadTextField = true
    let numberInField = workField.text!

    if operStack.count == 1 {
        operStack.append(operType)
        return
    }
    
    if operStack.isEmpty {
        operStack.append(numberInField)
        operStack.append(operType)
    } else {
        operStack.append(numberInField)
        
        guard let first = Double(operStack[0]) else {
            return
        }
        guard let second = Double(operStack[2]) else {
            return
        }
        let middleOperator = operStack[1]
        
        var result: Double = Double(InitialCalculator)!
        switch middleOperator {
        case "plus":
            result = first + second
        case "minus":
            result = first - second
        case "multi":
            result = first * second
        case "divide":
            result = first / second
        default:
            break
        }
        
        var finRes: String = String(result)
        if result == ceil(result) {
            finRes.removeSubrange(finRes.firstIndex(of: ".")!..<finRes.endIndex)
        }
        if operType == "equalSign" {
            workField.text = finRes
            operStack.removeAll()
            operStack.append(workField.text!)
            reloadTextField = false
            return
        } else if operType != "equalSign" {
            operStack[1] = operType
        }
        
        workField.text = finRes
        operStack[0] = finRes
    
        operStack.removeLast()
    }
}

 

코드가 너무 기니까 하나하나씩 뜯어보자

 

if reloadTextField {
    return
}
guard let operType = sender.restorationIdentifier else {
    return
}

reloadTextField = true
let numberInField = workField.text!

if operStack.count == 1 {
    operStack.append(operType)
    return
}

reloadTextField의 주 목적은 operator Button(사칙연산 버튼들)이 중복되어 함수가 실행되지 않게 막아주는 역할을 한다. 

 

이렇게 되면 안되니까

 

그래서 reloadTextField가 false일 때만 일단 이 함수에 접근할 수 있다.

 

그 다음으로는 sender.restorationIdentifier가 보이는데 이것은 Identity Inspector에 설정한 Restoration ID를 의미한다. 

UIView.restorationIdentifier은 Optional 이기 때문에 옵셔널 바인딩을 통해서 operType에 값을 할당해줬다.

 

 

다음으로는 workField에 있는 값들 (화면에 표시된 값들)을 변수에 할당해주는데 사칙연산시 계산될 값은 항상 불변이므로 let으로 선언한다. 

 

이제 할것은 실제로 숫자와 연산자를 이용한 계산 프로세스다.

계산기에서 할 수 있는 계산 시나리오는 다음과 같이 있다.

 

1. 어떤 숫자A를 입력하고 연산자 A를 누르고 다시 다른 숫자 B를 입력하고 결과(=) 버튼을 누르면 숫자 A + 연산자 A + 숫자 B의 결과가 화면에 표시되어야 한다. 이때 스택에는 이에 대한 결과값만 남아있어야 한다.

 

2. 어떤 숫자 A를 입력하고 연산자 A을 누르고 다시 다른 숫자 B를 입력하고 다시 다른 연산자 B를 누를 때 숫자A + 연산자 A + 숫자B의 계산이 되어야 하고, 이것의 결과값을 숫자 C라고 하자 (이때 숫자 C는 화면에 표시). 이때 기존에 스택에 쌓여있던 숫자 A, 연산자 A, 숫자 B의 값은 지워지고, 숫자 C와 연산자 B만 스택에 남는다.

 

 

 

if operStack.count == 1 {
    operStack.append(operType)
    return
}

위 그림에서의 스택을 operStack 이라고 했을 때, operStack에 숫자 하나만 있는 상황에서만 연산자가 "계산되지 않고 operStack에 추가" 되어야 하므로 연산자를 operStack에 추가하고 함수를 종료한다.

 

만약에 위 if문이 없다면 

이렇게 다짜고짜 연산자가 operStack에 들어갈 수 있으므로 반드시 필요하다.

 

 

 

이번에도 분량조절에 실패했으므로 다음 포스팅에 마저 쓰도록 하겠다. ㅠㅠ