[RxSwift๐Ÿฆˆ] #3 UI ์ปดํฌ๋„ŒํŠธ์™€ ์—ฐ๋™

๋˜์ƒยท2022๋…„ 1์›” 6์ผ
0

iOS

๋ชฉ๋ก ๋ณด๊ธฐ
6/47
post-thumbnail

0. ์˜ˆ์ œ ํ”„๋กœ๊ทธ๋žจ ์„ค๋ช…

ํŠ€๊น€ ๋ฆฌ์ŠคํŠธ๋ฅผ json์œผ๋กœ ๋ถˆ๋Ÿฌ์˜ค๊ณ , ํ•˜๋‚˜์”ฉ ์ถ”๊ฐ€ํ•  ๋•Œ ์š”์ฒญ์„ ๋ณด๋‚ด์„œ ๋งˆ์ง€๋ง‰์— ๊ฐ€๊ฒฉ๊นŒ์ง€ ๊ณ„์‚ฐํ•ด์ฃผ๋Š” ๊ฐ„๋‹จํ•œ(?) ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“ค์—ˆ๋‹ค.


1. ํด๋” ๊ตฌ์กฐ

๊ณฐํŠ€๊น€๋‹˜ github ์— Step3 ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด ๋œ๋‹ค.

Step3 : UIKit ์œผ๋กœ๋งŒ ์ง  ์ฝ”๋“œ
Step3+Rx : RxSwift
Step3+Rx+MVVM : RxSwift + MVVM
์œผ๋กœ ์ด๋ฏธ ์™„์„ฑ๋˜์–ด์žˆ๋Š” ์ฝ”๋“œ์ด๋‹ค.

Step3+empty ๋ฅผ ์—ด์–ด์„œ ๊ฐ•์˜๋ฅผ ๋”ฐ๋ผ๊ฐ€๋ฉด์„œ ์ง„ํ–‰ํ–ˆ๋‹ค.


๊ทธ ์•ˆ์—๋Š”

Domain
Menu > Model > Model.swift - menu ์— ๋Œ€ํ•œ ๋ชจ๋ธ
Menu > Service > APIService.swift - ๋ฉ”๋‰ด๋ฅผ fetch ํ•ด์˜ค๋Š” class

Pages
MenuList > VC, Cell
Order > VC

์ด๋Ÿฐ ๊ตฌ์กฐ๋กœ ์ด๋ฃจ์–ด์ ธ ์žˆ๋‹ค.




2. step3

1. ํ”„๋กœ์ ํŠธ ์ƒํ™ฉ ๊ฐ€์ •

์ด๋ก ์ ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ ๋‹จ์€ API, ๋””์ž์ธ์ด ๋‚˜์˜ค๊ณ  ๋‚˜์„œ ๊ฐœ๋ฐœ์„ ์ง„ํ–‰ํ•˜์ง€๋งŒ, ์‹ค๋ฌด์—์„œ๋Š” ๊ทธ๋ ‡์ง€ ์•Š์€ ๊ฒฝ์šฐ๊ฐ€ ๋งŽ๋‹ค. ์šฐ๋ฆฌ๋Š” API๋„ ๋””์ž์ธ๋„ ๋ชจ๋ฅด๋Š” ์ƒํƒœ์—์„œ ๊ธฐํš์„œ๋งŒ ๋ณด๊ณ  ๊ฐœ๋ฐœ์„ ์ง„ํ–‰ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. API๋„ ๋‚˜์ค‘์— ์—ฐ๊ฒฐํ•˜๊ณ , ๋””์ž์ธ๋„ ๋‚˜์ค‘์— ๋„ฃ์„ ๊ฒƒ. -> ์ตœ์ ์˜ ๊ตฌ์กฐ MVVM~~


2. ๊ฐœ๋ฐœ~

ViewController == VC

