포스트

Tip-Calculator (5)

TipInputView publisher 생성

1
2
3
4
5
private let tipSubject: CurrentValueSubject<Tip, Never> = .init(.none)

var valuePublisher: AnyPublisher<Tip, Never> {
        return tipSubject.eraseToAnyPublisher()
    }

tipSubject는 CurrentValueSubject인 이유는 값이 전달되고도 해당 값을 새로운 값이 들어오기 전까지 가지고 있게하는데에 의미가 있다.

valuePublisher를 또 만든건 지난글 마지막부분쯤에 있으니 참고.

Button에 Publisher 생성

1
2
3
4
5
6
7
private lazy var tenPercentTipButton: UIButton = {
        let button = buildTipButton(tip: .tenPercent)
        button.tapPublisher.flatMap({ // added
            Just(Tip.tenPercent)
        }).assign(to: \.value, on: tipSubject).store(in: &cancellables)
        return button
    }()

이 부분의 의미는 뭐냐, 10% 버튼을 클릭하면 Tip에서 tenpercent를 가져온다.

flatmap을 사용함으로써. 여러 publisher들을 하나의 새로운 publisher로 만들어준다.

해당 사진의 예시 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
enum ConvertError: Error {
    case integerError
}
 
["1", "hoge", "2"].publisher
    .flatMap { value in
        return Just(value)
            .tryMap { value throws -> Int in
                if let integer = Int(value) {
                    return integer
                } else {
                    throw ConvertError.integerError
                }
            }
            .catch { _ in
                Just(0)
            }
    }
    .sink { completion in
        switch completion {
        case let .failure(error):
            print(error)
 
        case .finished:
            print("finished")
        }
    } receiveValue: { value in
        print(value)
    }
 
// 出力結果: 1, 0, 2, finished

출처

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum Tip {
    
    case none
    case tenPercent // here
    case fifteenPercent
    case twentyPercent
    case custom(value: Int)
    
    var stringValue: String {
        switch self{
        case .none:
            return ""
        case .tenPercent:
            return "10%"  // here
        case .fifteenPercent:
            return "15%"
        case .twentyPercent:
            return "20%"
        case .custom(let value):
            return String(value)
        }
    }
    
}

tipSubject의 value property에 tenPercent 가 들어가게 된다.

나머지 버튼들도 수정을 해주고,

1
2
3
4
5
6
7
8
9
10
11
12
13
// calculatorVC

private func bind() {

        let input = CalculatorVM.Input(
            billPublisher: billInputView.valuePublisher, 
            tipPulbisher: tipInputView.valuePublisher, // modfied
            splitPublisher: Just(5).eraseToAnyPublisher())
        
        let output = vm.transform(input: input)
        
    }

vc에서 이부분도 수정해준다.

그리고 vm에서 transform에

1
2
3
input.tipPublisher.sink { tip in
    print("the tip: \(tip)")
}.store(in: &cancellables)

이걸 적어 보고 실행 후, 팁 버튼을 클릭하면

the tip: tenPercent 라고 출력이 된다.

왜 10%가 아니지? 라는 부분은 enum에 또 var로 stringvalue를 computed property로 구성을 해두었기에,

print("the tip: \(tip.stringValue)")를 하게되면 퍼센티지로 출력이 된다.

customtip button 기능 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private lazy var customTipButton: UIButton = {
        let button = UIButton()
        button.setTitle("Custom Tip", for: .normal)
        button.titleLabel?.font = ThemeFont.bold(ofSize: 20)
        button.backgroundColor = ThemeColor.primary
        button.tintColor = .white
        button.addCornerRadius(radius: 8.0)
        button.tapPublisher.sink { [weak self] _ in // added
            self?.handleCustomTipButton()
        }.store(in: &cancellables)
        return button
    }()


private func handleCustomTipButton() {
        let alertController: UIAlertController = {
            let controller = UIAlertController(title: "Enter Custom Tip", message: nil, preferredStyle: .alert)
            controller.addTextField { textField in
                textField.placeholder = "Make it generous!"
                textField.keyboardType = .numberPad
                textField.autocorrectionType = .no
            }
            let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
            let okAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
                guard let text = controller.textFields?.first?.text, let value = Int(text) else { return }
                self?.tipSubject.send(.custom(value: value))
            }
            [okAction, cancelAction].forEach(controller.addAction(_:))
            return controller
        }()
        parentViewController?.present(alertController, animated: true)
    }


extension UIResponder {
    
