Server Driven UI, Multi Cell Type Custom Table View

κΉ€μƒμš°Β·2022λ…„ 7μ›” 17일
2
  • Server Driven UI 에 λŒ€ν•œ μž…λ¬Έ κΈ€

  • λ°±μ—”λ“œ 데이터에 μ˜ν•΄ λ³€ν™”ν•˜λŠ” UI λ§Œλ“€κΈ°
    β†’ μ΄λ ‡κ²Œ ν•˜λ©΄ μ•± μ—…λ°μ΄νŠΈ 없이 μœ μ €μ—κ²Œ λ³€κ²½λœ UI 제곡 κ°€λŠ₯

  • μ—¬λŸ¬κ°€μ§€ μ’…λ₯˜μ˜ TableView Cell 듀을 ν”„λ‘œν† μ½œλ‘œ λ¬Άμ–΄ CommonCell ν•œ 개둜 μΆ”μƒν™”ν•˜κΈ°


UITableView

Swift μ—μ„œ UITableView λŠ” ν…Œμ΄λΈ” ν˜•νƒœμ˜ λ·°λ₯Ό λ‚˜νƒ€λ‚΄λŠ” UIKit ν΄λž˜μŠ€μž…λ‹ˆλ‹€.
cell 이 λͺ¨λ‘ λΉ„μŠ·ν•œ ν˜•νƒœλ‘œ 생겼고, μ•ˆλ“œλ‘œμ΄λ“œμ˜ RecyclerView 처럼 cell 듀을 ν•˜λ‚˜ν•˜λ‚˜ μƒμ„±ν•˜μ§€ μ•Šκ³  μž¬ν™œμš©ν•΄μ„œ λ©”λͺ¨λ¦¬ νš¨μœ¨μ„ λ†’μΈλ‹€λŠ” νŠΉμ§•μ„ κ°–μŠ΅λ‹ˆλ‹€.


Multi Cell Type Table View

ν•˜μ§€λ§Œ 이런 κ²½μš°κ°€ μžˆμ„ 수 μžˆμŠ΅λ‹ˆλ‹€.

μ–΄λ–€ μ…€μ—μ„œλŠ” Main Text Label ν•œ μ€„λ§Œ 좜λ ₯ν•˜κ³  μ‹Άκ³ ,
μ–΄λ–€ μ…€μ—μ„œλŠ” Main, Sub Text Label 두 개의 쀄을 좜λ ₯ ν•˜κ³  μ‹Άκ³ ,
μ–΄λ–€ μ…€μ—μ„œλŠ” ν…μŠ€νŠΈκ°€ μ•„λ‹Œ 이미지λ₯Ό 좜λ ₯ν•΄μ£Όκ³  μ‹Άλ‹€λ©΄ ?

각각을 OneLineTextView , TwoLineTextView , OneImageView νƒ€μž…μ˜ 셀이라고 ν•˜κ² μŠ΅λ‹ˆλ‹€.
ν•˜λ‚˜μ˜ UITableView μ•ˆμ—μ„œ μ–΄λ–»κ²Œ Cell 을 Custom ν•΄μ•Ό 이런 λ©€ν‹° μ…€νƒ€μž…μ˜ ν…Œμ΄λΈ” λ·°λ₯Ό Common ν•˜κ²Œ 효율적으둜 κ΅¬ν˜„ν•  수 μžˆμ„κΉŒμ— λŒ€ν•œ λ‚΄μš©μ„ κΈ°λ‘ν•©λ‹ˆλ‹€.

Table Cell듀에 λŒ€ν•œ μ •μ˜μ™€, 각 Cell듀에 λŒ€ν•œ UI λ₯Ό μ •μ˜ν•˜λŠ” μ½”λ“œλ₯Ό ν•œλ²ˆ μ§œλ‘λ©΄, κ·Έ Cell 듀을 data source 둜 μ‚¬μš©ν•˜λŠ” λͺ¨λ“  UITableView μ—μ„œ μž¬ν™œμš©ν•΄μ„œ μ“Έ 수 μžˆλ„λ‘ ν•©λ‹ˆλ‹€.


Server Driven UI

λ°±μ—”λ“œμ—μ„œ json 을 λ‚΄λ €μ€„λ•Œ UI 와 κ΄€λ ¨λœ 데이터듀을 λ‹΄μ•„μ„œ 보내주고, ν΄λΌμ΄μ–ΈνŠΈμ—μ„œλŠ” κ·Έ 데이터에 κΈ°λ°˜ν•΄μ„œ UI λ₯Ό ꡬ성할 수 μžˆμŠ΅λ‹ˆλ‹€.