1. Menu.swift - View๋ฅผ ์œ„ํ•œ Model ์ƒ์„ฑ (View์— ๋ณด์—ฌ์ค„ ๊ฒƒ๋งŒ ๊ฐ€์ง„ ๋ชจ๋ธ)

struct Menu {
    var name: String = ""
    var price: Int = 0
    var count: Int = 0
    
    init(name: String, price: Int, count: Int) {
        self.name = name
        self.price = price
        self.count = count
    }
}

2. MenuVC - menus (ViewModel ๋ฐฐ์—ด) ์ƒ์„ฑ ํ›„ tableView์™€ ์—ฐ๊ฒฐํ•œ๋‹ค.

3. MenuListViewModel์„ ์ด์šฉํ•ด์„œ ๋ฉ”๋‰ด ๋ฐฐ์—ด์„ ๋”ฐ๋กœ ๊ด€๋ฆฌํ•˜์ž. - View์— ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ๋•Œ๋Š” VC์— viewModel ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•ด์„œ ๋ฐ›์•„์˜ค๋ฉด ๋œ๋‹ค.

// MenuListViewModel
class MenuListViewModel {
    let menus: [Menu] = [
        Menu(name: "๋ณด์•„", price: 1105, count: 0),
        Menu(name: "ํƒœ์—ฐ", price: 309, count: 0),
        Menu(name: "ํšจ์—ฐ", price: 922, count: 0),
        Menu(name: "์Šฌ๊ธฐ", price: 210, count: 0),
        Menu(name: "์›ฌ๋””", price: 224, count: 0),
        Menu(name: "์นด๋ฆฌ๋‚˜", price: 411, count: 0),
        Menu(name: "์œˆํ„ฐ", price: 101, count: 0)
    ]

    var itemsCount: Int = 5
    var totalPrice: Int = 10000
}

4. viewModel์— ์žˆ๋Š” ์ •๋ณด์ธ totalPrice, itemsCount๋ฅผ ๋ฐ›์•„์˜ค๊ฒŒ ํ•ด๋ณด์ž. ์ œ๋Œ€๋กœ ๊ฐ€์ ธ์˜ค๋Š”์ง€ ๋ชจ๋ฅด๊ฒ ์œผ๋‹ˆ onOrder ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ด ๊ฐ€๊ฒฉ์ด 100์”ฉ ๋”ํ•ด์ง€๊ฒŒ๋„ ๋งŒ๋“ฆ.

// MenuVC
class MenuViewController: UIViewController {
    // MARK: - Life Cycle
    
    // viewModel ์ธ์Šคํ„ด์Šค ์ƒ์„ฑํ•ด์„œ ์ •๋ณด ๋ฐ›์•„์˜ค๊ธฐ
    let viewModel = MenuListViewModel();
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        updateUI()
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        let identifier = segue.identifier ?? ""
        if identifier == "OrderViewController",
            let orderVC = segue.destination as? OrderViewController {
            // TODO: pass selected menus
        }
    }

    func showAlert(_ title: String, _ message: String) {
        let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alertVC.addAction(UIAlertAction(title: "OK", style: .default))
        present(alertVC, animated: true, completion: nil)
    }

    // MARK: - InterfaceBuilder Links

    @IBOutlet var activityIndicator: UIActivityIndicatorView!
    @IBOutlet var tableView: UITableView!
    @IBOutlet var itemCountLabel: UILabel!
    @IBOutlet var totalPrice: UILabel!

    @IBAction func onClear() {
    }

    @IBAction func onOrder(_ sender: UIButton) {
        // TODO: no selection
        // showAlert("Order Fail", "No Orders")
//        performSegue(withIdentifier: "OrderViewController", sender: nil)
        viewModel.totalPrice += 100
        updateUI()
    }
    
    // updateUI๋กœ ๋”ฐ๋กœ ํ•จ์ˆ˜๋ฅผ ๋บ์Œ. ๊ทผ๋ฐ ์šฐ๋ฆฌ๊ฐ€ ์ด๊ฑธ ๋งค๋ฒˆ ํ˜ธ์ถœํ•ด์ค˜์•ผ๋˜๋ƒ..? ์‹ซ๋‹ค.
    func updateUI() {
        totalPrice.text = "\(viewModel.totalPrice)"
        itemCountLabel.text = "\(viewModel.itemsCount)"
    }
}