    var parentViewController: UIViewController? {
        return next as? UIViewController ?? next?.parentViewController
    }
    
}

code로 대체 사실 코드 보면 알기에 별다른 말이 필요 없을듯 하다.

UIResponder만 신선했다.

UIResponder 클래스는 iOS 앱에서 이벤트를 처리하고 응답 체인(responder chain)을 통해 이벤트를 전달하는 데 사용되는 기본 클래스이다.

Extension은 UIResponder에 parentViewController라는 Computed Property를 추가한다. 이 속성은 옵셔널 UIViewController를 반환. parentViewController의 구현은 next 속성을 사용한다. next는 응답 체인에서 다음 응답자를 반환하는 UIResponder 속성이다. 먼저 next 응답자가 UIViewController인지 확인하고, 그렇다면 해당 UIViewController를 반환 만약 next 응답자가 UIViewController가 아니라면, next?.parentViewController를 재귀적으로 호출하여 UIViewController를 찾거나 응답 체인의 끝까지 탐색

CleanShot 2024-05-03 at 16 21 00@2x

그리고 다시 VM으로 돌아가서

1
2
3
4
5
// transform

input.tipPublisher.sink { tip in
            print("the tip: \(tip)")
        }.store(in: &cancellables)

이걸 추가해서 실행해서 custom tip을 적어보면

프린트가 된다.

the tip: custom(value: 25) 이런식.

Handle Custom Tip Button

팁버튼을 누르게되면 배경색이 변하고, custom tip에 값을 입력하면 그부분이 입력한 값으로 변하게 할것이다.

우선 원상태로 돌릴 함수를 구현, 일종의 Initializer

1
2
3
4
5
6
7
8
9
10
11
private func resetView() {
        [tenPercentTipButton,
         fifTeenPercentTipButton,
         twentyPercentTipButton,
         customTipButton].forEach {
            $0.backgroundColor = ThemeColor.primary
        }
        let text = NSMutableAttributedString(string: "Custom Tip",
                                             attributes: [.font: ThemeFont.bold(ofSize: 20)])
        customTipButton.setAttributedTitle(text, for: .normal)
    }

resetView 함수는 모든 버튼의 색을 돌리고, custom tip 부분은 Custom tip 이라고 다시 돌아오게 한다.

observe 함수 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private func observe() {
        tipSubject.sink { [unowned self] tip in
            resetView()
            switch tip {
            case .none:
                break
            case .tenPercent:
                tenPercentTipButton.backgroundColor = ThemeColor.secondary
            case .fifteenPercent:
                fifTeenPercentTipButton.backgroundColor = ThemeColor.secondary
            case .twentyPercent:
                twentyPercentTipButton.backgroundColor = ThemeColor.secondary
            case .custom(let value):
                customTipButton.backgroundColor = ThemeColor.secondary
                let text = NSMutableAttributedString(
                    string: "$\(value)",
                    attributes: [
                        .font: ThemeFont.bold(ofSize: 20)
                    ])
                text.addAttributes([
                    .font: ThemeFont.bold(ofSize: 14)
                ], range: NSMakeRange(0, 1))
                customTipButton.setAttributedTitle(text, for: .normal)
            }
        }.store(in: &cancellables)
    }

우선 화면을 리셋 해주고나서, switch case를 통해 tip이 어떤 값인지에 따라 다르게 처리하게 한다.

배경색을 바꾸는것이고 custom 만 입력한 값이 보이게 한다.

그리고 이것 역시도 tipSubject를 통해 전달을 하는데, .store(in: &cancellables)를 통해 subscription을 저장해두지 않으면

적용이 안됨.

splitview 기능 구현

1
2
3
4
5
private let splitSubject: CurrentValueSubject<Int, Never> = .init(1)
    
    var valuePublisher: AnyPublisher<Int, Never> {
        return splitSubject.eraseToAnyPublisher()
    }

이번에도 역시 subject와 pulisher를 생성.

1
2
3
4
5
6
7
//TipInputView

//before
private let tipSubject = CurrentValueSubject<Tip, Never>(.none)

//after
private let tipSubject: CurrentValueSubject<Tip, Never> = .init(.none)

TipInputView에 type 설정과 initializing으로 하는것으로 교체.

다시 splitview로 와서

