일주일동안 iOS 스토리보드UI와 UIkit codebase를 사용해서 계산기앱을 만들었다.
일단 제공된 스토리보드UI랑 코드베이스 강의를 보면서 만들었지만 강의내용에서 부족한부분이 많다고 생각이되서 머리 박으면서 검색과 공식문서를 통해서 겨우겨우 만들었지만 이것저것 억까들이 많은 그런 일주일이였다.
맨처음 Snapkit을 설치를 받았지만 import Snapkit이 인식을 못해서 확인을 해보니 설치 과정에서 SnapKit, SnapKit-Dynamic 두가지 항목이 있는데 둘다 None으로 체크를 하고 설치를 못해서 다운은 했지만 프로젝트 자체에서 인식을 못한 상황이 발생했다.
원인을 파악하고 삭제후 재 설치를 했지만 이번에는 설치하고 인식까지는 했지만 Snapkit 기능들을 입력하니 오류가 발생했었다.
해당 원인은 SnapKit, SnapKit-Dynamic 두가지 항목을 다 설치받아서 발생했었던 오류로 정적, 동적 프로그램 및 프로젝트냐에 맞춰서 설치해야되는데 무지성으로 둘다 설치해버려서 발생한 문제였었다.
과제를 하던 도중 발생한 오류로 해당 에러는 view.add를 제대로 선언을 안해서 발생한 오류였었다.
실제로 과제를 하면서 Lv2까지는 별 문제가 없이 잘 진행 했었는데 Lv3항목을 진행하면서 제대로 항목 추가를 안해서 발생한 오류였었다.
생각보다 누락, 혹은 잘못된 위치에 선언해서 해당 오류가 많이 발생한 상황으로 보이며 다음부터는 add view를 더 신경써서 만들어야겠다고 다시 깨달았다.
일단 어찌저찌 Lv8까지 다 만들었지만 기능적으로 아쉬운 부분들, 혹은 개선사항들이 많이 보여서 Lv9이라는 자체 항목을 만들어서 부족한 부분들을 한번 메꿔보자고 생각해서 따로 정리를하고 혼자서 개선해보기로했다.
먼저 수식기호(+, -, *, /)를 두번 터치하거나 아무것도 없는데 수식기호 버튼을 터치 시에 프로젝트 디버그가 걸리면서 프리뷰, 시뮬레이터 둘다 멈추는 상황을 개선하고자 고민을 했었었다.
먼저 함수로 마지막 문자가 연산자인지 확인하는 메서드를 만든다음
각 연산자 버튼에 else if 문을 통해서 숫자가 들어간경우 / 연산자가 들어간경우를 구별해서 코드를 만들었다. 그 과정에서 만약 정상적인 계산기 입력 방식이 아닌경우 에러를 출력하게 만들었다.
하단의 코드는
// MARK: - Helper Methods
/// - Returns: 마지막 문자가 연산자이면 true, 아니면 false
/// - 체크하는 연산자: +, -, *, /
/// - 사용 용도:
/// 1. 연속된 연산자 입력 방지
/// 2. 수식 마지막이 연산자인 상태에서 계산 방지
private func isLastCharacterOperator() -> Bool {
let operators = ["+", "-", "*", "/"] // 유효한 연산자 목록
// firstNumber의 마지막 문자를 String으로 변환
// 문자열이 비어있으면 false 반환
guard let lastChar = firstNumber.last.map(String.init) else { return false }
// 마지막 문자가 연산자 배열에 포함되어 있는지 확인
return operators.contains(lastChar)
}
// MARK: - Operator Button Actions
/// 더하기(+) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 첫 입력이 연산자인 경우 에러
/// 2. 연속된 연산자 입력 시 에러
/// 3. 정상적인 경우 수식에 + 추가
@objc
private func plusButtonTapped() {
if firstNumber == "0" { // 첫 입력이 연산자인 경우
firstNumber = "+"
label.text = "error"
} else if isLastCharacterOperator() { // 이전 입력이 연산자인 경우
label.text = "error" // 에러 메시지 표시
} else { // 정상적인 입력인 경우
firstNumber += "+" // 수식에 더하기 연산자 추가
label.text = "\(firstNumber)" // 화면 업데이트
isNewCalculation = false // 계산 진행 중 상태로 설정
}
}
계산기 사용 후 자동으로 새 연산, 혹은 기존 연산을 이어가는 기능을 만드록 싶어서 숫자를 새로 입력시에 자동으로 초기화 되는 기능들을 구현할려고 했다.
하단에는 작성한 해당 코드 부분과 주석을 함께 적었다.
/// 계산기에 표시되는 현재 숫자 또는 수식을 저장하는 변수
private var firstNumber = "0"
/// 새로운 계산 시작 여부를 판단하는 플래그
/// true: 계산 결과가 표시된 직후 상태로, 새로운 숫자 입력 시 초기화 필요
/// false: 계산 진행 중인 상태
private var isNewCalculation = false
/// 숫자 버튼 입력을 처리하는 공통 메서드
/// - Parameter number: 입력된 숫자 문자열
/// - Note:
/// 1. 초기 상태(firstNumber가 "0")이거나
/// 2. 새로운 계산 시작(isNewCalculation이 true)인 경우
/// -> 입력된 숫자로 완전히 대체
/// 3. 그 외의 경우 -> 기존 숫자에 이어서 추가
private func handleNumberInput(_ number: String) {
if firstNumber == "0" || isNewCalculation {
firstNumber = number // 새로운 숫자로 완전히 대체
isNewCalculation = false // 계산 진행 중 상태로 변경
} else {
firstNumber += number // 기존 숫자에 이어붙이기
}
label.text = "\(firstNumber)" // 화면 업데이트
}
/// 등호(=) 버튼 동작을 처리하는 메서드
@objc
private func equalButtonTapped() {
if firstNumber == "0" {
label.text = "error"
} else if isLastCharacterOperator() {
label.text = "error"
} else {
if let result = calculate(expression: firstNumber) {
label.text = "\(result)"
firstNumber = "\(result)"
isNewCalculation = true // 계산 완료 후 새로운 계산 시작을 위해 플래그를 true로 설정
} else {
label.text = "error"
}
}
}
숫자가 커지면 9자리 초과분부터는 갑자기 뒤에는 ...으로 출력되는 현상을 확인하고 개인적으로 꼴보기 싫어서 구글링을 통해서 UILabel내에서 글씨가 늘어나면 라벨 내부의 텍스트 크기를 줄이는 기능을 찾아서 적용시켰다.
label.adjustsFontSizeToFitWidth 라는 설정을 통해서 자동으로 글씨크기를 조정가능하게 했으며
label.minimumScaleFactor 라는 설정을 통해서 글씨의 최소값을 고정시켜서 적용했다.
하단에는 해당 부분의 코드를 적어놨다.
private func calculatorUI() {
// 배경색 설정
view.backgroundColor = .black
// 레이블 설정
label.text = "\(firstNumber)"
label.textColor = .white
label.font = .boldSystemFont(ofSize: 60)
label.textAlignment = .right
label.numberOfLines = 1 // 한 줄로 표시
label.adjustsFontSizeToFitWidth = true // 글자 크기 자동 조절
label.minimumScaleFactor = 0.5 // 최소 글자 크기는 원본의 50%까지
결국 제출해야 하는과제지만 내가 임의로 변경 + 추가 적용한 기능들이 있어서 해당 부분들은 따로 Lv9이라는 이름으로 따로 추가 시켜서 해당 부분들을 적용시켰다.
사실 개선하고 싶은것들은 많지만 현재 내 배움의 깊이가 부족해서 완전히 다 적용을 못 시키는게 매우 안타깝게 생각이 들고있다. 그래도 이번주에 만든것중 개선하고 싶은 부분을 정리하자면
뭔가 엄청 더럽지만 어찌저찌 다 만들기는 했다만... 아쉽기도 하고 개선사항이 많이 있을것같은 코드인것같다.
하단은 만든 최종본을 올린다.
//
// ViewController.swift
// CodebaseCalculator
//
// Created by 유태호 on 11/14/24.
//
import UIKit
import SnapKit
class ViewController: UIViewController {
// MARK: - Properties
/// 계산기에 표시되는 현재 숫자 또는 수식을 저장하는 변수
/// 초기값은 "0"이며, 사용자 입력에 따라 숫자와 연산자가 추가됨
/// 예: "123+456", "789*2" 등의 형태로 저장
private var firstNumber = "0"
/// 새로운 계산 시작 여부를 판단하는 플래그
/// - true: 계산 결과가 표시된 직후 상태. 새로운 숫자 입력 시 현재 표시된 결과를 지우고 새로 시작
/// - false: 계산 진행 중인 상태. 입력된 숫자나 연산자를 기존 수식에 추가
private var isNewCalculation = false
// MARK: - UI Components
/// 계산 결과를 표시하는 레이블
/// - 텍스트 정렬: 우측
/// - 글자 크기: 자동 조절 (60pt에서 시작, 최소 30pt까지 자동 축소)
/// - 표시 내용: 현재 입력 중인 수식 또는 계산 결과
let label = UILabel()
// 첫번째 줄 버튼들 (7,8,9,+)
let plusButton = UIButton() // 더하기 연산자 버튼 (주황색)
let sevenButton = UIButton() // 숫자 7 버튼 (회색)
let eightButton = UIButton() // 숫자 8 버튼 (회색)
let nineButton = UIButton() // 숫자 9 버튼 (회색)
let stackView = UIStackView() // 7,8,9,+ 버튼을 가로로 배치하는 스택뷰
// 두번째 줄 버튼들
let fourButton = UIButton() // 숫자 4
let fiveButton = UIButton() // 숫자 5
let sixButton = UIButton() // 숫자 6
let minusButton = UIButton() // 빼기 연산자
let stackView1 = UIStackView() // 4,5,6,- 버튼을 가로로 배치하는 스택뷰
// 세번째 줄 버튼들
let oneButton = UIButton() // 숫자 1
let twoButton = UIButton() // 숫자 2
let threeButton = UIButton() // 숫자 3
let multplyButton = UIButton() // 곱하기 연산자
let stackView2 = UIStackView() // 1,2,3,* 버튼을 가로로 배치하는 스택뷰
// 네번째 줄 버튼들
let resetButton = UIButton() // 초기화(AC) 버튼
let zeroButton = UIButton() // 숫자 0
let equalButton = UIButton() // 등호(계산) 버튼
let dividButton = UIButton() // 나누기 연산자
let stackView3 = UIStackView() // AC,0,=,- 버튼을 가로로 배치하는 스택뷰
// MARK: - Lifecycle Methods
override func viewDidLoad() {
super.viewDidLoad()
calculatorUI()
}
// MARK: - UI Setup Methods
/// 계산기의 전체적인 UI를 설정하는 메서드
private func calculatorUI() {
// 배경색 설정
view.backgroundColor = .black
// 레이블 설정
label.text = "\(firstNumber)"
label.textColor = .white
label.font = .boldSystemFont(ofSize: 60)
label.textAlignment = .right
label.numberOfLines = 1 // 한 줄로 표시
label.adjustsFontSizeToFitWidth = true // 글자 크기 자동 조절
label.minimumScaleFactor = 0.5 // 최소 글자 크기는 원본의 50%까지
// 버튼 설정
colorButton(button: plusButton, title: "+")
setupButton(button: sevenButton, title: "7")
setupButton(button: eightButton, title: "8")
setupButton(button: nineButton, title: "9")
// 두번째 버튼 설정
setupButton(button: fourButton, title: "4")
setupButton(button: fiveButton, title: "5")
setupButton(button: sixButton, title: "6")
colorButton(button: minusButton, title: "-")
//세번째 버튼 설정
setupButton(button: oneButton, title: "1")
setupButton(button: twoButton, title: "2")
setupButton(button: threeButton, title: "3")
colorButton(button: multplyButton, title: "*")
//네번째 버튼 설정
colorButton(button: resetButton, title: "AC")
setupButton(button: zeroButton, title: "0")
colorButton(button: equalButton, title: "=")
colorButton(button: dividButton, title: "/")
// 1.스택뷰 설정
stackView.axis = .horizontal
stackView.spacing = 10
stackView.distribution = .fillEqually
stackView.addArrangedSubview(sevenButton)
stackView.addArrangedSubview(eightButton)
stackView.addArrangedSubview(nineButton)
stackView.addArrangedSubview(plusButton)
// 2.스택뷰 설정
stackView1.axis = .horizontal
stackView1.spacing = 10
stackView1.distribution = .fillEqually
stackView1.addArrangedSubview(fourButton)
stackView1.addArrangedSubview(fiveButton)
stackView1.addArrangedSubview(sixButton)
stackView1.addArrangedSubview(minusButton)
// 3.스택뷰 설정
stackView2.axis = .horizontal
stackView2.spacing = 10
stackView2.distribution = .fillEqually
stackView2.addArrangedSubview(oneButton)
stackView2.addArrangedSubview(twoButton)
stackView2.addArrangedSubview(threeButton)
stackView2.addArrangedSubview(multplyButton)
//4. 스택뷰 설정
stackView3.axis = .horizontal
stackView3.spacing = 10
stackView3.distribution = .fillEqually
stackView3.addArrangedSubview(resetButton)
stackView3.addArrangedSubview(zeroButton)
stackView3.addArrangedSubview(equalButton)
stackView3.addArrangedSubview(dividButton)
// 숫자버튼 액션 설정
resetButton.addTarget(self, action: #selector(resetButtonTapped), for: .touchDown)
oneButton.addTarget(self, action: #selector(oneButtonTapped), for: .touchDown)
twoButton.addTarget(self, action: #selector(twoButtonTapped), for: .touchDown)
threeButton.addTarget(self, action: #selector(threeButtonTapped), for: .touchDown)
fourButton.addTarget(self, action: #selector(fourButtonTapped), for: .touchDown)
fiveButton.addTarget(self, action: #selector(fiveButtonTapped), for: .touchDown)
sixButton.addTarget(self, action: #selector(sixButtonTapped), for: .touchDown)
sevenButton.addTarget(self, action: #selector(sevenButtonTapped), for: .touchDown)
eightButton.addTarget(self, action: #selector(eightButtonTapped), for: .touchDown)
nineButton.addTarget(self, action: #selector(nineButtonTapped), for: .touchDown)
zeroButton.addTarget(self, action: #selector(zeroButtonTapped), for: .touchDown)
// 연산자 액션 설정
plusButton.addTarget(self, action: #selector(plusButtonTapped), for: .touchDown)
minusButton.addTarget(self, action: #selector(minusButtonTapped), for: .touchDown)
multplyButton.addTarget(self, action: #selector(multplyButtonTapped), for: .touchDown)
dividButton.addTarget(self, action: #selector(dividButtonTapped), for: .touchDown)
equalButton.addTarget(self, action: #selector(equalButtonTapped), for: .touchDown)
// 뷰 추가
view.addSubview(label)
view.addSubview(stackView)
view.addSubview(stackView1)
view.addSubview(stackView2)
view.addSubview(stackView3)
// 제약조건 설정
label.snp.makeConstraints {
$0.height.equalTo(100)
$0.leading.equalToSuperview().offset(30)
$0.trailing.equalToSuperview().offset(-30)
$0.top.equalToSuperview().offset(200)
}
stackView.snp.makeConstraints {
$0.top.equalTo(label.snp.bottom).offset(60)
$0.leading.equalToSuperview().offset(30)
$0.trailing.equalToSuperview().offset(-30)
$0.height.equalTo(80)
}
stackView1.snp.makeConstraints {
$0.top.equalTo(stackView.snp.bottom).offset(10)
$0.leading.equalToSuperview().offset(30)
$0.trailing.equalToSuperview().offset(-30)
$0.height.equalTo(80)
}
stackView2.snp.makeConstraints {
$0.top.equalTo(stackView1.snp.bottom).offset(10)
$0.leading.equalToSuperview().offset(30)
$0.trailing.equalToSuperview().offset(-30)
$0.height.equalTo(80)
}
stackView3.snp.makeConstraints {
$0.top.equalTo(stackView2.snp.bottom).offset(10)
$0.leading.equalToSuperview().offset(30)
$0.trailing.equalToSuperview().offset(-30)
$0.height.equalTo(80)
}
}
/// 숫자 버튼의 공통 UI를 설정하는 메서드
private func setupButton(button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 30) // - 폰트 크기: 30pt
button.backgroundColor = UIColor(red: 58/255, green: 58/255, blue: 58/255, alpha: 1.0) // - 배경색: RGB(58,58,58) - 회색
button.setTitleColor(.white, for: .normal) // - 글자색: 흰색
button.layer.cornerRadius = 40 // - 모서리: 40pt 라운드 처리
}
/// 연산자 버튼의 공통 UI를 설정하는 메서드
private func colorButton(button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 30) // - 폰트 크기: 30pt
button.backgroundColor = UIColor(red: 255/255, green: 147/255, blue: 0/255, alpha: 1.0) // - 배경색: RGB(255,147,0) - 주황색
button.setTitleColor(.white, for: .normal) // - 글자색: 흰색
button.layer.cornerRadius = 40 // - 모서리: 40pt 라운드 처리
}
// MARK: - Helper Methods
/// 숫자 버튼 입력을 처리하는 공통 메서드
/// - Parameter number: 입력된 숫자 문자열
/// - Note:
/// 1. 초기 상태(firstNumber가 "0")이거나
/// 2. 새로운 계산 시작(isNewCalculation이 true)인 경우
/// -> 입력된 숫자로 완전히 대체
/// 3. 그 외의 경우 -> 기존 숫자에 이어서 추가
private func handleNumberInput(_ number: String) {
if firstNumber == "0" || isNewCalculation {
firstNumber = number // 새로운 숫자로 완전히 대체
isNewCalculation = false // 계산 진행 중 상태로 변경
} else {
firstNumber += number // 기존 숫자에 이어붙이기
}
label.text = "\(firstNumber)" // 화면 업데이트
}
// MARK: - Button Actions
// 초기화 버튼
@objc
private func resetButtonTapped() {
firstNumber = "0" // 숫자 초기화
isNewCalculation = false // 계산 상태 초기화
label.text = "\(firstNumber)" // 화면 업데이트
}
/// 숫자 버튼 액션 메서드들
@objc
private func oneButtonTapped() {
handleNumberInput("1")
}
@objc
private func twoButtonTapped() {
handleNumberInput("2")
}
@objc
private func threeButtonTapped() {
handleNumberInput("3")
}
@objc
private func fourButtonTapped() {
handleNumberInput("4")
}
@objc
private func fiveButtonTapped() {
handleNumberInput("5")
}
@objc
private func sixButtonTapped() {
handleNumberInput("6")
}
@objc
private func sevenButtonTapped() {
handleNumberInput("7")
}
@objc
private func eightButtonTapped() {
handleNumberInput("8")
}
@objc
private func nineButtonTapped() {
handleNumberInput("9")
}
@objc
private func zeroButtonTapped() {
handleNumberInput("0")
}
// MARK: - Helper Methods
/// 입력된 수식의 마지막 문자가 연산자인지 확인하는 메서드
/// - Returns: 마지막 문자가 연산자이면 true, 아니면 false
/// - 체크하는 연산자: +, -, *, /
/// - 사용 용도:
/// 1. 연속된 연산자 입력 방지
/// 2. 수식 마지막이 연산자인 상태에서 계산 방지
private func isLastCharacterOperator() -> Bool {
let operators = ["+", "-", "*", "/"] // 유효한 연산자 목록
// firstNumber의 마지막 문자를 String으로 변환
// 문자열이 비어있으면 false 반환
guard let lastChar = firstNumber.last.map(String.init) else { return false }
// 마지막 문자가 연산자 배열에 포함되어 있는지 확인
return operators.contains(lastChar)
}
// MARK: - Operator Button Actions
/// 더하기(+) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 첫 입력이 연산자인 경우 에러
/// 2. 연속된 연산자 입력 시 에러
/// 3. 정상적인 경우 수식에 + 추가
@objc
private func plusButtonTapped() {
if firstNumber == "0" { // 첫 입력이 연산자인 경우
firstNumber = "+"
label.text = "error"
} else if isLastCharacterOperator() { // 이전 입력이 연산자인 경우
label.text = "error" // 에러 메시지 표시
} else { // 정상적인 입력인 경우
firstNumber += "+" // 수식에 더하기 연산자 추가
label.text = "\(firstNumber)" // 화면 업데이트
isNewCalculation = false // 계산 진행 중 상태로 설정
}
}
/// 빼기(-) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 첫 입력이 - 인 경우 음수 표현을 위해 허용
/// 2. 연속된 연산자 입력 시 에러
/// 3. 정상적인 경우 수식에 - 추가
@objc
private func minusButtonTapped() {
if firstNumber == "0" { // 첫 입력이 마이너스인 경우 (음수 표현)
firstNumber = "-"
label.text = "\(firstNumber)"
} else if isLastCharacterOperator() { // 이전 입력이 연산자인 경우
label.text = "error" // 에러 메시지 표시
} else { // 정상적인 입력인 경우
firstNumber += "-" // 수식에 빼기 연산자 추가
label.text = "\(firstNumber)" // 화면 업데이트
isNewCalculation = false // 계산 진행 중 상태로 설정
}
}
/// 곱하기(*) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 첫 입력이 연산자인 경우 에러
/// 2. 연속된 연산자 입력 시 에러
/// 3. 정상적인 경우 수식에 * 추가
@objc
private func multplyButtonTapped() {
if firstNumber == "0" { // 첫 입력이 연산자인 경우
firstNumber = "*"
label.text = "error"
} else if isLastCharacterOperator() { // 이전 입력이 연산자인 경우
label.text = "error" // 에러 메시지 표시
} else { // 정상적인 입력인 경우
firstNumber += "*" // 수식에 곱하기 연산자 추가
label.text = "\(firstNumber)" // 화면 업데이트
isNewCalculation = false // 계산 진행 중 상태로 설정
}
}
/// 나누기(/) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 첫 입력이 연산자인 경우 에러
/// 2. 연속된 연산자 입력 시 에러
/// 3. 정상적인 경우 수식에 / 추가
@objc
private func dividButtonTapped() {
if firstNumber == "0" { // 첫 입력이 연산자인 경우
firstNumber = "/"
label.text = "error"
} else if isLastCharacterOperator() { // 이전 입력이 연산자인 경우
label.text = "error" // 에러 메시지 표시
} else { // 정상적인 입력인 경우
firstNumber += "/" // 수식에 나누기 연산자 추가
label.text = "\(firstNumber)" // 화면 업데이트
isNewCalculation = false // 계산 진행 중 상태로 설정
}
}
/// 등호(=) 버튼 동작을 처리하는 메서드
/// - Note:
/// 1. 수식이 비어있는 경우(0) 에러
/// 2. 마지막 입력이 연산자인 경우 에러
/// 3. 정상적인 경우 계산 수행
/// 4. 계산 결과를 화면에 표시하고 새로운 계산 준비
@objc
private func equalButtonTapped() {
if firstNumber == "0" {
label.text = "error"
} else if isLastCharacterOperator() {
label.text = "error"
} else {
if let result = calculate(expression: firstNumber) {
label.text = "\(result)"
firstNumber = "\(result)"
isNewCalculation = true // 계산 완료 후 새로운 계산 시작을 위해 플래그를 true로 설정
} else {
label.text = "error"
}
}
}
/// 수식을 계산하는 메서드
/// - Parameter expression: 계산할 수식 문자열 (예: "123+456")
/// - Returns: 계산 결과값 (Int?). 계산 실패 시 0 반환
/// - 사용: NSExpression을 통해 문자열 수식을 계산
/// - 주의:
/// 1. 정수 계산만 지원
/// 2. 잘못된 수식은 0 반환
private func calculate(expression: String) -> Int? {
let expression = NSExpression(format: expression)
if let result = expression.expressionValue(with: nil, context: nil) as? Int {
return result
} else {
return 0
}
}
}
// MARK: - Preview Provider
#Preview {
ViewController()
}