extension MenuViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.menus.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MenuItemTableViewCell") as! MenuItemTableViewCell
        
        let menu = viewModel.menus[indexPath.row]
        cell.title.text = "\(menu.name)"
        cell.price.text = "\(menu.price)"
        cell.count.text = "\(menu.count)"

        return cell
    }
}

5. ๋งค๋ฒˆ updateUI() ๋ถˆ๋Ÿฌ์•ผ ํ•ด? ์‹ซ์–ด!! viewModel์ด ๋ฐ”๋€Œ๋ฉด view๋„ ์•Œ์•„์„œ ๊ฐ’์ด ๋ฐ”๋€Œ์–ด๋ด๋ผ!! -> Observable์„ ์ด์šฉํ•œ๋‹ค.

// ์—ฌ๊ธฐ๋ถ€ํ„ฐ๋Š” ๋ฐ”๋€Œ๋Š” ๋ถ€๋ถ„๋งŒ ํ•„๊ธฐ.
class MenuListViewModel {
    let menus: [Menu] = [
        Menu(name: "๋ณด์•„", price: 1105, count: 0),
        Menu(name: "ํƒœ์—ฐ", price: 309, count: 0),
        Menu(name: "ํšจ์—ฐ", price: 922, count: 0),
        Menu(name: "์Šฌ๊ธฐ", price: 210, count: 0),
        Menu(name: "์›ฌ๋””", price: 224, count: 0),
        Menu(name: "์นด๋ฆฌ๋‚˜", price: 411, count: 0),
        Menu(name: "์œˆํ„ฐ", price: 101, count: 0)
    ]
    
    var itemsCount: Int = 5
    var totalPrice: Observable<Int> = Observable.just(10000)
}
// MenuVC
var viewModel = MenuListViewModel()
var disposeBag = DisposeBag()

override func viewDidLoad() {
    super.viewDidLoad()
    viewModel.totalPrice
        .map { $0.currencyKR() }
        .subscribe(onNext: {
            self.totalPrice.text = $0
        })
        .disposed(by: disposeBag)
}

@IBAction func onOrder(_ sender: UIButton) {
    // TODO: no selection
    // showAlert("Order Fail", "No Orders")
    // performSegue(withIdentifier: "OrderViewController", sender: nil)
    
    // viewModel.totalPrice += 100
    // ๊ทผ๋ฐ.. viewMode.totalPrice (Observable)์— ๊ฐ’์„ ์–ด๋–ป๊ฒŒ ๋ณด๋‚ผ๊ฑด๋ฐ..?
    // Subject๋ฅผ ์‚ฌ์šฉํ•˜์ž.
    // ๊ทผ๋ฐ onNext ๋Š” ๊ฐ’์„ ๋ณด๋‚ด๊ธฐ๋งŒ ํ•˜๋Š”๋ฐ ์–ด๋–ป๊ฒŒ ์Œ“์ง€? -> scan
    viewModel.totalPrice.onNext(100)
}

6. PublishSubject : updateUI๋ฅผ ๊ฐ€๊ฒฉ์ด ๋ฐ”๋€Œ๋Š” ๋ชจ๋“  ๋กœ์ง ์•ˆ์— ์“ฐ์ง€ ์•Š์•„๋„, viewDidLoad์—์„œ subscribe ํ•œ๋ฒˆ๋งŒ ํ•ด์ฃผ๋ฉด ๊ฐ’์ด ์ž๋™์œผ๋กœ ๋ฐ”๋€๋‹ค.

