# 테이블뷰 (스토리보드)

테이블 뷰는 세로로만 스크롤이 가능한 뷰이다. 테이블 뷰는 테이블뷰 셀들로 이루어져 있다. 테이블뷰와 뷰 컨트롤러 간의 상호작용을 위해 델리게이트 패턴을 사용한다.

스토리보드를 통해 테이블뷰를 구성하는 과정을 아래와 같다.

  1. 라이브러리에서 Table View를 화면에 배치한다. 일반적으로 테이블 뷰는 화면 전체에 걸쳐 나타나기 때문에 제약조건도 상-하-좌-우 모두 기본 margin간격을 제외하고 딱 붙인다.
  2. 라이브러리에서 TableView Cell을 가져와 테이블 뷰에 붙인다.
  3. 테이블뷰 셀 내부에 컨텐츠를 원하는대로 집어넣는다. 이미지뷰, 레이블 등을 적절히 스택뷰와 함께 섞어 구성한다.
  4. 이니셜 뷰컨트롤러에 테이블 뷰 컨트롤러와 관련된 프로토콜을 채택하고 필수 구현대상 함수를 구현한다.
class ViewController: UIViewController, UITableViewDataSource {

    @IBOutlet weak var tableView: UITableView!


    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 10
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }

}
  1. UITableViewDataSource프로토콜을 채택하고 테이블뷰 인스턴스를 생성한다.
  2. 테이블뷰 인스턴스의 dataSource 속성을 self 뷰 컨트롤러 인스턴스와 연결짓는 것이 테이블 뷰 델리게이트 패턴 구현 형태이다.
  3. func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int, func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 두 함수를 구현한다.

numberOfRowsInSection은 테이블뷰 섹션별 셀의 숫자이고, cellForRowAt 파라미터를 갖는 함수는 커스텀 테이블 뷰 셀을 정의한 뒤 리턴하면 된다.

뷰 컨트롤러에 데이터를 서버 데이터를 로드하고 배열 형태로 저장했다고 가정해보자. 해당 데이터들을 각 셀에 표현해주기 위해서는 tableView(_ tableView: UITablewView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 함수를 정의해주면 된다.

import UIKit

class ViewController: UIViewController, UITableViewDataSource {

    @IBOutlet weak var tableView: UITableView!

    var moviesArray: [Movie] = [
        Movie(movieImage: UIImage(named: "이미지명.png"), movieName: "영화명", movieDescription: "영화 줄거리.."),
        // .... 나머지 데이터 정의
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        // 델리게이트 정의
        tableView.dataSource = self

        // 테이블 뷰 각 셀의 높이를 지정한다.
        tableView.rowHeight = 120
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // 테이블 뷰 내의 특정 섹션에 몇 개의 셀이 들어가는지 정의한다.
        // 불러온(정의한) 데이터 배열의 count 속성을 리턴하면 된다.
        return moviesArray.count
    }


    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // UITableView.dequeueReusableCell 메서드를 통해 셀을 빼온다.
        // 셀을 빼왔으면 원하는 타입으로 타입 다운캐스팅을 한다.
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "MovieCell", for: indexPath) as? MovieCell else {return UITableViewCell()}

        // indexPath.section - 그룹에 대한 섹션을 나타냄
        // indexPath.row - 각 그룹당 몇번째 row인지 인덱스를 나타냄

        cell.mainImageView.image = moviesArray[indexPath.row].movieImage
        cell.movieNameLabel.text = moviesArray[indexPath.row].movieName
        cell.descriptionLabel.text = moviesArray[indexPath.row].movieDescription

        return cell
    }

}
  1. 뷰컨트롤러 저장속성에 표현할 데이터 타입을 정의한다.
  2. 델리게이트를 self로 지정하고 각 셀의 height값을 지정한다. (tableView.rowHeight)
  3. tableView의 numberOfRowsInSection 파라미터를 갖는 함수를 구현한다. 섹션에 대해 불러온 데이터 수를 전달하면 된다. (배열.count)
  4. tableView의 cellForRowAt 파라미터를 갖는 함수를 구현한다. 각 셀을 어떻게 표현할지 정의하는 함수이다.
    • tableView.dequeueReusableCell(withIdentifier:for:) 메서드를 통해 각 셀에 대해 접근한다.
    • 스토리보드의 테이블뷰 셀을 클릭하여 identifier를 정의한 뒤 dequeueReusableCell의 아규먼트에 그대로 전달한다.
    • dequeueReusableCellfor 파라미터에는 호출자 함수 tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCellindexPath 파라미터를 그대로 전달하면 된다.
    • indexPath는 각 셀의 인덱스를 나타내는 인스턴스이다. indexPath.row 속성을 통해 테이블 뷰 내의 셀 인덱스를 Int타입으로 얻어낼 수 있다.
    • dequeueReusableCell로 셀에 대한 정보를 뽑아냈으면 반드시 타입 다운캐스팅을 통해 셀 인스턴스에 대한 정보를 명확히 해야한다.
    • 셀 인스턴스의 저장속성 각각에 접근하여 API로부터 불러온 데이터 indexPath.row와 함께 데이터를 연동한다.
    • 정의한 셀의 형태를 리턴한다.

