Server Driven UI μ λν μ λ¬Έ κΈ
λ°±μλ λ°μ΄ν°μ μν΄ λ³ννλ UI λ§λ€κΈ°
β μ΄λ κ² νλ©΄ μ± μ
λ°μ΄νΈ μμ΄ μ μ μκ² λ³κ²½λ UI μ 곡 κ°λ₯
μ¬λ¬κ°μ§ μ’ λ₯μ TableView Cell λ€μ νλ‘ν μ½λ‘ λ¬Άμ΄ CommonCell ν κ°λ‘ μΆμννκΈ°
Swift μμ UITableView λ ν
μ΄λΈ ννμ λ·°λ₯Ό λνλ΄λ UIKit ν΄λμ€μ
λλ€.
cell μ΄ λͺ¨λ λΉμ·ν ννλ‘ μκ²Όκ³ , μλλ‘μ΄λμ RecyclerView μ²λΌ cell λ€μ νλνλ μμ±νμ§ μκ³ μ¬νμ©ν΄μ λ©λͺ¨λ¦¬ ν¨μ¨μ λμΈλ€λ νΉμ§μ κ°μ΅λλ€.
νμ§λ§ μ΄λ° κ²½μ°κ° μμ μ μμ΅λλ€.
μ΄λ€ μ μμλ Main Text Label ν μ€λ§ μΆλ ₯νκ³ μΆκ³ ,
μ΄λ€ μ μμλ Main, Sub Text Label λ κ°μ μ€μ μΆλ ₯ νκ³ μΆκ³ ,
μ΄λ€ μ μμλ ν μ€νΈκ° μλ μ΄λ―Έμ§λ₯Ό μΆλ ₯ν΄μ£Όκ³ μΆλ€λ©΄ ?
κ°κ°μ OneLineTextView , TwoLineTextView , OneImageView νμ
μ μ
μ΄λΌκ³ νκ² μ΅λλ€.
νλμ UITableView μμμ μ΄λ»κ² Cell μ Custom ν΄μΌ μ΄λ° λ©ν° μ
νμ
μ ν
μ΄λΈ λ·°λ₯Ό Common νκ² ν¨μ¨μ μΌλ‘ ꡬνν μ μμκΉμ λν λ΄μ©μ κΈ°λ‘ν©λλ€.
Table Cellλ€μ λν μ μμ, κ° Cellλ€μ λν UI λ₯Ό μ μνλ μ½λλ₯Ό νλ² μ§λλ©΄, κ·Έ Cell λ€μ data source λ‘ μ¬μ©νλ λͺ¨λ UITableView μμ μ¬νμ©ν΄μ μΈ μ μλλ‘ ν©λλ€.
λ°±μλμμ json μ λ΄λ €μ€λ UI μ κ΄λ ¨λ λ°μ΄ν°λ€μ λ΄μμ 보λ΄μ£Όκ³ , ν΄λΌμ΄μΈνΈμμλ κ·Έ λ°μ΄ν°μ κΈ°λ°ν΄μ UI λ₯Ό ꡬμ±ν μ μμ΅λλ€.
μλ₯Ό λ€μ΄, λΌλ²¨μ ꡬμ±ν΄μΌνλλ° λ°±μλμμ text, text size, text color, text style λ±μ λ΄λ €μ€λ€λ©΄ κ·Έκ±Έ κ·Έλλ‘ λ°μμ λμμ€ μ μμ΅λλ€.
μ΄λ―Έμ§λ·°λ₯Ό ꡬμ±ν λ λ°±μλμμ image url, image size λ₯Ό ν¨κ» λ΄λ €μ€λ€λ©΄ μ΄λ―Έμ§μ μ΄λ―Έμ§ μ¬μ΄μ¦λ₯Ό λͺ¨λ λ°±μλ λ°μ΄ν°μ μμ‘΄ν UI λ₯Ό λ§λ€ μ μμ΅λλ€.
μ΄λ κ² Server Driven UI λ₯Ό ꡬννμ λμ μ₯μ λ€μ λλ€.
μΏ ν‘, μΈμ€νκ·Έλ¨κ³Ό κ°μ μ±λ€μ μ΄λ κ² ν΄λΌμ΄μΈνΈ μ½λκ° μλ λ°±μλ μ½λμ μμ‘΄ν UI λ₯Ό μ λ§ μ λ§λ€μ΄ λμμ΅λλ€. κ·Έλμ μ΄λλ μ μ κ° μ λ°μ΄νΈλ₯Ό νμ§λ μμλλ°λ UI κ° λ°λμ΄μλ κ²½νμ ν μ μμ΅λλ€.
μ§κΈ μ΄μΌκΈ°νκ³ μλ Multi Cell Type Custom Table View λ μ΄ Server Driven UI λ₯Ό λ ν¨μ¨μ μΌλ‘ νμ©νκΈ° μν΄ νμν©λλ€.
μλ₯Ό λ€μ΄, μλ²μμ μ΄λ° json data κ° λ¨μ΄μ§λ€κ³ ν΄λ΄ λλ€.
{
"viewItems": [
{
"viewType": "ONE_LINE_TEXT",
"viewObject": {
"text1": "μΉΌκ΅μ λ©λ΄"
}
},
{
"viewType": "ONE_IMAGE",
"viewObject": {
"url": "https://ifh.cc/g/AowvTF.jpg"
}
},
{
"viewType": "TWO_LINE_TEXT",
"viewObject": {
"text1": "λ€κΉ¨ μΉΌκ΅μ",
"text2": "8000μ"
}
}
...
]
}
"viewItems" μ λ€μ΄μλ κ° ViewItem λ€μ ν
μ΄λΈ λ·°μ μ
μ΄ λ©λλ€.
ViewItem μλ, μμμ μ΄μΌκΈ° νλ 3κ°μ§ μ
νμ
μ€ μ΄λ€ νμ
μ μ±νν κ±΄μ§ μλ―Ένλ "viewType" κ³Ό, κ·Έ μ
μ μλ§λ λ·° λ°μ΄ν°λ₯Ό λ΄κ³ μλ "viewObject" κ° μμ΅λλ€. viewObject μ ννλ λͺ¨λ λ€λ¦
λλ€.
μ°λ¦¬λ ONE_LINE_TEXT, TWO_LINE_TEXT, ONE_IMAGE λΌλ μ’ λ₯μ λ·°νμ λ€μ μ¬μ©νκΈ°λ‘ μ½μνμ΄.
μ΄λ κ² λ°±μλμ ν΄λΌμ΄μΈνΈκ° λ·°νμ
μ μ½μνκΈ°λ§ νλ©΄,
ν΄λΌμ΄μΈνΈμμλ κ°κ°μ λ·°νμ
μ ꡬνν΄λκ³ , λ°±μλλ κ·Έ λ·°νμ
λ΄μμ μΌλ§λμ§ λ°μ΄ν°λ‘ UI λ₯Ό λ³κ²½ν μ μκ² λ©λλ€.
μμ 1) "[λ€κΉ¨ μΉΌκ΅μ , 8000μ] μ κ°κ° λ€λ₯Έ μ μ λ£κ³ μΆμ΄μ‘μ΄.." λΌκ³ μκ°νλ©΄, λ°±μλμμ viewTypeμ "ONE_LINE_TEXT"λ‘ λ³κ²½νκ³ text1 μ λλ² λ΄μμ λ΄λ €μ£Όλ©΄ λ©λλ€.
μμ 2) "κ°μκΈ° μ¬μ§λ³΄λ€ κ°κ²©μ λ¨Όμ 보μ¬μ£Όκ³ μΆμ΄μ‘μ΄.." λΌκ³ μκ°νλ©΄ μ± μ λ°μ΄νΈλ₯Ό ν νμ μμ΄ λ°±μλμμ κ·Έμ json λ°μ΄ν°μ 2λ²μ§Έμ 3λ²μ§Έ λ°μ΄ν° μμλ₯Ό λ°κΏμ λ΄λ €μ£ΌκΈ°λ§ νλ©΄ λ©λλ€.
κ·μΉμ μ΄μ§ μμ μ json data λ₯Ό μ΄λ κ² νμ±ν μ μμ΅λλ€.
Decodable νλ‘ν μ½μ μ€μνλ ViewObject λΌλ λΆλͺ¨ ν΄λμ€λ₯Ό λ§λ€κ³ , OneLineViewObject, TwoLineViewObject, OneImageViewObject ν΄λμ€λ€μ μμμΌλ‘ λ§λ€μ΄ ViewObject λ₯Ό μμλ°μ΅λλ€.
import Foundation
class Entity: Decodable {
let viewItems: [ViewItem]
}
class ViewItem: Decodable {
let viewType: String?
// ViewObject -> OneLineViewObject, TwoLineViewObject, OneImageViewObject
var viewObject: ViewObject?
enum CodingKeys: String, CodingKey {
case viewType, viewObject
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
viewType = try container.decode(String.self, forKey: .viewType)
viewObject = nil
switch viewType {
case ViewType.oneLine.rawValue:
viewObject = try container.decode(OneLineViewObject.self, forKey: .viewObject)
case ViewType.twoLine.rawValue:
viewObject = try container.decode(TwoLineViewObject.self, forKey: .viewObject)
case ViewType.oneImage.rawValue:
viewObject = try container.decode(OneImageViewObject.self, forKey: .viewObject)
default:
return
}
}
}
class ViewObject : Decodable { }
class OneLineViewObject: ViewObject {
var text1: String?
enum Keys: String, CodingKey {
case text1
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Keys.self)
self.text1 = try container.decode(String.self, forKey: .text1)
let superDecoder = try container.superDecoder()
try super.init(from: superDecoder)
}
}
...
OneLineViewObject μ λ°μ΄ν°λ ν μ΄λΈ μ OneLineCell μ λ§λλλ€. μ΄λ° OneLineCell, TwoLineCell, OneImageCell ν΄λμ€λ€μ CommonCell μ΄λΌλ νλ‘ν μ½λ‘ λ¬Άμ΄ μΆμνν©λλ€.
// CommonCell -> OneLineCell, TwoLineCell, OneImageCell
protocol CommonCell: UITableViewCell {
func bind(data: ViewObject)
func initCellUI()
}
...
class OneLineCell: UITableViewCell, CommonCell {
var label = UILabel()
func bind(data: ViewObject) {
guard let oneLineData = data as? OneLineViewObject else { return }
label.text = oneLineData.text1
label.textColor = .blue
label.font = UIFont.boldSystemFont(ofSize: 30)
}
func initCellUI() {
addSubview(label)
label.snp.makeConstraints {
$0.top.bottom.equalToSuperview()
$0.centerX.equalToSuperview()
}
}
}
...
Common Cell Factory λ Common Cell μ λ§λ€μ΄ λ°°μΆνλ 곡μ₯μ λλ€. μ΄ Factory λ₯Ό νμ©ν¨μΌλ‘μ¨ OCP, Open Closed Principle λ₯Ό λ§μ‘±μμΌ μ€ μ μμ΅λλ€. (νμ₯μ μμ΄μλ μ΄λ € μμ΄μΌ νλ©°, μμ μ μμ΄μλ λ«ν μμ΄μΌ νλ€)
class CommonCellFactory {
public static func makeCell(tableView: UITableView, indexPath: IndexPath, viewItem: ViewItem)
-> CommonCell?
{
guard let viewTypeID = viewItem.viewType else { return nil }
guard let viewObject = viewItem.viewObject else { return nil }
switch viewTypeID {
case ViewType.oneLine.rawValue:
let cell = tableView.dequeueReusableCell(withIdentifier: viewTypeID, for: indexPath) as! OneLineCell
cell.bind(data: viewObject)
cell.initCellUI()
return cell
case ViewType.twoLine.rawValue:
let cell = tableView.dequeueReusableCell(withIdentifier: viewTypeID, for: indexPath) as! TwoLineCell
cell.bind(data: viewObject)
cell.initCellUI()
return cell
case ViewType.oneImage.rawValue:
let cell = tableView.dequeueReusableCell(withIdentifier: viewTypeID, for: indexPath) as! OneImageCell
cell.bind(data: viewObject)
cell.initCellUI()
return cell
default:
return nil
}
}
}
UITableView λ₯Ό νμ©νκΈ° μν΄μλ UITableViewDataSource νλ‘ν μ½μ μ±νν΄μΌν©λλ€. κ·Έλμ 보ν΅μ ViewController μμ extension μΌλ‘ delegate λ©μλλ₯Ό μ¬μ©ν©λλ€.
μ λ UITableViewDataSource λ 컀μ€ν ν΄μ λ§λ€μ΄λ΄€μ΅λλ€.
import UIKit
class CustomTableViewDataSource: NSObject, UITableViewDataSource {
var data = [ViewItem]()
init(data: [ViewItem]) {
super.init()
self.data = data
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
// Common Cell Factory μ¬μ©
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = CommonCellFactory.makeCell(tableView: tableView, indexPath: indexPath, viewItem: data[indexPath.row]) else { return UITableViewCell() }
return cell
}
}
κ·Έλ¦¬κ³ μ΄ DataSource λ₯Ό CustomTableView μμ μ±νν΄μ€λλ€.
class CustomTableView: UITableView {
var customDataSource: CustomTableViewDataSource!
override init(frame: CGRect, style: UITableView.Style) {
super.init(frame: frame, style: style)
}
convenience init(viewController: UIViewController, data: [ViewItem]) {
self.init(frame: CGRect.zero, style: .plain)
registerCells(viewController)
setDatasource(viewController, data)
}
func registerCells(_ viewController: UIViewController) {
register(OneLineCell.self, forCellReuseIdentifier: ViewType.oneLine.rawValue)
register(TwoLineCell.self, forCellReuseIdentifier: ViewType.twoLine.rawValue)
register(OneImageCell.self, forCellReuseIdentifier: ViewType.oneImage.rawValue)
}
func setDatasource(_ viewController: UIViewController, _ data: [ViewItem]) {
// custom data source μ±ν
customDataSource = CustomTableViewDataSource(data: data)
self.dataSource = customDataSource
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
μ΄μ Main ViewController μμ 컀μ€ν ν μ΄λΈ λ·°λ₯Ό μ¬μ©ν μ μμ΅λλ€.
import UIKit
import SnapKit
class MainViewController: UIViewController {
lazy var customTableView = CustomTableView()
override func viewDidLoad() {
super.viewDidLoad()
setTableView()
initUI()
}
func setTableView() {
let data = Data(jsonData.utf8)
guard let parsedData = try? JSONDecoder().decode(Entity.self, from: data) else { return }
customTableView = {
let tableView = CustomTableView(viewController: self, data: parsedData.viewItems)
return tableView
}()
}
func initUI() {
...
}
}
}
json data
{ "viewItems": [ { "viewType": "ONE_LINE_TEXT", "viewObject": { "text1": "μΉΌκ΅μ λ©λ΄" } }, { "viewType": "ONE_IMAGE", "viewObject": { "url": "https://ifh.cc/g/AowvTF.jpg" } }, { "viewType": "TWO_LINE_TEXT", "viewObject": { "text1": "λ€κΉ¨ μΉΌκ΅μ", "text2": "8000μ" } }, { "viewType": "ONE_IMAGE", "viewObject": { "url": "https://ifh.cc/g/2lJZOQ.jpg" } }, { "viewType": "TWO_LINE_TEXT", "viewObject": { "text1": "λ°μ§λ½ μΉΌκ΅μ", "text2": "7000μ" } }, { "viewType": "ONE_IMAGE", "viewObject": { "url": "https://ifh.cc/g/GlMvRF.jpg" } }, { "viewType": "TWO_LINE_TEXT", "viewObject": { "text1": "μΌν° μΉΌκ΅μ", "text2": "8000μ" } }, { "viewType": "ONE_LINE_TEXT", "viewObject": { "text1": "λ§λ λ©λ΄" } }, { "viewType": "ONE_IMAGE", "viewObject": { "url": "https://ifh.cc/g/KBoPgM.jpg" } }, { "viewType": "TWO_LINE_TEXT", "viewObject": { "text1": "κ°λΉ λ§λ", "text2": "4000μ" } }, { "viewType": "ONE_IMAGE", "viewObject": { "url": "https://ifh.cc/g/aXCGY3.jpg" } }, { "viewType": "TWO_LINE_TEXT", "viewObject": { "text1": "μλ§λ", "text2": "4500μ" } } ] }
μ 체 μ½λ : https://github.com/heyksw/Multi-Cell-Type-TableView