subject : observer๊ฐ€ ๊ฐ’์„ ๋ฐ›์•„์˜ฌ ์ˆ˜๋„ ์žˆ๊ณ  ๋ณด๋‚ผ ์ˆ˜๋„ ์žˆ๋‹ค

// MenuListViewModel
class MenuListViewModel {
    let menus: [Menu] = [
        // ์ƒ๋žต
    ]
    
    var itemsCount: Int = 5
    var totalPrice: PublishSubject<Int> = PublishSubject()
}


// MenuVC
override func viewDidLoad() {
    super.viewDidLoad()

    // ์ฒ˜์Œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ.
    viewModel.totalPrice
        .scan(0, accumulator: +) // 100 ๋ณด๋‚ด๊ธฐ๋งŒ ํ•˜๋Š”๊ฑธ ์Œ“์•„์คŒ.
        .map { $0.currencyKR() }
        .subscribe(onNext: {
            self.totalPrice.text = $0
        })
        .disposed(by: disposeBag)
}

@IBAction func onOrder(_ sender: UIButton) {
    // Subject๊ฐ€ ๋“ฑ์žฅ!
    viewModel.totalPrice.onNext(100) // 100์„ ๋ณด๋‚ด๊ธฐ๋งŒ ํ•จ 
}

๊ทธ๋Ÿฌ๋ฉด menuList๋ฅผ Subject๋กœ ๋ฐ”๊พธ๊ณ  ์ง€์ผœ๋ณด๋‹ค๊ฐ€ itemsCount, totalPrice๋ฅผ ๊ฑฐ๊ธฐ์„œ ๊ฐ€์ง€๊ณ  ์˜ค๋ฉด ๋˜๊ฒ ๋„ค.

class MenuListViewModel {
    
    // ๊ทธ๋Ÿฌ๋ฉด..? ๋ฉ”๋‰ด ๋ฆฌ์ŠคํŠธ๋ฅผ ์ง€์ผœ๋ณด๋‹ค๊ฐ€ ๊ฐ’์„ ๊ฐ€์ง€๊ณ  ์˜ค๋ฉด ๋˜๊ฒ ๋„ค
    // lazy var menuObservable = Observable.just(menus)
    // ๊ทผ๋ฐ ๋ฉ”๋‰ด๋Š” ์ •๋ณด๋ฅผ ๋ฐ›์•„์„œ ๋ฐ”๋€” ์ˆ˜๋„ ์žˆ์–ด์•ผ์ง€?
    // init์œผ๋กœ menu array๋ฅผ ๋นผ๋ฉด์„œ lazy๋„ ๋บ„ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค.
    var menuObservable = PublishSubject<[Menu]>()
    
    lazy var itemsCount = menuObservable.map { menu in
        menu.map { $0.count }.reduce(0, +)
    }
    lazy var totalPrice = menuObservable.map { menu in
        $0.map { $0.price * $0.count }.reduce(0, +)
    }
    
//    Subject : ๊ฐ’์„ ๋ณด๋‚ด์ค„ ์ˆ˜๋„ ์žˆ๊ณ , ๊ฐ’์„ ๋ฐ›์•„์˜ฌ ์ˆ˜๋„ ์žˆ์Œ.
    
    init() {
        let menus: [Menu] = [
            Menu(name: "์˜ค์ง€์ง€", price: 15000, count: 0),
            Menu(name: "SMTOWN", price: 39000, count: 0),
            Menu(name: "์˜ค์ง€์ง€", price: 12000, count: 0),
            Menu(name: "์˜ค์ง€์ง€", price: 12000, count: 0),
            Menu(name: "์˜ค์ง€์ง€", price: 12000, count: 0),
            Menu(name: "์˜ค์ง€์ง€", price: 12000, count: 0)
        ]
        
        menuObservable.onNext(menus)
    }   
}