rowHeight

테이블뷰 인스턴스의 rowHeight속성에 상수값을 직접 할당하는 방식이 아닌 델리게이트 메서드 형태로 셀 높이를 지정할 수도 있다.

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat 함수에 셀 높이값을 리턴하면 된다. switch문 분기를 통해 각 셀에 대한 높이값을 유기적으로 변경할 수 있는 로직을 구현할 수 있다.

extension ViewController: UITableViewDelegate{
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 120
    }
}

상수값을 직접 전달해도 되지만 UITableView의 타입 저장속성인 automaticDimension을 사용해도 된다. 개행 시 추가되는 높이에 따라 자동으로 높이를 늘려주는 등의 상황에서 사용된다. 파라미터가 heightForRowAt인 함수에서 사용하는 것이 아니라 estimatedHeightForRowAt 파라미터를 갖는 함수에서 사용한다. (셀프 사이징 셀에 대한 개념을 이해해야한다. 다음 문서를 (opens new window) 참고하자.)

extension ViewController: UITableViewDelegate{
    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableView.automaticDimension
    }
}

테이블 뷰 셀 register

  1. 테이블 뷰 셀을 스토리보드로 등록할때 반드시 Custom Class에 UITableViewCell로 생성한 셀 클래스타입명을 기입해주고 (Identity Inspector메뉴)
  2. Attributes Inspector에서는 Identifier속성을 지정해주고 후에 dequeueReusableCell 메서드의 identifier 파라미터 값으로 사용한다.

하나라도 안하면 레지스터 관련 오류가 발생하므로 반드시 주의하자.

위 예제코드를 MVC로 리팩토링 하게 되면 서버 통신을 통해 데이터를 얻어오는 함수를 따로 구현해야 한다.

class DataManager{
    var movieDataArray: [Movie] = []

    func makeMovieData(){
        // API 요청코드
    }

    func getMovieData() -> [Movie]{
        return movieDataArray
    }
}

초기 데이터는 빈 데이터로 데이터의 형태만 정의하고, 매니저 메서드에서 API요청을 통해 데이터를 저장하는 방식으로 리팩토링한다.

코드로 테이블 뷰 구현

스토리보드가 아닌 코드로 테이블 뷰를 구현할때 주의할 점은 아래와 같다.

  1. 테이블 뷰 제약조건은 상-하-좌-우 0으로 구성한다. (반드시는 아님)
  2. 테이블 뷰에 테이블 뷰 셀 인스턴스도 등록해줘야 한다. register 메서드를 통해 등록한다. tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: "셀 identifier")

셀을 테이블에 등록할때는 인스턴스의 타입을 전달하는 것이 아니라 인스턴스 타입의 타입, 즉 메타타입을 전달해야 한다. forCellReuseIdentifier 문자열 값은 추후 dequeueReusableCell메서드를 호출할때 사용한다.

스토리보드가 아닌 XIB파일을 통해 코드로 직접 인스턴스를 생성하여 테이블 뷰에 셀을 등록할 때에는 func register(_ nib: UINib?, forCellReuseIdentifier identifier: String) 함수를 사용한다. nib 파라미터에는 UINib 생성자를 통해 인스턴스를 직접 전달하며, 인스턴스 형태는 UINib(nibName: String, bundle: Bundle?)이다. 생성자의 bundle 파라미터는 잘 사용하지 않는다고 한다.

