[TIL] iOS 입문 주차 과제 : 계산기 앱 만들기 day.02

Emily·2024년 11월 13일
1

CalculatorApp

목록 보기
7/11

지난 시간에는 UI를 완성했었다. 이제 로직의 영역이 시작됐다. 로직 구현의 기본 전제사항은 다음과 같다.

1. Int 계산만 구현
2. = 버튼을 눌렀을 때만 연산이 이루어지도록 구현
3. 연산은 Swift의 기본 제공 메소드 활용

다만 개인적으로 세번째 항목은 요구사항을 지키되 다른 방법으로도 구현할 수 있다면 추가적으로 해보고 싶은 마음이 든다. 사이드 프로젝트로 이미 진행중이었던 계산기 앱에서 활용할 수 있는 방법을 찾고 싶기 때문이다.

도전 구현기능 Lv.6 ~ 8

01) Button과 Label 연동

MainViewController에 모든 컴포넌트에 대한 코드가 들어있다면 그 안에 버튼 탭 동작 함수 역시 넣을 수 있었을 것이다.

extension MainViewController {
  @objc func buttonTapped(_ buttonInfo: ButtonInfo) {
      inputLabel.text.append(buttonInfo.name.title)
  }
}

아마 이거 하나로 간단하게 해결이 되었을 것이다.

하지만 내가 StackViewButton을 따로 클래스로 선언했기 때문에, MainViewController와 간접적으로 소통해야 한다. 사실 이 부분 때문에 고민을 하루 넘게 했다. 왜냐하면 나혼자 막 만들 때는 그냥 동작하는 것 자체에만 의미를 두었었는데, 학습하는 입장이 되었고 개발 과정을 기록으로 남기며 내 코드에 대한 근거를 적다보니 정당성을 같이 생각하게 되는 것이다. 그렇다보니 어떤 방법이 떠오르더라도 이게 바람직한 방법일까? 싶은 고민이 추가적으로 생겼다. 근데 문제는 그게 괜찮은 방법인건지 아닌건지 판단하고 결론을 스스로 내릴 능력이 내게는 없다. 바보 같은 기분으로 시간만 흘렀다. 그래도 바보도 기록은 할 수 있다. 바보기록을 남기기로 결심했다.

  1. 일단 Button class 안에서 addTarget 해보기
class Button: UIButton {
	private let buttonInfo: ButtonInfo
    
    init(buttonInfo: ButtonInfo) {
        self.buttonInfo = buttonInfo
        super.init(frame: .zero)
        addTarget()
    }
    
    private func addTarget() {
        addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
    }
}

extention Button {
	@objc func buttonTapped() {
        print(buttonInfo.name.title)
    }
}

addTarget을 하려면 @objc 함수를 action 파라미터로 넘겨줘야 한다. 눌린 버튼의 텍스트를 콘솔에 출력하는 것으로 버튼 탭이 잘 동작하는지 확인했다.

  1. button tap을 담당하는 Service class 만들기

MainViewControllerinputLabel에 버튼 정보를 넘기려면 어쨌든 외부의 창구를 통해야 한다. 일단 그 시작을 ButtonTapService 클래스를 만드는 것으로 해보았다.
나중에 눌린 버튼의 종류에 따라 처리할 부분이 생길 것 같아서 그 처리를 담당하라고도 만든 친구다.

protocol ButtonTapServiceType {
	func buttonTapped(of buttonInfo: ButtonInfo)
}

class ButtonTapService: ButtonTapServiceType {
	func buttonTapped(of buttonInfo: ButtonInfo) {
    	print(buttonInfo.name.title)
    }
}

class Button: UIButton {
	private let service: ButtonTapServiceType = ButtonTapService()
}

extension Button {
	@objc func buttonTapped() {
    	service.buttonTapped(of: buttonInfo)
    }
}
  1. ViewModel을 만들고 Combine 적용하기

MainViewControllerinputLabel과 실시간으로 소통할 @Published 변수를 갖고 있는 ViewModel을 만들었다. ButtonTapService에서도 @Published 변수로 눌린 버튼의 정보를 전송할 것이다.

class MainViewModel {
	// MainViewController에만 주입하는 것이 아니라, ButtonTapService에서도 접근하도록 하기 위해 싱글톤으로 만들었다.
    static let shared = MainViewModel()
	
    // MainViewController의 inputLabel.text와 연동할 변수
	@Published var inputLabelText = ""
}