1
2
3
4
5
6
7
8
private lazy var incrementButton: UIButton = {
        let button = buildButton(text: "+", corners: [.layerMaxXMinYCorner, .layerMaxXMaxYCorner])
        button.tapPublisher.flatMap { [unowned self] _ in // added
            Just(splitSubject.value + 1)
        }.assign(to: \.value, on: splitSubject)
            .store(in: &cancellables)
        return button
    }()

현재 splitSubject가 가지고 있는 값에서 1을 추가를 해주고 그것을 다시 splitSubject의 value 프로퍼티에 할당시켜준다.

1
2
3
4
5
6
7
8
private lazy var decrementButton: UIButton = {
        let button = buildButton(text: "-", corners: [.layerMinXMinYCorner, .layerMinXMaxYCorner])
        button.tapPublisher.flatMap { [unowned self] _ in // added
            Just(splitSubject.value == 1 ? 1 : splitSubject.value - 1)
        }.assign(to: \.value, on: splitSubject)
            .store(in: &cancellables)
        return button
    }()

이때 decrementBtn의 경우 1이 최소값이 므로 삼항연산자를 통해 1일때는 1을 그대로 유지하고, 1이 아닐때만 -1 을 하여 그값을 splitSubject의 value프로퍼티에 넣게 해주었다.

observe 함수 구현

1
2
3
4
5
private func observe() {
        splitSubject.sink { [unowned self] quantity in
            quantityLabel.text = quantity.stringValue
        }.store(in: &cancellables)
    }

observe 함수를 통해 splitSubject의 value를 quantityLabel에 표시하게 한다.

quantity의 type이 int이므로 extension을 통해 변환을 하게 해주었다.

vm에서 작동 확인

1
2
3
4
// transform
input.splitPublisher.sink { split in
            print("the split: \(split)")
        }.store(in: &cancellables)

에 이부분을 추가하여 +, - 버튼을 클릭할때마다 값이 제대로 증감하는지 확인.

1
2
3
4
5
6
7
8
9
10
the split: 1
the split: 2
the split: 3
the split: 2
the split: 1
// 1인 상태에서 여러번 - 클릭
the split: 1
the split: 1
the split: 1
the split: 1

1을 계속 눌렀을때 이벤트가 생기는걸 방지.

1
2
3
var valuePublisher: AnyPublisher<Int, Never> {
        return splitSubject.removeDuplicates().eraseToAnyPublisher()
    }

removeDuplicates는 중복상황이 생기는걸 방지하는데 지금은 split의 최소 값은 1인데 1에서 - 를 계속 누르면 1이라는 이벤트가 계속 발생하게 되는데, 이때 저 메서드를 통해 1인 상태 즉 최소값일 때 더이상 같은 이벤트가 발생하지 않게 막아주는 역할을 하게된다.

Compute Result

우선 vm의 transform 함수를 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func transform(input: Input) -> Output {
        
        let updateViewPublisher = Publishers.CombineLatest3( // added
            input.billPublisher,
            input.tipPublisher,
            input.splitPublisher).flatMap { [unowned self] (bill, tip, split) in
                let totalTip = getTipAmount(bill: bill, tip: tip)
                let totalBill = bill + totalTip
                let amountPerPerson = totalBill / Double(split)
                let result = Result( 
                    amountPerPerson: amountPerPerson, // modified
                    totalBill: totalBill,             // modified
                    totalTip: totalTip)               // modified
                
                return Just(result)
            }.eraseToAnyPublisher()
        
        return Output(updateViewPublisher: updateViewPublisher) // modified
    }

private func getTipAmount(bill: Double, tip: Tip) -> Double {
        switch tip{
        case .none:
            return 0
        case .tenPercent:
            return bill * 0.1
        case .fifteenPercent:
            return bill * 0.15
        case .twentyPercent:
            return bill * 0.2
        case .custom(let value):
            return Double(value)
        }
    }    

CombineLatest 메서드를 통해 3개의 publisher를 묶어준다.

그리고 flatMap을 통해 하나의 Publisher로 리턴을 해준다.

vc로 돌아가서 bind 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private func bind() {
        
        
        let input = CalculatorVM.Input(
            billPublisher: billInputView.valuePublisher,
            tipPublisher: tipInputView.valuePublisher,
            splitPublisher: spiltInputView.valuePublisher)
        
        let output = vm.transform(input: input)

        output.updateViewPublisher.sink { result in // added
            print(result)
        }.store(in: &cancellables)
        
    }

이렇게 하면 우리가 시뮬레이터를 실행하면 값에 따라 모든게 출력이 된다.

CleanShot 2024-05-03 at 17 05 36@2x

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.