총평

테이블 뷰를 대량 데이터를 화면에 출력하는 방법으로 생각했는데, 테이블 뷰를 사용해서 다양한 형태로 표현한다는 것을 알게되었습니다. 그리고 테이블 뷰의 라이프사이클을 정확하게(사실 아직도 그러함) 인지하지 못해서 아직도 약간은 복잡한 화면은 조금 힘들긴 합니다. 그럼에도 불구하고 테이블 뷰에 대한 기본적인 내용을 명확하게 알 수 있는 기회가 되었습니다.

TableView!

회사에서 아래와 같은 View를 작성하라는 Issue를 받았습니다. 이걸 해결하기 위해서 많은 방법이 있겠지만 아래와 같은 이미지는 리스트 혹은 테이블을 사용할 것 같다는 생각이 머리속을 스쳐지나갔습니다. 누가봐도 그렇게 생각했을 것 같은 그런 생각으로 덤벼들었는데, iOS의 테이블뷰에 대해서 조금 애매하게 알고 있어서 생각보다 많은 시간(근무일 기준 15시간 이상)을 사용했습니다.

그래서 이번주는 TableView에 대해서 가볍게 정리하도록 하겠습니다.

이런걸 만들어야 된다구?

테이블 뷰란(UITableView)?

테이블 뷰는 대량의 데이터를 일관된 형태로 표현하기 위해서 사용되는 방법입니다. 테이블 뷰는 스크롤 뷰의 하위 클래스(subclass)이며, 수직 방향(vertical)으로 화면 이동이 가능합니다.

테이블 뷰는 행이 나열된 형태의 일반 테이블 뷰(plain table)과 그룹 단위로 데이터를 표현하는 그룹 테이블(group table)로 구분할 수 있습니다. 섹션(section)으로 묶어서 데이터를 표현할 수 있으며, 헤더(header)와 푸터(footer)를 사용해서 최상단과 최하단에 추가로 정보를 표현할 수 있습니다.

https://docs-assets.developer.apple.com/published/6c67362d82/eb067a17-54f7-415f-ac37-25681879911f.png

테이블 뷰 셀(UITableViewCell)?

셀은 Content Only, With Accessory View, In Edit Mode 3가지의 구조(혹은 형태)를 가지고 있습니다. 기본적으로 사용되는 Content Only 구조에 조작 가능한 영역이 좌우에 포함되는 형태로 구성됩니다.

https://docs-assets.developer.apple.com/published/2128ef91ee/a27538d0-bc9a-4972-aa83-8616889d7959.png

셀은 3가지 속성이 미리 정의 되어 있습니다. 기본적으로 정의된 속성을 잘 활용하면 좋겠지만, 만약 다른 형태의 테이블 셀이 필요하다면 UITableCell을 상속 받아서 수정하면 됩니다.

@available(iOS 3.0, *)
open var imageView: UIImageView? { get } // default is nil.  image view will be created if necessary.

@available(iOS 3.0, *)
open var textLabel: UILabel? { get } // default is nil.  label will be created if necessary.

@available(iOS 3.0, *)
open var detailTextLabel: UILabel? { get } // default is nil.  label will be created if necessary (and the current style supports a detail label).

테이블 셀의 구조

정리하자면 UITableView는 UIScrollView를 상속하고 있어서, 화면보다 긴 내용을 '스크롤'을 통해 확인할 수도 있으며, 화면에 표시되는 행은 UITableCell에 의해서 결정됩니다. 그리고 행의 모양을 다른 형태로 변경해서 사용하고 싶다면 UITableCell을 상속 받아서 사용하면 됩니다. 테이블과 셀을 연결하기 위해선 당연히 컨트롤러가 필요합니다.

테이블 뷰 컨트롤러(TableViewController)

UITableViewController는 테이블 뷰의 여러 기능을 제공합니다. 행을 수정, 설정, 선택지에 대한 관리 등의 역할을 수행 합니다. UITableView 객체는 델리게이트(delegate)와 데이터 소스(dataSource)가 필요합니다.

테이블 뷰 컨트롤러는 MVC모델(Model-View-Controller)의 역할 분담에 따라 데이터 소스에서 제공하는 데이터 모델과 뷰 사이를 연결하는 역할을 합니다. 델리게이트는 테이블 뷰의 형태와 기능을 관리하는 역할을 합니다.