class ButtonTapService: ButtonTapServiceType {
	private let vm = MainViewModel.shared
    
    func buttonTapped(of buttonInfo: ButtonInfo) {
    	// text에 눌린 버튼 title 추가
	    vm.inpuLabelText.append(buttonInfo.name.title)
    }
}
import Combine

class MainViewController: UIViewController {
	private var cancellables = Set<AnyCancellable>()
    
    private let vm = MainViewModel.shared
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }
    
    // label text와 연동
    private func bind() {
    	vm.$inputLabelText
            .sink { [weak self] text in
                self?.inputLabel.text = text
            }
            .store(in: &cancellables)
    }
}
  1. 기본 텍스트를 0이 되도록 하기
func buttonTapped(of buttonInfo: ButtonInfo) {
	if vm.inputLabelText == "0" {
        vm.inputLabelText = buttonInfo.name.title
    } else {
        vm.inputLabelText.append(buttonInfo.name.title)
    }
}

02) AC Button 구현

기존의 buttonTapped 내부 코드를 다른 함수로 분리하고, inputLabelText0으로 초기화하는 함수도 만든 뒤 buttonTapped 에서는 ButtonName 속성에 따라 다른 동작을 호출하도록 switch문으로 처리했다.

func buttonTapped(of buttonInfo: ButtonInfo) {
    switch buttonInfo.name {
    case .clear:
        clearText()
    default:
        appendText(buttonInfo.name.title)
    }  
}

private func appendText(_ text: String) {
    if vm.inputLabelText == "0" {
        vm.inputLabelText = text
    } else {
        vm.inputLabelText.append(text)
    }
}

private func clearText() {
    vm.inputLabelText = "0"
}

03) = Button 구현하기에 앞서

NSExpression.expressionValue 알아보기

과제 요구사항에 연산에 필요한 메소드가 제공되어 있었다.

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 nil
    }
}

저걸 보자마자 든 생각 : NSExpression이 뭘까? 일단 어렵게 생겼다.
나는 웬만한 걸 보면 그러려니 하고 그냥 쓰는 편인데 이건 생긴 게 심상치 않아서 찾아봤다. (내 기준 심상치 않음 : NS가 붙음)

NSExpression
An expression for use in a comparison predicate.
비교 서술 용도를 위한 표현(법)

표현법이라서 init(format: )이 있는 거겠군. 근데 그냥 저걸 해석만 해서는 뭐라는 건지 와닿지가 않아서 결국 AI 친구에게 물어봤다. 수식이나 계산식을 표현하고 평가하는데 사용되는 Foundation 프레임워크의 클래스란다. 그래서 얘를 쓰려면 import Foundation을 해줘야 한다.

func expressionValue(with: Any?, context: NSMutableDictionary?) -> Any?
Evaluates an expression using a specified object and context.
특정 object와 context를 사용하여 표현문(수식)의 값을 구한다. (사용할 object와 context가 nil, 즉 없을 수 있음)

NSMutableDictionary를 본 순간 더 알아보고 싶지 않아졌다.(언젠간 알고 싶긴 한데 지금은 안 알고 싶다.) 하여튼 1 + 2 값을 넣으면 알아서 3을 반환해주는 그런 친구인 거 같다.
NSExpression의 공식문서 페이지를 구경해보면 아주 다양한 expression에 적용할 수 있는 거 같아보였는데, 너무 많아보여서 모든 용례를 알아보고 싶지는 않은 기분이 들었다.(ExpressionTypecase부터가 10개가 넘는다) 꼭 수식이 아니더라도 정말 다양한 format의 식을 처리해주는 거 같았다. 나중에 또 쓰게 되면 필요한 부분에 대해 알아보는 걸로 하고 넘어간다.

다만 1 + 2가 아니라 1++ 같은 비정상적인 수식을 입력하면 크래시가 나기 때문에 그에 대한 방지가 필요해보였다.

최근에 누른 ButtonInfo 저장하기

내가 가장 먼저 떠올린 방법은 최근에 누른 버튼을 기억하고, 그 버튼이 연산 버튼이었다면 다음에는 연산 버튼의 입력을 무효 처리하는 방법이다. 이 방법을 쓰면 맨 처음에 연산 버튼을 눌렀을 때도 inputLabel에 연산 기호가 입력되는 것이 아니라 아무 일도 일어나지 않고, 숫자 버튼을 눌러야만 inputLabel에 입력되도록 처리하는 것도 가능해질 것 같았다. 또, 1+와 같이 수식이 완성되지 않은 상태에서 =를 눌렀을 때도 무효 처리가 가능해지는 등 유용할 것 같아 마지막에 누른 버튼을 저장하는 변수를 ButtonTapService에 선언하였다.

