ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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

     

    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

    '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
Designed by Tistory.