ํ๊น ๋ฆฌ์คํธ๋ฅผ json์ผ๋ก ๋ถ๋ฌ์ค๊ณ , ํ๋์ฉ ์ถ๊ฐํ ๋ ์์ฒญ์ ๋ณด๋ด์ ๋ง์ง๋ง์ ๊ฐ๊ฒฉ๊น์ง ๊ณ์ฐํด์ฃผ๋ ๊ฐ๋จํ(?) ์ดํ๋ฆฌ์ผ์ด์ ์ ๋ง๋ค์๋ค.
๊ณฐํ๊น๋ 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
์ด๋ฐ ๊ตฌ์กฐ๋ก ์ด๋ฃจ์ด์ ธ ์๋ค.
์ด๋ก ์ ์ผ๋ก ํด๋ผ์ด์ธํธ ๋จ์ API, ๋์์ธ์ด ๋์ค๊ณ ๋์ ๊ฐ๋ฐ์ ์งํํ์ง๋ง, ์ค๋ฌด์์๋ ๊ทธ๋ ์ง ์์ ๊ฒฝ์ฐ๊ฐ ๋ง๋ค. ์ฐ๋ฆฌ๋ API๋ ๋์์ธ๋ ๋ชจ๋ฅด๋ ์ํ์์ ๊ธฐํ์๋ง ๋ณด๊ณ ๊ฐ๋ฐ์ ์งํํ๋ ๊ฒ์ด๋ค. API๋ ๋์ค์ ์ฐ๊ฒฐํ๊ณ , ๋์์ธ๋ ๋์ค์ ๋ฃ์ ๊ฒ. -> ์ต์ ์ ๊ตฌ์กฐ MVVM~~
ViewController == VC
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
}
}
// 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
}
// 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
}
}
// ์ฌ๊ธฐ๋ถํฐ๋ ๋ฐ๋๋ ๋ถ๋ถ๋ง ํ๊ธฐ.
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)
}
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 ์ด๋ผ๊ณ ๋ถ๋ฅธ๋ค.
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)
// 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๋ผ์ ๊ทธ๋ ๋ค!!
// 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๊น์ง ์์์ ์ ๋ฐ๋๋ค.
// 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)
})
}
๋ฐฉ๋ฒ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)
})
}
์ง๊ธ๊น์ง ์ง ๋ฐฉ์์ผ๋ก ์ฝ๋๋ฅผ ์ง๋ฉด 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