class ButtonTapService {
	private var lastTappedButton: ButtonInfo?
}

그런데,

04) 트러블 슈팅

문제 발생

내 의도는 최근에 누른 ButtonInfo 저장하기 였는데, 저장이 전혀 안되는 문제가 발생했다.

버튼을 누를 때마다 이전에 누른 button이 nil인 것이다.

원인 파악 및 해결방법 모색

근데 지금 생각해보면 당연한 게, 개별 버튼 클래스 안에다가 서비스를 주입했으니 값이 남아있을 리가 없다. 그럼 서비스 클래스를 어떻게 주입해야 하는 걸까? 싱글톤을 뷰 모델이 아닌 서비스에 적용하는 것으로 변경해보기로 했다.

그리고 이렇게 적용하려고 하는 와중에 싱글톤을 완성하기 위해서는 private init()을 해주어야 한다는 것도 알게되었다.

static let shared = ButtonTapService()
private init() {}

private init()을 해주지 않으면, shared를 생성한 것이 의미 없이 ButtonTapService.shared가 아닌 ButtonTapService()로 인스턴스 생성이 가능해진다. 그러면 single source of truth를 위해 싱글톤을 채택한 의미가 퇴색된다. 여기서 내가 single source of truth로 만들고 싶은 것은 버튼 탭 내역이다. (여기서 버튼 탭 내역은 누적 텍스트와 직전에 누른 버튼 정보를 묶어서 편하게 칭한 것이다)

문제 해결

lastTappedButtonsingle source of truth로 만들기 위해 ButtonTapService를 싱글톤으로 선언하고 MainViewModel을 삭제했다. 여기에 구구절절 쓸 엄두도 안 나는 여러가지 삽질을 거쳐 여기에 도달했다.

class Button: UIButton {
	private let buttonTapService: ButtonTapService.shared
}

extension Button {
	@objc func buttonTapped() {
        buttonTapService.buttonTapped(of: buttonInfo)
    }
}

class ButtonTapService: ButtonTapServiceType {
	static let shared = ButtonTapService()
    
    private init() {}
    
    @Published var textStack: String = "0"
    
    private var lastTappedButton: ButtonInfo?
    
    // ... //
}

class MainViewController: UIViewController {
    private let buttonTapService = ButtonTapService.shared
    
    // ... //
    
    private func bind() {
    	buttonTapService.$textStack
            .sink { [weak self] text in
                self?.inputLabel.text = text
            }
            .store(in: &cancellables)
    }
    

그렇게 이제는 전에 눌린 버튼의 정보를 잘 저장하게 된 lastTappedButton 변수를 이용해, 직전에 버튼 누른 내역이 없거나 연산 버튼이었을 경우, 연산 버튼을 눌렀을 때 아무 동작도 하지 않도록 처리했다.

func buttonTapped(of buttonInfo: ButtonInfo) {
	switch buttonInfo.role {
    case .number:
    	appendText(buttonInfo.name.title)
    case .operation:
    	// AC Button은 제약 없이 0 처리가 가능하도록 맨 먼저 처리
        if buttonInfo.name == .clear {
        	clearText()
        } else if lastTappedButton == nil || lastTappedButton?.role == .operation {
        	print("Nothing happens")
        } else if buttonInfo.name == .eqaul {
        	// 계산 처리 (내일 구현할 것) //
        } else {
        	appendText(buttonInfo.name.title)
        }
    }
    // 누른 버튼으로 정보 update
    lastTappedButton = buttonInfo
}

정작 = 버튼 동작 구현 단계에서 = 버튼 처리만 빼고 다 한 것 같다. 이제 딱 = 버튼 동작 할 차례인데, 지금은 이걸 내일 하고 싶은 마음 밖에 안든다. 오늘 코딩을 많이 한 것도 아닌데 힘이 든다. 하루종일 TILXcode를 켠 채 머리를 싸매고 고민과 생각으로 시간을 보냈다. 지금 다시 이 글을 쭉 읽어보니 삽질 내역을 글로 남긴 것 같다. 그래도 열심히 했으니깐 남긴다. 드러누우러 가야겠다.

profile
iOS Junior Developer

0개의 댓글