데이터 소스(UITableViewDataSource)

테이블에 출력한 데이터를 제공합니다. 데이터 소스는 UITableViewDataSource 프로토콜을 준수합니다. 이 프로토콜은 두 개의 메서드를 필수적으로 작성해야 합니다. 각 섹션에서 몇 개의 행을 보여줄 지를 결정하는 numberOfRowsInSection: 과 각 행에서 어떤 정보를 보여줄 지를 제공하는 cellForRowAtIndexPath: 메서드입니다.

class ViewController: UIViewController {

    var tableData = [String]()

    override func viewDidLoad() {
        super.viewDidLoad()
        let tableView = UITableView(frame: .zero, style: .plain)
        view = tableView
        for count in 0...100 {
            tableData.append("Item \(count)")
        }
        print("The tableData array contains \(tableData)")
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tableViewCell")
        tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return tableData.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell")
        cell?.textLabel?.text = tableData[indexPath.row]
        return cell!
    }
}

델리게이트(UITableViewDelegate)

UITableViewDelegate는 테이블 뷰의 셀 재정렬, 삭제 등과 같은 편집 및 그 외 다양한 이벤트를 관리합니다. 그리고 섹션 헤더뷰, 푸터뷰의 설정 등과 같이 뷰를 표현하는 방법을 결정합니다. 예를 들어, 테이블뷰의 커스텀 헤더뷰, 푸터뷰 생성 및 관리 (viewForHeaderInSection:, viewForFooterInSection: 등), 테이블뷰 해더뷰, 푸터뷰, 행 등의 커스텀 높이설정 및 구체화 가능 (heightForRowAt:, heightForHeaderInSection:), 테이블뷰 셀 선택 시 반응 (didSelectRowAt:, willSelectRowAt: 등), 테이블뷰 컨텐츠의 수정 편집기능 제공 (editingStyleForRowAt: 등) 합니다.

델리게이트 패턴(Delegation pattern)

이 시점에서 델리게이트 패턴을 별도로 다루는 이유는 패턴 자체의 중요성 때문이 아니라 Apple 생태계에서 델리게이트 패턴이 널리 사용되기 때문입니다. 가깝게는 테이블 뷰에서 아주 머나먼 NSCacheDelegate까지 Apple 생태계에 중요한 위치를 차지하는 패턴입니다.

Delegation is a way to make composition as powerful for reuse as inheritance [Lie86, JZ91]. In delegation, two objects are involved in handling a request: a receiving object delegates operations to its delegate. This is analogous to subclasses deferring requests to parent classes. But with inheritance, an inherited operation can always refer to the receiving object through the this member variable in C++ and self in Smalltalk. To achieve the same effect with delegation, the receiver passes itself to the delegate to let the delegated operation refer to the receiver. // GoF, Design Patterns: Elements of Reusable Object-Oriented Software

위의 영문에서 중요한 단어만 뽑아보면 reuse as inheritance, a receiving object, delegate 입니다. 요약하면 상속처럼 객체를 재활용하기 위해서 사용되는 구조 패턴 중 하나이며, 수신을 받는 객체(a receiving object)의 역할을 대신(delegate) 할 객체가 필요합니다.

델리게이트 패턴의 주요 목적은 결함도를 낮추는 방법으로 소개되지만, 이런 고상한 단어는 저와 같은 초급자에게 먼 이야기이며 사실 초급자 입장에선 높은 진입장벽 중 하나로 간주됩니다. 예를 들어, 객체가 타입과 직접적으로 연관되어 있지 않기 때문에 유지(정확히는 변경)하기 쉽고 재사용 하기 어렵지 않게 코드를 작성할 수 있다고 하지만, 저와 같은 초보자에게 타입과 직접적으로 연관되어있다는게 무슨 뜻인지도 잘 모르겠고 실질적으로 이 패턴을 언제, 어떻게 적용해야 하는지 판단하는게 쉽지 않습니다.