์ด๋Ÿฐ ์—ฐ๊ฒฐ ๊ด€๊ณ„๋ฅผ stream ์ด๋ผ๊ณ  ๋ถ€๋ฅธ๋‹ค.

7. RxCocoa ๋กœ label, tableView binding

1. ์œ„์—์„œ viewModel ์˜ data๋“ค์„ ๋ชจ๋‘ Subject ๋กœ ๋ฐ”๊พธ์—ˆ์œผ๋‹ˆ, view์—์„œ๋„ ๊ทธ๊ฒƒ์— ๋งž์ถฐ์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์„œ ํ™”๋ฉด์— ๋‚˜ํƒ€๋‚ด์•ผ ํ•œ๋‹ค.

subscribe๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ฌ ์ˆ˜๋„ ์žˆ์ง€๋งŒ

// viewDidLoad
viewModel.itemsCount
	.map { "\($0)" }
	.subscribe(onNext: {
		itemCountLabel.text = $0
	})
	.disposed(by: disposeBag)

RxCocoa ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ bind ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ๋‹ค!

// RxCocoa : RxSiwft ๊ธฐ๋Šฅ์„ Extension ์œผ๋กœ UI ์— ์ถ”๊ฐ€ํ•œ ๊ฒƒ.
// ์ผ๋ฐ˜ text์™€์˜ ์ฐจ์ด์ ์€ bind ์ธ๋ฐ,
// subscribe ๋Œ€์‹  bind(to: itemCountLable.rx.text) ๋ฅผ ํ•˜๋ฉด,
// ์ˆœํ™˜ ์ฐธ์กฐ๊ฐ€ ์•ˆ์ƒ๊ธด๋‹ค.
// ๋Œ€์‹  UI ์ž‘์—…์ด๊ธฐ ๋•Œ๋ฌธ์— ๊ผญ MainScheduler๋กœ ๋ฐ”๊พธ๊ณ  ๋™์ž‘ํ•˜๊ฒŒ ํ•ด์•ผํ•œ๋‹ค.
viewModel.itemsCount
	.map { "\($0)" }
	.observeOn(MainScheduler.instance)
	.bind(to: itemCountLabel.rx.text)
	.disposed(by: disposeBag)

2. tableView binding : tableView item ๋„ bind ํ•  ์ˆ˜ ์žˆ๋‹ค.

// viewDidLoad
// bind ํ•  ๊ฒƒ ์ด๋ฏ€๋กœ dataSource ์—ฐ๊ฒฐ ๋Š๊ณ  ๊ด€๋ จ ์ฝ”๋“œ ์—†์• ๋„ ๋จ.
tableView.dataSource = nil

viewModel.menuObservable
    .bind(to: tableView.rx.items(cellIdentifier: "MenuItemTableViewCell", cellType: MenuItemTableViewCell.self)) { index, item, cell in
        //์•ž์˜ bind๋ฅผ ์‹คํ–‰ํ•˜๋ฉด์„œ tableView์˜ items์— cellId, cellType์„ ๋„ฃ์œผ๋ฉด
        // (ObservableType) -> ((@escaping (Int, Sequence.Element, Cell) -> Void) -> Disposable)
        // ์œ„์™€ ๊ฐ™์€ escaping closure ๋ฅผ ๋ฆฌํ„ดํ•˜๊ฒŒ ๋จ.
        //  ๊ทธ๋Ÿฌ๋ฉด ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ ํ•œ ๋‹ค์Œ์— index, item, cell ์„ ๊ฐ€์ง€๊ณ  tableView์˜ item์— ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋‹ค.
        cell.title.text = item.name
        cell.price.text = "\(item.price)"
        cell.count.text = "\(item.count)"
    }

๊ทธ๋Ÿฐ๋ฐ..? ์‹คํ–‰์€ ๋˜๋Š”๋ฐ ํ…Œ์ด๋ธ”๋ทฐ์— ๋ฐ์ดํ„ฐ๊ฐ€ ์•„๋ฌด ๊ฒƒ๋„ ์•ˆ๋‚˜์˜จ๋‹ค... menuObservable ์ด PublishSubject๋ผ์„œ ๊ทธ๋ ‡๋‹ค!!

