-
ReactorKit으로 계산기 구현하기Swift 2021. 7. 5. 21:48
안녕하세요. 그린입니다🟢
이번 포스팅에서는 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
[참고자료]
'Swift' 카테고리의 다른 글
순수함수와 사이드이펙트 (0) 2021.08.22 Safe Array (0) 2021.07.24 진수 변환 (0) 2021.05.27 Deep copy & Shallow copy (0) 2021.05.25 패턴 (식별자, 값 바인딩, 튜플) (0) 2021.05.19