객체의 동작이나 결정들을 위임하는(delegating) 행위는 모든 행위(메소드)를 가지고 있는 거대한 객체가 되지 않기 위해서 델리게이트 패턴을 사용한다고 하지만 사실 파일이 많아지면 코드가 길어지는 것만큼이나 프로젝트의 코드나 구조를 파악하는데 쉽지 않습니다.

결론적으로 말해서, 저와 같은 꼬꼬마들은 패턴의 본질에 관한 내용보다는 일단 이걸 잘 사용하는데 집중하도록 하는게 우선입니다. 일단 사용해봐요!

왜 하필 TableView에?

UITableView는 마음만 먹으면 다양한 형태로 표현할 수 있습니다. 그래서 델리게이트 패턴을 통해서 해당 테이블 뷰에서 발생하는 이벤트, 셀을 테이블 뷰에 배치하는 속성들을 결정합니다. 테이블의 구조를 유연하게 만들고, 테이블과 연계된 이벤트와 입력 데이터를 효율적으로 분리할 수 있다는 장점이 있을 것으로 생각됩니다.

델리게이트 패턴을 구현하는 가장 흔한 방법은 바로 델리게이트 프로토콜을 사용하는 것입니다. 즉, 프로토콜 기반으로 델리게이트 패턴을 구현합니다. 쉽고 간단한 델리게이트 패턴을 만들어보겠습니다.

델리게이트 패턴 간단 예제

일단 우리는 콜라(Coke)를 만드는 회사(CokeFactory)라 가정합시다. 제조 회사라서 별도의 영업 직군을 채용할 여지가 없고, 콜라를 납품해야 될 곳이 많아서 모두 대응하기 힘든 상황입니다. 그래서 도매를 전문적으로 하시는 분(CokeFactoryDelegate)과 계약해서 콜라를 생산하고, 도매상에게 만들어진 콜라를 전달(CokeMart)하면 판매는 도매상에서 판매하는 형태로 회사를 운영하고 싶습니다.

// 1. 콜라를 만들자!
struct Coke {
    var size:Int = 5
    var hasZeroCoke:Bool = false
}

// 2. 공장을 세워봐요!
class CokeFactory
{    
    func makeCoke()
    {
        var coke = Coke()
        coke.size = 24
        coke.hasZeroCoke = false
    }
}

// 3.도매를 전문적으로 해주실 분
protocol CokeFactoryDelegate {
    func cokeMade(_ coke: Coke)
}

// 4. 판매점 입니다.
class CokeMart {}

콜라가 생산되면 도매상(var delegate: CokeFactoryDelegate?)에게 연락해야 됩니다. 그리고 만들어진 콜라를 도매상에거 전달(delegate?.cokeMade(coke)) 합니다. 도매상에게 콜라를 넘기면 도매상과 계약한 가계(class CokeMart: CokeFactoryDelegate)에 배달됩니다. 우리가 신경써야 할 내용은 도매상입니다. 도매상까지 넘어가면 나머진 도매상이 알아서 합니다.

// 1. 공장에서 도매상에게 콜라를 전달
class CokeFactory
{   
        var delegate: CokeFactoryDelegate?
    
    func makeCoke()
    {
        var coke = Coke()
        coke.size = 6
        coke.hasZeroCoke = true
                delegate?.cokeMade(coke)
    }
}

// 2. 도매상과 연결된 판매처
class CokeMart: CokeFactoryDelegate {
    func cokeMade(_ coke: Coke) {
        print("\(coke.size)가 전달됩니다.")
    }
}

// 3. 발주를 시작한다.
let cokeFactory = CokeFactory()
let cokeMart = CokeMart()
// 3.1 도매상과 연결
cokeFactory.delegate = cokeMart
// 3.2 도매상을 통해 전달
cokeFactory.makeCoke()

UITableView에 사용되는 대표적인 델리게이션은 아래와 같습니다. ViewController는 UITableView과 UITableViewCell을 관리하게 되는데, UITableView에 포함될 Cell에 관한 내용을 UITableViewDataSource 을 사용해서 위임하게 됩니다.

위임하는 이유야 많겠지만, UIViewController의 역할을 명확하게 한정하고 외부 데이터의 변경에 유연하게 대처하기 위한 것으로 간주하면 됩니다(꼬꼬마 답게 그렇게 알고 넘어가고, 나중에 호랑이 같은 사수나 마스터에게 물어봅시다)