8. Behavior Subject : ๋ฐ์ดํ„ฐ๋Š” ์ด๋ฏธ ์ƒ์„ฑ์ด ๋˜์—ˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” ๊ทธ ํ›„์— menuObservable์„ subscribe ํ•ด์˜ค๋‹ˆ๊นŒ ๊ทธ ์ด์ „์— ์ƒ์„ฑ๋œ ๋ฐ์ดํ„ฐ๋Š” ๋ฐ›์•„์˜ฌ ์ˆ˜๊ฐ€ ์—†๋‹ค. Behavior Subject๋กœ ๋ฐ”๊พผ๋‹ค.

// MenuListViewModel
var menuObservable = BehaviorSubject<[Menu]>(value: [])

์ œ๋Œ€๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•ด๋ณด๊ธฐ ์œ„ํ•ด order ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด tableView ์•„์ดํ…œ๋“ค์ด ๋ฐ”๋€Œ๊ฒŒ ๋งŒ๋“ค์–ด๋ณด์ž

// MenuVC
@IBAction func onOrder(_ sender: UIButton) {
    viewModel.menuObservable.onNext([
        Menu(name: "a", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
        Menu(name: "b", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
        Menu(name: "c", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
    ])
}

menuObservable์— ๋ฐฐ์—ด๋งŒ ๋ฐ”๊ฟ”๋ณด๋ƒˆ๋Š”๋ฐ itemsCount, totalPrice๊นŒ์ง€ ์•Œ์•„์„œ ์ž˜ ๋ฐ”๋€๋‹ค.

9. ๋ฒ„ํŠผ ๋™์ž‘ํ•˜๊ฒŒ ๋งŒ๋“ค๊ธฐ

1. clear ๋ฒ„ํŠผ

// clear ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด count๊ฐ€ ๋‹ค 0์œผ๋กœ ๋ฐ”๋€Œ์–ด์•ผ ํ•œ๋‹ค.

// MenuVC
@IBAction func onClear() {
    viewModel.clearAllItemSelections()
}

// MenuListViewModel
func clearAllItemSelections() {
    // menu๋ฅผ ๋ฐ”๊ฟ”์•ผํ•˜๋‹ˆ๊นŒ menuObservable์„ ๊ฐ€์ ธ์™€์„œ menu count 0 ์œผ๋กœ ๋ฐ”๊พผ๋‹ค.
    menuObservable
        .map { menus in
            menus.map { m in
                Menu(name: m.name, price: m.price, count: 0)
            }
        }
        .take(1) // ๋ฒ„ํŠผ ๋ˆŒ๋ €์„ ๋•Œ ํ•œ๋ฒˆ ํ•˜๊ณ  ์ฃฝ์–ด์•ผํ•จ. ๊ณ„์† ๋‚จ์•„์žˆ์œผ๋ฉด์„œ clear ํ•˜๋ฉด ์•ˆ๋จ.
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
}

2. + - ๋ฒ„ํŠผ : cell ์•ˆ์— ์žˆ์œผ๋ฏ€๋กœ, cell์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผํ•œ๋‹ค.

๋ฐฉ๋ฒ•1. viewModel์„ cell์— ๋ณ€์ˆ˜๋กœ ๋„˜๊ฒจ์ฃผ๊ณ  ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•˜๊ธฐ.
๋ฐฉ๋ฒ•2. + - ๋ฒ„ํŠผ์˜ IBAction ์•ˆ์—์„œ ์‹คํ–‰ํ•  ํ•จ์ˆ˜๋ฅผ tableView cell bind ์—์„œ ๋„˜๊ฒจ์„œ ์ฒ˜๋ฆฌ
๋ฐฉ๋ฒ•3. delegate

class MenuItemTableViewCell: UITableViewCell {
    @IBOutlet var title: UILabel!
    @IBOutlet var count: UILabel!
    @IBOutlet var price: UILabel!

    // ๋ฐฉ๋ฒ•1. viewModel์„ ๋„˜๊ฒจ์ฃผ๊ณ  ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•˜๋˜๊ฐ€.
    var viewModel: MenuListViewModel! //์„ ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜.

    // ๋ฐฉ๋ฒ•2. ์‰ฌ์šด ๋ฐฉ๋ฒ•์€ onChange๋ฅผ ๋„˜๊ฒจ์„œ ์ฒ˜๋ฆฌ.
    var onChange: ((Int) -> Void)?

    @IBAction func onIncreaseCount() {
        onChange?(+1)
    }
    @IBAction func onDecreaseCount() {
        onChange?(-1)
    }
}

// MenuVC > viewDidLoad
viewModel.menuObservable
    .bind(to: tableView.rx.items(cellIdentifier: "MenuItemTableViewCell", cellType: MenuItemTableViewCell.self)) { index, item, cell in
        cell.title.text = item.name
        cell.price.text = "\(item.price)"
        cell.count.text = "\(item.count)"
        
        cell.onChange = { [weak self] increase in
            self?.viewModel.changeCount(item: item, increase: increase)
        }
    }
    .disposed(by: disposeBag)
    
// MenuListViewModel
func changeCount(item: Menu, increase: Int) {
    _ = menuObservable
        .map { menus in
            menus.map { menu in
                // menu์— id ์ถ”๊ฐ€ํ•ด์„œ ๊ด€๋ จ๋œ ๋ถ€๋ถ„ ๋ฐ”๊พธ์ž.
                if menu.id == item.id {
                    return Menu(id: menu.id, name: menu.name, price: menu.price, count: menu.count + increase)
                } else {
                    return Menu(id: menu.id, name: menu.name, price: menu.price, count: menu.count)
                }
            }
        }
        .take(1)
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
}

10. fetch

์ง€๊ธˆ๊นŒ์ง€ ์ง  ๋ฐฉ์‹์œผ๋กœ ์ฝ”๋“œ๋ฅผ ์งœ๋ฉด viewModel๋งŒ์œผ๋กœ test๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด ๋œ๋‹ค. test case๋ฅผ ๋งŒ๋“ค๊ธฐ๊ฐ€ ํ›จ์”ฌ ์‰ฌ์›Œ์ง„๋‹ค. ์•ž์„œ ๊ฐ€์ •ํ•œ ๊ฒƒ์ฒ˜๋Ÿผ API๊ฐ€ ๋‚˜์˜ค๊ธฐ ์ „์— ์•ฑ๋ถ€ํ„ฐ ์ œ์ž‘ํ•  ๋•Œ, API๊ฐ€ ์ถ”ํ›„์— ๋‚ด๋ ค์˜ค๋ฉด model -> viewModel๋กœ ํŽ˜์นญ์„ ํ•œ๋ฒˆ ํ•ด์ฃผ๋ฉด ๋˜๊ณ , ๋กœ์ง์— ์ˆ˜์ •์‚ฌํ•ญ์ด ์ƒ๊ธฐ๋ฉด viewModel๋งŒ ๊ฑด๋“œ๋ฆฌ๋ฉด ๋œ๋‹ค.

// APIService
import Foundation

let MenuUrl = "https://firebasestorage.googleapis.com/v0/b/rxswiftin4hours.appspot.com/o/fried_menus.json?alt=media&token=42d5cb7e-8ec4-48f9-bf39-3049e796c936"

class APIService {
    // ๊ธฐ์กด fetch ์ฝ”๋“œ
    static func fetchAllMenus(onComplete: @escaping (Result<Data, Error>) -> Void) {
        URLSession.shared.dataTask(with: URL(string: MenuUrl)!) { data, res, err in
            if let err = err {
                onComplete(.failure(err))
                return
            }
            guard let data = data else {
                let httpResponse = res as! HTTPURLResponse
                onComplete(.failure(NSError(domain: "no data",
                                            code: httpResponse.statusCode,
                                            userInfo: nil)))
                return
            }
            onComplete(.success(data))
        }.resume()
    }
    
    
    // ๊ทผ๋ฐ ๊ทธ๋Ÿฌ๋ฉด ๊ธฐ์กด ํ”„๋กœ์ ํŠธ๋ฅผ Rx๋กœ ๋ฆฌํŒฉํ† ๋งํ•  ๋•Œ ์•„์˜ˆ ๋‹ค ์ƒˆ๋กœ๋งŒ๋“œ๋‚˜์š”?
    // ์•„๋‹ˆ์š”! ๊ธฐ์กด fetch ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.
    // legacy๋ฅผ rx๋กœ ๊ฐ์‹ธ์„œ ๋ฆฌํŒฉํ† ๋งํ•˜๋ฉด ๋œ๋‹ค.
    static func fetchAllMenusRx() -> Observable<Data> {
        return Observable.create() { emmiter in
            fetchAllMenus { result in
                switch result {
                case .success(let data):
                    emitter.onNext(data)
                    emitter.onCompleted()
                case .failure(let err):
                    emitter.onError(err)
            }
            return Disposables.create()
        }
    }
}
// MenuListViewModel
    init() {
        _ = APIService.fetchAllMenusRx()
            .map { data -> [MenuItem] in
                struct Response: Decodable {
                    let menus: [MenuItem]
                }
                let response = try! JSONDecoder().decode(Response.self, from: data)
                return response.menus
            }
            // menuItem -> menu 
            // menu์— extension ์ž‘์„ฑํ•ด์„œ ๋ฐ”๊พผ๋‹ค.
            .map { menuItems in
                var menus: [Menu] = []
                // ์›๋ž˜ ์—ฌ๊ธฐ๋„ map์œผ๋กœ ํ•˜๋ ค๊ณ  ํ–ˆ์œผ๋‚˜ id์— index ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ enumerated().forEach ์‚ฌ์šฉ
                menuItems.enumerated().forEach { (index, menuItem) in
                    let menu = Menu.fromMenuItems(id: index, item: menuItem)
                    menus.append(menu)
                }
                
                return menus
            }
            .take(1)
            .bind(to: menuObservable)
    }
// model -> view๋ฅผ ์œ„ํ•œ model ๋กœ ๋ฐ”๊พธ๋Š” ์ฝ”๋“œ.
extension Menu {
    static func fromMenuItems(id: Int, item: MenuItem) -> Menu {
        return Menu(id: id, name: item.name, price: item.price, count: 0)
    }
}

์ด๋ ‡๊ฒŒ ์šฐ๋ฆฌ๋Š” ์ฝ”๋“œ๋ฅผ ๋‹ค ์ž‘์„ฑํ•˜๊ณ  API๋ฅผ ๋ฐ›์€ ํ›„, fetch, init, extension๋งŒ ์ถ”๊ฐ€ํ•ด์„œ ์ž˜ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋Š” ์•ฑ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์—ˆ๋‹ค.




์ถœ์ฒ˜

์œ ํŠœ๋ธŒ ๊ณฐํŠ€๊น€๋‹˜ RxSwift 4์‹œ๊ฐ„์— ๋๋‚ด๊ธฐ
https://youtu.be/iHKBNYMWd5I

RxSwift 4์‹œ๊ฐ„์— ๋๋‚ด๊ธฐ github
https://github.com/iamchiwon/RxSwift_In_4_Hours

profile
0๋…„์ฐจ iOS ๊ฐœ๋ฐœ์ž์ž…๋‹ˆ๋‹ค.

0๊ฐœ์˜ ๋Œ“๊ธ€