지난 시간에는 UI를 완성했었다. 이제 로직의 영역이 시작됐다. 로직 구현의 기본 전제사항은 다음과 같다.
1.
Int
계산만 구현
2.=
버튼을 눌렀을 때만 연산이 이루어지도록 구현
3. 연산은Swift
의 기본 제공 메소드 활용
다만 개인적으로 세번째 항목은 요구사항을 지키되 다른 방법으로도 구현할 수 있다면 추가적으로 해보고 싶은 마음이 든다. 사이드 프로젝트로 이미 진행중이었던 계산기 앱에서 활용할 수 있는 방법을 찾고 싶기 때문이다.
MainViewController
에 모든 컴포넌트에 대한 코드가 들어있다면 그 안에 버튼 탭 동작 함수 역시 넣을 수 있었을 것이다.
extension MainViewController {
@objc func buttonTapped(_ buttonInfo: ButtonInfo) {
inputLabel.text.append(buttonInfo.name.title)
}
}
아마 이거 하나로 간단하게 해결이 되었을 것이다.
하지만 내가 StackView
와 Button
을 따로 클래스로 선언했기 때문에, MainViewController
와 간접적으로 소통해야 한다. 사실 이 부분 때문에 고민을 하루 넘게 했다. 왜냐하면 나혼자 막 만들 때는 그냥 동작하는 것 자체에만 의미를 두었었는데, 학습하는 입장이 되었고 개발 과정을 기록으로 남기며 내 코드에 대한 근거를 적다보니 정당성을 같이 생각하게 되는 것이다. 그렇다보니 어떤 방법이 떠오르더라도 이게 바람직한 방법일까? 싶은 고민이 추가적으로 생겼다. 근데 문제는 그게 괜찮은 방법인건지 아닌건지 판단하고 결론을 스스로 내릴 능력이 내게는 없다. 바보 같은 기분으로 시간만 흘렀다. 그래도 바보도 기록은 할 수 있다. 바보기록을 남기기로 결심했다.
- 일단 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
파라미터로 넘겨줘야 한다. 눌린 버튼의 텍스트를 콘솔에 출력하는 것으로 버튼 탭이 잘 동작하는지 확인했다.
- button tap을 담당하는
Service class
만들기
MainViewController
의 inputLabel
에 버튼 정보를 넘기려면 어쨌든 외부의 창구를 통해야 한다. 일단 그 시작을 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)
}
}
ViewModel
을 만들고Combine
적용하기
MainViewController
의 inputLabel
과 실시간으로 소통할 @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)
}
}
- 기본 텍스트를
0
이 되도록 하기
func buttonTapped(of buttonInfo: ButtonInfo) {
if vm.inputLabelText == "0" {
vm.inputLabelText = buttonInfo.name.title
} else {
vm.inputLabelText.append(buttonInfo.name.title)
}
}
기존의 buttonTapped
내부 코드를 다른 함수로 분리하고, inputLabelText
를 0
으로 초기화하는 함수도 만든 뒤 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"
}
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
에 적용할 수 있는 거 같아보였는데, 너무 많아보여서 모든 용례를 알아보고 싶지는 않은 기분이 들었다.(ExpressionType
의 case
부터가 10개가 넘는다) 꼭 수식이 아니더라도 정말 다양한 format
의 식을 처리해주는 거 같았다. 나중에 또 쓰게 되면 필요한 부분에 대해 알아보는 걸로 하고 넘어간다.
다만 1 + 2
가 아니라 1++
같은 비정상적인 수식을 입력하면 크래시가 나기 때문에 그에 대한 방지가 필요해보였다.
최근에 누른
ButtonInfo
저장하기
내가 가장 먼저 떠올린 방법은 최근에 누른 버튼을 기억하고, 그 버튼이 연산 버튼이었다면 다음에는 연산 버튼의 입력을 무효 처리하는 방법이다. 이 방법을 쓰면 맨 처음에 연산 버튼을 눌렀을 때도 inputLabel
에 연산 기호가 입력되는 것이 아니라 아무 일도 일어나지 않고, 숫자 버튼을 눌러야만 inputLabel
에 입력되도록 처리하는 것도 가능해질 것 같았다. 또, 1+
와 같이 수식이 완성되지 않은 상태에서 =
를 눌렀을 때도 무효 처리가 가능해지는 등 유용할 것 같아 마지막에 누른 버튼을 저장하는 변수를 ButtonTapService
에 선언하였다.
class ButtonTapService {
private var lastTappedButton: ButtonInfo?
}
그런데,
내 의도는 최근에 누른 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로 만들고 싶은 것은 버튼 탭 내역이다. (여기서 버튼 탭 내역은 누적 텍스트와 직전에 누른 버튼 정보를 묶어서 편하게 칭한 것이다)
lastTappedButton
을 single 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
}
정작 =
버튼 동작 구현 단계에서 =
버튼 처리만 빼고 다 한 것 같다. 이제 딱 =
버튼 동작 할 차례인데, 지금은 이걸 내일 하고 싶은 마음 밖에 안든다. 오늘 코딩을 많이 한 것도 아닌데 힘이 든다. 하루종일 TIL
과 Xcode
를 켠 채 머리를 싸매고 고민과 생각으로 시간을 보냈다. 지금 다시 이 글을 쭉 읽어보니 삽질 내역을 글로 남긴 것 같다. 그래도 열심히 했으니깐 남긴다. 드러누우러 가야겠다.