musicTableView.register(UINib(nibName: Cell.musicCellIdentifier, bundle: nil), forCellReuseIdentifier: Cell.musicCellIdentifier)

테이블뷰 separatorStyle

테이블뷰와 내부 셀을 정의하면 기본적으로 셀 사이를 구분해주는 구분선이 기본 제공된다. separatorStyle 속성 조정을 통해 없애주거나 커스텀 스타일링도 가능하다.

# 화면 전환과 데이터 전달

테이블 뷰 셀 클릭을 통해 다음 화면으로 전환하기 위해 필요한 내용은 아래와 같다.

  1. 새로운 뷰 컨트롤러 생성 및 세그웨이 연결
  2. 서브뷰 UI설정, 전달받을 데이터 속성 정의
  3. 테이블뷰 델리게이트 패턴으로 이벤트 감지 및 데이터 전달함수 구현

Movie라는 모델이 위의 예제코드와 동일하게 영화 타이틀, 줄거리, 이미지로 정의되어 있다고 가정해보자. 영화 디테일 페이지의 뷰컨트롤러는 아래와 같이 정의할 수 있다.

class DetailViewController: UIViewController {

    @IBOutlet weak var mainImageView: UIImageView!

    @IBOutlet weak var movieNameLabel: UILabel!

    @IBOutlet weak var movieDescriptionLabel: UILabel!

    var movieData: Movie?

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUI()
    }

    func setupUI(){
        mainImageView.image = movieData?.movieImage
        movieNameLabel.text = movieData?.movieName
        movieDescriptionLabel.text = movieData?.movieDescription
    }
}

스토리보드와 UI요소를 정의 및 연결하고 테이블 뷰에서 UI 저장속성들에 직접 접근하는 방식으로 데이터를 내려주면 된다.

테이블뷰에서 데이터를 전달하기 위해서 필요한 부분은 다음과 같다.

  1. 테이블 뷰 자체에 델리게이트를 뷰컨트롤러로 지정함으로써 터치와 같은 이벤트의 처리 당사자를 뷰 컨트롤러에게 위임한다.
    • UITableViewDelegate 프로토콜을 채택한다.
    • func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) 함수를 구현한다.
  2. 세그웨이로 UI연결을 한 경우, performSegue를 통해 데이터를 전달하게 된다.
    • 스토리보드에 세그웨이 identifier를 정의하고 그대로 문자열을 performSegue의 withIdentifier 파라미터에 전달한다.
    • sender파라미터에 didSelectRowAt 파라미터를 전달해야한다. 이유는 아래에 정리한다.
  3. 세그웨이를 통한 데이터 전달을 위해 전달할 데이터를 정의해야한다. 이는 세그웨이의 prepare 메서드를 오버라이딩 함으로써 구현 가능하다.
    • 어떤 데이터를 전달해야하는가.. 를 다시 생각해보면 테이블 뷰 중 클릭된 특정 셀 내에 포함된 이미지와 타이틀 디스크립션 데이터들을 전달해야한다.
    • 이때 테이블 뷰에서 클릭된 셀의 row값을 알아야한다. 이는 테이블뷰 델리게이트 프로토콜 함수에서 didSelectRowAt 파라미터를 통해 알 수 있다.
    • performSegue함수가 호출되는 시점이 prepare함수가 호출된 이후이기 때문에, performSegue의 sender파라미터에 didSelectRowAt 파라미터를 전달했을때 prepare의 sender 파라미터의 값이 didSelectRowAt이 되는 것이다. 타입 다운캐스팅은 필요하다.
    • 모델로부터 영화 데이터들을 저장한 배열을 얻어오고 해당 배열의 didSelectRowAt.row 인덱스의 값을 얻는다.
    • segue.destination을 통해 접근할 디테일페이지 뷰 컨트롤러 인스턴스를 얻고, 내부 저장속성인 UI요소들의 text 등의 속성들을 데이터값들로 초기화시키면 끝!

코드로 정리하면 아래와 같다.

델리게이트 설정

dataSource나, 텍스트필드 델리게이트 설정과 동일하게 UITableViewDelegateviewDidLoad등의 시점에서 테이블 뷰 이벤트처리에 대한 당사자를 self 인스턴스, 즉 현재 뷰 컨트롤러로 지정해야한다.

override func viewDidLoad() {
    super.viewDidLoad()

    tableView.dataSource = self

    // 반드시 추가!
    tableView.delegate = self

    tableView.rowHeight = 120

    movieDataManager.makeMovieData()
}

