ReactorKit으로 계산기 구현하기
안녕하세요. 그린입니다🟢
이번 포스팅에서는 ReactorKit으로 계산기를 구현해보겠습니다🧑🏻💻
우선 이번 계산기 프로젝트 같은 경우에는 ReactorKit으로 어느정도 정형화된 리액트 MVVM 아키텍쳐 패턴을 따릅니다.
여기 포스팅에서는 필수 파일에 대한 구현들만 간단히 소개하고 아래 제 Git 레포 주소를 남겨두겠습니다!
더 참고하실 분들을 Git을 참고해주세요👍🏻
1. View
- 뷰는 우선 스토리보드로 UI요소 배치와 오토레이아웃을 적용했습니다.
- 그 후 ViewController에서 ReactorKit을 임포트하고 스토리보드뷰를 채택하여 리액트와 바인딩될 수 있게 구현하였습니다.
import UIKit
import ReactorKit
import RxCocoa
class CalculatorViewController: UIViewController, StoryboardView {
var disposeBag = DisposeBag()
// 계산기 버튼 프로퍼티
@IBOutlet weak var resultLabel: UILabel!
@IBOutlet weak var zeroButton: UIButton!
@IBOutlet weak var oneButton: UIButton!
@IBOutlet weak var twoButton: UIButton!
@IBOutlet weak var threeButton: UIButton!
@IBOutlet weak var fourButton: UIButton!
@IBOutlet weak var fiveButton: UIButton!
@IBOutlet weak var sixButton: UIButton!
@IBOutlet weak var sevenButton: UIButton!
@IBOutlet weak var eightButton: UIButton!
@IBOutlet weak var nineButton: UIButton!
@IBOutlet weak var dotButton: UIButton!
@IBOutlet weak var acButton: UIButton!
@IBOutlet weak var signButton: UIButton!
@IBOutlet weak var percentButton: UIButton!
@IBOutlet weak var divisionButton: UIButton!
@IBOutlet weak var multiplyButton: UIButton!
@IBOutlet weak var subtractButton: UIButton!
@IBOutlet weak var addButton: UIButton!
@IBOutlet weak var equalButton: UIButton!
func bind(reactor: CalculatorReactor) {
/// Action
// MARK: 숫자 및 Dot 버튼 클릭 시 인터렉션 리액터 바인딩
let numberButtons: [UIButton] = [zeroButton, oneButton, twoButton, threeButton, fourButton, fiveButton, sixButton, sevenButton, eightButton, nineButton]
numberButtons.enumerated().forEach({ (button) in
button.element.rx.tap
.map { .inputNumber("\(button.offset)") }
.bind(to: reactor.action)
.disposed(by: disposeBag)
})
self.dotButton.rx.tap
.map { .inputDot(".") }
.bind(to:reactor.action)
.disposed(by: self.disposeBag)
// MARK: AC 버튼 클릭 시 인터렉션 리액터 바인딩
self.acButton.rx.tap
.map { .clear }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
// MARK: 연산 버튼 클릭 시 인터렉션 리액터 바인딩
self.signButton.rx.tap
.map { .operation(.sign({ -$0 })) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.percentButton.rx.tap
.map { .operation(.percent({ $0 / 100})) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.divisionButton.rx.tap
.map { .operation(.operation({ $0 / $1 })) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.multiplyButton.rx.tap
.map { .operation(.operation({ $0 * $1 })) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.subtractButton.rx.tap
.map { .operation(.operation({ $0 - $1 })) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.addButton.rx.tap
.map { .operation(.operation({ $0 + $1 })) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
self.equalButton.rx.tap
.map { .operation(.result) }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
/// State
// MARK: 연산 결과 뷰 바인딩
reactor.state.map(\.displayText)
.distinctUntilChanged()
.bind(to: self.resultLabel.rx.text)
.disposed(by: self.disposeBag)
}
}
- 각 계산기의 버튼들의 tap을 이용해주기 위해서 RxCocoa를 채택하였습니다.
- 각 버튼들 클릭 시 리액터의 각 Action과 바인딩될 수 있게 적용하고 리액터의 State를 통해 뷰의 상태변화 값을 나타낼 수 있도록 바인딩 해줍니다.
2. SceneDelegate
- 1의 뷰컨에 생성하는 리액터를 주입할 수 있도록 씬딜리게이트의 세션 연결 설정에서 선언해줍니다.
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let _ = (scene as? UIWindowScene) else { return }
let viewController = self.window?.rootViewController as! CalculatorViewController
viewController.reactor = CalculatorReactor()
}
...
}
3. ViewModel
import Foundation
import ReactorKit
final class CalculatorReactor: Reactor {
// MARK: 버튼에 따른 연산 케이스
enum Operation {
case operation((Decimal, Decimal) -> Decimal)
case sign((Decimal) -> Decimal)
case percent((Decimal) -> Decimal)
case result
}
// MARK: 뷰 버튼 인터랙션에 따른 액션
enum Action {
case inputNumber(String)
case inputDot(String)
case operation(Operation)
case clear
}
enum Mutation {
case number(String)
case dot(String)
case operation(Operation)
case clear
}
// MARK: 뷰 상태 클래스
struct State {
var displayText: String = "0"
fileprivate var resultNum: Decimal = 0
fileprivate var operation: Operation?
fileprivate var inputText: String = "0"
fileprivate var inputNum: Decimal {
let value = Decimal(string: inputText)
if value != 0 {
return value!
}
return resultNum
}
}
// MARK: State 초기화 상수 선언
let initialState: State = .init()
}
extension CalculatorReactor {
// MARK: Action -> Mutation 스트림 변환
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .inputNumber(let number):
return .just(.number(number))
case .inputDot(let dot):
return .just(.dot(dot))
case .operation(let operation):
return .just(.operation(operation))
case .clear:
return .just(.clear)
}
}
// MARK: 이전 Mutation 상태 받아 다음 상태 반환
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case .number(let number):
state.inputText.append(number)
state.inputText = checkPrefixNum(state.inputText)
state.displayText = state.inputText
case .dot(let dot):
state.inputText.append(dot)
state.inputText = checkPrefixNum(state.inputText)
state.inputText = checkDot(state.inputText)
state.displayText = state.inputText
case .operation(let operation):
switch operation {
case .operation:
state.operation = operation
state.resultNum = state.inputNum
state.inputText = "0"
case .sign(let sign):
if var temp = Decimal(string: state.inputText) {
temp += state.resultNum
state.resultNum = 0
state.inputText = String("\(sign(temp))")
state.displayText = state.inputText
}
case .percent(let percent):
if var temp = Decimal(string: state.inputText) {
temp += state.resultNum
state.resultNum = 0
state.inputText = String("\(percent(temp))")
state.displayText = state.inputText
}
case .result:
if case let .operation(operation) = state.operation {
state.resultNum = operation(state.resultNum, state.inputNum)
state.inputText = "0"
state.operation = nil
}
state.displayText = String("\(state.resultNum)")
}
case .clear:
state.inputText = "0"
state.displayText = "0"
state.resultNum = 0
state.operation = nil
}
return state
}
// MARK: 입력 숫자 첫 요소 검증
fileprivate func checkPrefixNum(_ text: String) -> String {
var inputText = text
if !(inputText.contains(".")) && inputText.hasPrefix("0") {
inputText.removeFirst()
}
return inputText
}
// MARK: 중복 소수점 검증 및 제거
fileprivate func checkDot(_ text: String) -> String {
var inputText = text
let dot = inputText.filter{ $0 == "." }
if dot.count > 1 {
inputText.remove(at: inputText.lastIndex(of: ".")!)
}
return inputText
}
// MARK: 계산 시간 지연
fileprivate func delayCalculate() -> Single<Int> {
return Single.create(subscribe: { single in
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
single(.success(1))
})
return Disposables.create()
})
}
}
- 연산 Operation 열거형 타입 생성
- 뷰 버튼에 따른 액션 정의
- Action 스트림과 State 스트림의 중간 연결 역할인 Mutation 정의
- 뷰 상태를 변화시켜줄 State 구조체 정의
: State는 클래스를 사용하지않고 구조체를 사용합니다. 클래스를 사용하여도 되긴하지만 클래스를 사용한다면 이전 상태와 현재 상태들의 정보값들이 같아져 Equatable을 사용해 비교할 수 없습니다. State를 리액터에서 사용하는것은 각 변화되는 상태들을 비교하여 뷰에 바인딩해주기 위함입니다. 이에 참조하지 않도록 구조체를 사용하는것이 맞습니다.
: 또한 State 구조체에서는 각 상태값들의 변수만 담고 변수값이 변화되는 로직 메서드들은 reduce에서 구현해주는것이 좋습니다.
- State 초기화
- mutate 메서드 구현
: 순수함수로 전부 구현
: Action에 따라 케이스별로 Action 스트림을 Mutation 스트림으로 변환
: Observable을 반환하여 관찰되고 있는 동작을 실행
- reduce 메서드 구현
: 이전 State와 Mutation을 받아와 현재의 State를 반환
: mutation에 따라 케이스를 나눠 상태값 변경
: 숫자 입력의 경우 숫자 스트링에 추가하고 검증 메서드를 호출한 뒤 결과 레이블에 바인딩해줍니다.
: 소수점 입력의 경우도 마찬가지지만 소수점 중복 검증 메서드를 호출하는것이 다릅니다.
: 연산 입력의 경우에는 연산에 대해 저장하고 = 버튼을 누를때 동작 하도록 합니다.
: 부호변경과 백분율 표기의 경우는 즉시 실행해줍니다.
: 초기화 시 값들과 연산 상태를 초기화 해줍니다.
- 이전 숫자 검증 메서드
: 맨 앞 숫자열이 0이면서 소수점이 포함되지 않는다면 맨 앞 문자 1개를 지워줍니다.
- 중복 소수점 검증 메서드
: 숫자열의 배열에서 소수점을 필터해 갯수를 파악하고 1개보다 많다면 중복된것으로 마지막 추가된 인덱스의 소수점을 삭제해줍니다.
- 연산 강제 지연 메서드
: 비동기 처리와 같이 mutate에서 사이드이펙트에 대한 처리를 해주는것을 구현해보고 싶었습니다
: 연산 시 1초 기다리게 하기 위함
: Single로 단 하나의 사이드이펙트를 처리할 수 있도록 비동기 처리를 통해 1초 기다림을 구현
[더 자세히 알아보기]
https://github.com/GREENOVER/Calculator_Reactor
[참고자료]