즉, 우리는 테이블 뷰를 잘 만들기 위해선 UITableView, UITableViewCell, UITableViewController, UITableViewDataSource, UITableViewDelegate에 대해서 차근차근 공부해야 합니다. 그리고 각각의 역할에 맞게 코드를 작성해야 하며, 결론적으로 말해서 상호간 데이터 교환은 우리 마음속의 공유지 UITableViewController에서 진행되야 합니다.

// 1. 위임할 메서드
public protocol UITableViewDataSource : NSObjectProtocol {
    @available(iOS 2.0, *)
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
    
    
    // Row display. Implementers should *always* try to reuse cells by setting each cell's reuseIdentifier and querying for available reusable cells with dequeueReusableCellWithIdentifier:
    // Cell gets various attributes set automatically based on table (separators) and data source (accessory views, editing controls)
    
    @available(iOS 2.0, *)
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
...
}

// 2. 위임 객체 구성
extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 
    ...
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    ...
    }
}

// 3. 연결
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        // delegate 연결
                let tableView = UITableView()
        tableView.dataSource = self
    }
}

마지막으로 iOS에서 사용하는 델리게이트 패턴에 관해서 알아두면 좋을만한 것들을 정리해봅시다.

  1. 델리게이트 메소드에선 행위를 위임하는 객체는 인자의 첫번째에 위치합합니다. 예를 들어서 UITableViewDelegate에서 매개변수의 첫번째 인지가 tableView라고 명시되어 있습니다. 뒤집어서 말하면, 우리가 델리게이트 메소드를 생성할 때도 이런 원칙을 잊지 않는다면 좋은 델리게이트 메서드를 만들 수 있습니다.
  2. 세부 구현 사항을 노출시켜선 안됩니다. 그럴꺼면 위임할 필요가 없습니다.
  3. (다른 방법을 알지 못하지만 그럼에도 불구하고) 프로토콜을 사용하세요. 프로토콜에 기반한 방법의 가장 큰 이점은 컴파일러 선생님의 지도하에 우리가 잘못 만든 코드를 검증 받을 수 있다는 점 입니다.

이정도 했으면 충분하니 UITableView에서 가장 유명한 예제인 "FoodTracker"(https://developer.apple.com/library/archive/referencelibrary/GettingStarted/DevelopiOSAppsSwift/index.html#/)로 연습해보세요.

https://developer.apple.com/library/archive/referencelibrary/GettingStarted/DevelopiOSAppsSwift/Art/IN_sim_navbar_2x.png

테이블 뷰로 만들어보는 카드 뷰

FoodTracker로 연습이 끝났으면 이제 테이블 뷰를 사용한 유사 카드 뷰를 만들어보겠습니다. 테이블 뷰는 스크롤 뷰의 특성을 모두 가지기 때문에 테이블 뷰의 Cell 모양을 카드 형태로 변경하면 됩니다. 관전 포인트는 Cell을 카드뷰로 만드는 방법입니다. 꼬꼬마답게 그냥 시작해보겠습니다.

아래 코드를 확인하시면 알 수 있듯이 CardView를 만들기 위해선 기존의 Cell에서 제공하는 프로퍼티를 최대한 활용하였다. 그리고 NSLayoutConstraint를 사용해서 CardView 애니메이션을 사용해서 접는 효과에 사용하였다.

// 기본 프로퍼티를 최대한 활용하기 위해서 해당 View를 생성
var featureImage: UIImageView = {
    let iv = UIImageView()
    iv.translatesAutoresizingMaskIntoConstraints = false
    iv.contentMode = .scaleAspectFill
    iv.layer.masksToBounds = true
    iv.layer.cornerRadius = 2
    return iv
}()
    
var titleLabel: UILabel = {
    let label = UILabel()
    label.textAlignment = .center
    label.font = UIFont.systemFont(ofSize: 16, weight: .medium)
    label.textColor = .black
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
}()
    
var infoText: UITextView = {
    let infoText = UITextView()
    infoText.font = UIFont.systemFont(ofSize: 12, weight: .light)
    infoText.textColor = .black
    infoText.isEditable = false
    infoText.translatesAutoresizingMaskIntoConstraints = false
    infoText.text = "This series will show you how to build ..."
    infoText.backgroundColor = .clear
    return infoText
}()

// CardView를 만듬
contentView.addSubview(featureImage)
contentView.addSubview(titleLabel)
contentView.addSubview(infoText)
    
// CardView를 접기 위해서 사용
imageHeightOpened = featureImage.heightAnchor.constraint(equalToConstant: 140)
imageHeightClosed = featureImage.heightAnchor.constraint(equalToConstant: 20)

// CardView를 접는 애니메이션
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
    self.imageHeightClosed.isActive = false
    self.imageHeightOpened.isActive = true
    
    UIView.animate(withDuration: 0.3, delay: 0.15, usingSpringWithDamping: 0.2, initialSpringVelocity: 0.1, options: .curveEaseIn, animations: {
        self.contentView.layoutIfNeeded()
    }, completion: .none)
}