dataSource 델리게이트는 테이블 뷰 데이터 표현의 책임이 뷰컨트롤러에 있는 것이고, delegate테이블 뷰 자체와 사용자의 상호작용 처리의 책임이 뷰컨트롤러에 있는 것이다.

extension ViewController: UITableViewDelegate{
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // 1. 스토리보드의 세그웨이 identifier를 전달
        // 2. sender에 tableView didSelectRowAt 파라미터를 전달.
        performSegue(withIdentifier: "toDetail", sender: indexPath)
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if(segue.identifier == "toDetail"){
            // 3. 세그웨이 identifier로 분기
            // 4. 디테일페이지 뷰 컨트롤러 인스턴스를 얻기 위해 세그웨이 목적지 인스턴스를 타입 다운캐스팅한다.
            guard let detailVC = segue.destination as? DetailViewController else {return}

            // 5. tableView 델리게이트 함수의 sender파라미터가 prepare의 sender와 동일하지만, 타입 다운캐스팅을 해줘야 한다.
            guard let indexPath = sender as? IndexPath else {return}

            // 6. 모델로부터 배열 전체를 뽑아온다.
            let movieArray = movieDataManager.getMovieData()

            // 7. 뽑아온 배열의 인덱스에 접근하여 해당 데이터를 세그웨이 목적지 뷰 컨트롤러 인스턴스에 전달한다.
            detailVC.movieData = movieArray[indexPath.row]
        }
    }
}

dataSource와 dequeue

데이터소스 델리게이트 함수 중 cellForRowAt 파라미터가 포함된 함수를 필수 구현해야 했었다. 이때 테이블 뷰 내에 포함된 전체 셀을 얻어오기 위해 tableView.dequeueReusableCell(withIdentifier: "MovieCell", for: indexPath)라는 함수를 호출했어야 했다.

함수 이름이 왜 dequeueReusableCell일까? 실제로 테이블 뷰 내의 셀들이 스크롤에 따라 스크린에서 사라지고 나타나는 방식이 자료구조 덱 자료구조와 동일하게 동작하기 때문이다. 덱은 Double-ended queue로, push되고 pop되는 입구가 한방향이 아닌 양방향으로 정의된다.

스크롤에서 생각해보아도 아래로 스크롤하면 위에서 데이터가 끌어내려오고 아래로 데이터가 pop된다. 반대의 경우 위로 데이터가 pop되고 아래에서 데이터가 push된다.

데이터 push와 pop이 이루어질때에는 메타데이터를 이루는 인스턴스를 메모리 상에서 완전히 삭제하는 것이 아니다. 화면에서 테이블뷰 셀이 빠져나가면 내부 데이터만 비워진 채로 인스턴스가 메모리에 남게 된다.

즉, 우리가 눈으로 보는 데이터의 구성은 [Movie, Movie, Movie.. , Movie] 이겠지만 실제 구성은 [텅빈 Movie, Movie, Movie, ... Movie, 텅빈 Movie]의 형태로 존재하며 스크린에서 인스턴스가 빠져나갈때 데이터를 비우고 양쪽 끝단에 위치시키며 다시 데이터가 푸시될때에는 양쪽 끝단에 위치한 텅 빈 인스턴스를 채워넣는 방식으로 이루어진다는 것이다.

테이블뷰 렌더링 이후 데이터의 수정

테이블 뷰와 내부 셀이 기존 데이터에 따라 화면에 그려진 이후 시점에 내부 데이터의 수정이 이루어진 경우 테이블 뷰에 데이터가 변경되었다는 상황에 대해 알려줘야 한다.

해당 기능을 하는 메서드는 reloadData() 메서드이다. 데이터를 새로고침 한다고 생각하면 된다.

@IBAction func addButtonTapped(_ sender: UIBarButtonItem) {
    movieDataManager.updateMovieData()
    tableView.reloadData()
}

화면 이동 후 다른 화면으로 다시 이동하였을때 기존 테이블 뷰를 업데이트 하려면 viewWillAppear 생명주기에서 리로드를 해주면 된다.

테이블 뷰 셀 오토레이이아웃

테이블 뷰 셀 인스턴스 생성을 위해 UITableViewCell 클래스 상속을 받게 되면 아래와 같은 함수들이 자동으로 작성된다.

