Swift

ReactorKit으로 계산기 구현하기

GREEN.1229 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

 

GREENOVER/Calculator_Reactor

Reactor Calculator. Contribute to GREENOVER/Calculator_Reactor development by creating an account on GitHub.

github.com

 

[참고자료]

https://github.com/ReactorKit/ReactorKit

 

ReactorKit/ReactorKit

A library for reactive and unidirectional Swift applications - ReactorKit/ReactorKit

github.com

https://www.pointfree.co/episodes/ep2-side-effects