UITableViewController을 상속 받아서, 셀(cell)을 등록하고 해당 셀과 섹션을 설정하고 적당한(응?! 적당한 이라뉘?) 셀을 화면에 출력하면 됩니다.

import UIKit

// 1. 섹션과 셀의 데이터 구조
struct SectionData {
    var open: Bool
    var data: [CellData]
}

struct CellData {
    var title: String
    var featureImage: UIImage 
}

// 2. UITableViewController 상속
class TableViewController: UITableViewController {
    
        // 섹션 - 셀 형태로 구성하기 위해서 자료구조를 설정
    var sections: [SectionData] = [
        SectionData(open: true, data: [
            CellData(title: "Image 1", featureImage: UIImage(named: "0")!),
            CellData(title: "Image 2", featureImage: UIImage(named: "1")!),
            CellData(title: "Image 3", featureImage: UIImage(named: "2")!),
            CellData(title: "Image 4", featureImage: UIImage(named: "3")!),
            ]
        )
    ]
    
        // UITableViewController에서 제공하는 기본 view(UIView)와 tableView(UITableView)를 설정
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.init(red: 228/255, green: 230/255, blue: 234/255, alpha: 1)
        navigationItem.title = "F1"
        tableView.register(CardCell.self, forCellReuseIdentifier: "CellId")
    }
    
    // 행(row,cell)의 내용
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CellId", for: indexPath) as! CardCell
        let section = sections[indexPath.section]
        let cellData = section.data[indexPath.row]
        cell.cellData = cellData
        cell.animate()
        return cell
    }
    
    // 섹션의 View 설정
    override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        let button = UIButton()
        button.setTitle("Close", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.addTarget(self, action: #selector(self.openSection), for: .touchUpInside)
        return button
    }
                
    ...
        
    @objc func openSection(button: UIButton) {
        let section = button.tag
        
        var indexPaths = [IndexPath]()
        
        for row in sections[section].data.indices {
            let indexPathToDelete = IndexPath(row: row, section: section)
            indexPaths.append(indexPathToDelete)
        }
        
        let isOpen = sections[section].open
        sections[section].open = !isOpen
        
        button.setTitle(isOpen ? "Open" : "Close", for: .normal)
                
        if isOpen {
            tableView.deleteRows(at: indexPaths, with: .fade)
        } else {
            tableView.insertRows(at: indexPaths, with: .fade)
        }
    }
}

결론적으로 말해서, 테이블 뷰를 제대로 활용하기 위해선 셀과 테이블뷰 그리고 테이블 컨트롤러의 역할을 명확하게 나눌 수 있어야 하고, 해당 역할에 알맞는 코드를 작성하는게 중요하다. 결론은 델리게이트 패턴에 대해서 조금 깊게 다룰 필요가 있어 보인다.

다음주는?

그리고 우리 사수가 어느날 갑자기 MVC를 포기하시고 MVVM을 도입하셔서 우회하려고 노력했으나(어려운건 피해가자!), 코드를 보니 생각보다 강력해서(상태 변경에 따른 View를 유연하게 다룰 수 있다니!) Combine을 도입하기로(회사 코드엔 이미 도입되어 있음) 스스로 결정해서, 다음주는 Combine에 대해서 도전해보도록 하겠습니다.