awakeFromNib함수는 일종의 테이블 뷰 셀의 생명주기와 관련된 함수인데 스토리보드와 코드를 연결할때 사용되는 함수이다.

코드로만 오토레이아웃을 잡기 위해서는 init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) 생성자 함수를 오버라이딩 해야한다.

이때 수퍼클래스의 생성자를 호출해야하는데 style 파라미터에는 일반적으로 .default 열거형 케이스를 전달한다. reuseIdentifier는 생성자 함수의 파라미터를 그대로 전달하면 된다.

또한 코드로만 테이블 뷰 셀의 오토레이아웃을 구성할때 필수생성자를 추가로 구현해야하는데, 발생한 에러메세지의 Fix버튼만 클릭하면 자동으로 만들어지며 그대로 사용하면 된다.

제약조건 설정 코드는 동일하게 updateConstraints함수를 , 나머지 레이아웃 관련 스타일링은 layoutSubviews 함수를 오버라이딩 하면 된다.

// 오버라이딩 생성자 함수
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: .default, reuseIdentifier: reuseIdentifier)
}

// 필수생성자
required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

override func updateConstraints() {
    setConstraints()
    super.updateConstraints()
}

override func layoutSubviews() {
    super.layoutSubviews()
    profileView.clipsToBounds = true
    profileView.layer.cornerRadius = profileView.frame.width / 2
}

override func awakeFromNib() {
    super.awakeFromNib()
    // Initialization code
}

override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)

    // Configure the view for the selected state
}

# 테이블 뷰 셀 데이터 세팅

기존 방식으로는 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell 함수에서 indexPath.row로 서브스크립팅 하여 뽑아온 UI 요소에 직접 하나하나 접근하여 데이터를 세팅하는 방식을 사용했었다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "memberCell", for: indexPath) as? MemberTableViewCell else {return UITableViewCell()}

    // 셀을 뽑아온 뒤
    // UITableViewCell 인스턴스 UI 저장속성 데이터에 직접 접근
    // 모델로부터 데이터를 뽑아와 세팅
    cell.profileView.image = memberManager[indexPath.row].profileImage
    cell.nameLabel.text = memberManager[indexPath.row].name
    cell.addressLabel.text = memberManager[indexPath.row].address
    cell.selectionStyle = .none

    return cell
}

이러한 방식이 아니라 전달할 데이터 전체 객체에 대해 UITableViewCell 클래스 내에 저장속성 메모리 공간을 미리 마련해둔 뒤 전체를 한번에 전달하는 방식을 사용하는 것이 일반적이다.

이때 전달받은 전체 객체 데이터는 다시 하나씩 접근하여 세팅하는 방식이 아니라, didSet 속성감시자를 통해 각 데이터를 세팅하게 된다.

class MemberTableViewCell: UITableViewCell {

    var member: Member? {
        didSet{
            guard var member = member else {return}
            profileView.image = member.profileImage
            nameLabel.text = member.name
            addressLabel.text = member.address
        }
    }
    // ...
}

member라는 저장속성 데이터가 바뀔때마다 매번 UI요소에 직접 반영하는 코드이다.

XIB NIB

UIKit 기반의 프로젝트에서 사용되는 개념이며 SwiftUI 프레임워크에서는 사용되지 않는다. UIKit에서 UI에 대한 컴포넌트를 생성하기 위해 사용된다.

XIB는 Xcode Interface Builder, NIB는 Nextstep Interface Builder의 약자이다. 둘은 기능적으로 동일하지만 nib은 좀 더 로우한 형태의 파일이고 xib파일을 컴파일하면 nib으로 변환된다.

테이블 뷰 생성 후 직접 UI 라이브러리에서 테이블 뷰 셀을 가져와 붙여도 되지만 테이블 뷰 셀 재사용을 위해 xib파일을 생성해도 된다.

MVC패턴 아래 뷰 그룹에 Cell 파일을 생성할때 xib파일과 함께 생성한다는 옵션을 체크하여 파일 생성을 하게 되면 .xib확장자의 파일도 함께 생성된다.

# Reference

  1. 앨런 Swift 문법 마스터스쿨 (opens new window)
  2. Difference between Nib and Xib (iOS) (opens new window)
  3. TableView 동적 높이 설정 (opens new window)