예λ₯Ό λ“€μ–΄, 라벨을 κ΅¬μ„±ν•΄μ•Όν•˜λŠ”λ° λ°±μ—”λ“œμ—μ„œ text, text size, text color, text style 등을 λ‚΄λ €μ€€λ‹€λ©΄ κ·Έκ±Έ κ·ΈλŒ€λ‘œ λ°›μ•„μ„œ λ„μ›Œμ€„ 수 μžˆμŠ΅λ‹ˆλ‹€.

이미지뷰λ₯Ό ꡬ성할 λ•Œ λ°±μ—”λ“œμ—μ„œ image url, image size λ₯Ό ν•¨κ»˜ λ‚΄λ €μ€€λ‹€λ©΄ 이미지와 이미지 μ‚¬μ΄μ¦ˆλ₯Ό λͺ¨λ‘ λ°±μ—”λ“œ 데이터에 μ˜μ‘΄ν•œ UI λ₯Ό λ§Œλ“€ 수 μžˆμŠ΅λ‹ˆλ‹€.

μ΄λ ‡κ²Œ Server Driven UI λ₯Ό κ΅¬ν˜„ν–ˆμ„ λ•Œμ˜ μž₯μ λ“€μž…λ‹ˆλ‹€.

  1. μœ μ €κ°€ μ•±μŠ€ν† μ–΄μ—μ„œ 앱을 μ—…λ°μ΄νŠΈ ν•˜μ§€ μ•Šμ•„λ„, λ°±μ—”λ“œ 데이터 μ—…λ°μ΄νŠΈλ‘œ UI λ₯Ό λ³€κ²½ν•  수 μžˆλ‹€. (Server Driven UI)
    • 예λ₯Ό λ“€μ–΄, 이미지 -> 라벨 순으둜 보여주닀가 μ–΄λŠλ‚  κ°‘μžκΈ° λ°±μ—”λ“œ μ—…λ°μ΄νŠΈ ν•œλ²ˆμœΌλ‘œ 라벨 -> 이미지 순으둜 보여쀄 수 있게 λœλ‹€.
    • ν…μŠ€νŠΈ μ»¬λŸ¬λ‚˜ 폰트λ₯Ό λ°±μ—”λ“œμ—μ„œ λ³€κ²½ν•  수 있게 λœλ‹€.
  2. ABTest 에 μš©μ΄ν•˜λ‹€.

쿠팑, μΈμŠ€νƒ€κ·Έλž¨κ³Ό 같은 앱듀은 μ΄λ ‡κ²Œ ν΄λΌμ΄μ–ΈνŠΈ μ½”λ“œκ°€ μ•„λ‹Œ λ°±μ—”λ“œ μ½”λ“œμ— μ˜μ‘΄ν•œ UI λ₯Ό 정말 잘 λ§Œλ“€μ–΄ λ†“μ•˜μŠ΅λ‹ˆλ‹€. κ·Έλž˜μ„œ μ–΄λŠλ‚  μœ μ €κ°€ μ—…λ°μ΄νŠΈλ₯Ό ν•˜μ§€λ„ μ•Šμ•˜λŠ”λ°λ„ UI κ°€ λ°”λ€Œμ–΄μžˆλŠ” κ²½ν—˜μ„ ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

μ§€κΈˆ μ΄μ•ΌκΈ°ν•˜κ³  μžˆλŠ” Multi Cell Type Custom Table View λŠ” 이 Server Driven UI λ₯Ό 더 효율적으둜 ν™œμš©ν•˜κΈ° μœ„ν•΄ ν•„μš”ν•©λ‹ˆλ‹€.


Json Data

예λ₯Ό λ“€μ–΄, μ„œλ²„μ—μ„œ 이런 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 Parsing

κ·œμΉ™μ μ΄μ§€ μ•Šμ€ μ € 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)
    }
}

...

Common Cell, Common Cell Factory

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
        }
    }
}

Custom DataSource, Custom TableView

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")
    }
}

Custom TableView μ‚¬μš©

이제 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

profile
μ•ˆλ…•ν•˜μ„Έμš”, iOS 와 μ•Œκ³ λ¦¬μ¦˜μ— λŒ€ν•œ 글을 μ”λ‹ˆλ‹€.

0개의 λŒ“κΈ€