본문 바로가기

TIL

[TIL]TableView Cell로 아코디언 형식 만들기

만들게 된 계기

요즘 출시 경험을 쌓기 위해 프로젝트 하나 진행하고 있는데요.

기획환 디자인 중 아래 사진과 같이 아코디언 형식을 구현해야 하는 상황을 마주하게 되어 그 구현 과정을 기록하면 좋을 것 같아 포스팅을 남기게 되었습니다.

 

Figma로 직접 기획한 디자인입니다.

 

구현 과정

위 사진에서 아실 수 있듯이 기본 틀은 테이블 뷰로 세팅하였습니다.

 

1.  테이블 뷰에 사용할 데이터를 위한 구조체를 구성해줍니다.

struct cellData {
    var opened: Bool
    var title: String
    var sectionData: [String]
}

 

위 구조체에서 각 변수에 대한 설명은 다음과 같습니다.

변수 opened는 테이블 셀이 접혔는지 펴젔는지에 대한 상태를 나타내 주는 변수입니다.

변수 title테이블 뷰의 제목 셀에 들어갈 데이터를 담는 변수입니다.

변수 sectionData제목에 해당하는 하위항목들에 대한 데이터들을 담는 변수입니다.

 

2. 테이블 뷰에 들어갈 데이터를 생성해 줍니다.

private var tableViewData: [cellData] = [
    cellData(opened: false, title: "지체장애", sectionData: ["주차여부", "대중교통", "핵심동선", "매표소", "홍보물", "휠체어", "엘리베이터", "화장실", "관람석(좌석)"]),
    cellData(opened: false, title: "시각장애", sectionData: ["점자블록", "안내요원", "음성안내", "큰활자/점자홍보물", "점자표지판", "유도안내설비"]),
    cellData(opened: false, title: "청각장애", sectionData: ["수어안내", "자막"]),
    cellData(opened: false, title: "영유아 가족", sectionData: ["유아차 대여"]),
    cellData(opened: false, title: "고령자", sectionData: ["휠체어 대여, 이동보조도구 대여"]),
]

3. 섹션의 개수를 생성한 데이터 개수만큼 구현해 줍니다.

func numberOfSections(in tableView: UITableView) -> Int {
    return tableViewData.count
}

 

4. 각 섹션의 개수를 지정 시, 섹션이 펴졌을 때와 접혔을 때를 구분 지어 분기처리해 줍니다.

분기처리 시 다음과 같은 로직으로 각 섹션의 개수를 동적으로 반환해 줍니다.

 

분기 처리

분기 1. 섹션이 펴졌을 때

- 제목 셀을 포함하여 표시할 테이블 테이터 개수만큼 반환

분기 2. 섹션이 접혔을 때

- 제목 셀만큼의 개수만 반환

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if tableViewData[section].opened {
    	// 제목 셀 포함 표시할 테이블 데이터 개수만큼 반환
        return 1 + tableViewData[section].sectionData.count
    } else {
    	// 제목 셀만큼의 개수만 반환
        return 1
    }
}

 

5. cellForRowAt 함수에서 셀 등록 시 제목 셀과 그 이외의 셀로 구분 지어 분기처리해 줍니다.

분기처리

분기 1. indexPath.row가 0인 경우, 각 셀의 첫 번째 셀에는 제목인 title을 뿌려줍니다.

분기 2. indexPath.row가 0 이외의 경우, 각 섹션의 제목에 부합하는 하위 항목 데이터들을 뿌려줍니다.

 

주의할 점: sectionData[indexPath.row - 1]에서 그냥 indexPath.row가 아닌 indexPath.row-1을 하는 이유

-1을 해주는 이유는 제목 부분 셀을 제외하고, 그 아래부터 데이터를 뿌려주기 위함입니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    if indexPath.row == 0 {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell") else { return UITableViewCell() }
        cell.textLabel?.text = tableViewData[indexPath.section].title
        cell.textLabel?.font = .boldSystemFont(ofSize: 20.0)
        return cell
    } else {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell") else { return UITableViewCell() }
        // 주의할 부분: sectionData[indexPath.row - 1]
        cell.textLabel?.text = tableViewData[indexPath.section].sectionData[indexPath.row - 1]
        cell.textLabel?.font = .systemFont(ofSize: 16.0)
        return cell
    }
}

 

6.  섹션을 터치했을 때 각 섹션을 펼지 접을지에 대한 상태를 나타내는 변수 opened의 값을 반전시켜 주고, 이 opened 상태값에 따라 해당 섹션의 상태가 화면에 반영될 수 있도록 해당 섹션을 reload 시켜줍니다.

여기서 주의할 점제목 셀을 터치했을 때만 동작이 이루어져야 한다는 것입니다.

따라서 여기서 indexPath.row == 0 일 때만 동작이 가능하도록 분기처리해 주면 됩니다.

extension MainViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    
        // 셀 선택 시 회색에서 다시 변하게 바꿔줌
        tableView.deselectRow(at: indexPath, animated: true)
        
        if indexPath.row == 0 {
        	// tableViewData[indexPath.section].opened의 값 반전
            tableViewData[indexPath.section].opened.toggle()
            
            // 반전된 tableViewData[section].opened의 값에 따른 터치된 섹션을 리로드(reload)
            tableView.reloadSections([indexPath.section], with: .none)
        }
    }
}

 

시연 영상

 

전체 코드

import UIKit
import SnapKit

struct cellData {
    var opened: Bool // 테이블 셀이 접혔는지 펴젔는지 확인해주기 위한 변수
    var title: String // 카테고리에 해당하는 문자열 변수
    var sectionData: [String] // 카테고리 내 아이템들에 해당하는 문자열 리스트 변수
}

final class MainViewController: UIViewController {
    
    lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = self
        tableView.dataSource = self
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
        return tableView
    }()
    
    private var tableViewData: [cellData] = [
        cellData(opened: false, title: "지체장애", sectionData: ["주차여부", "대중교통", "핵심동선", "매표소", "홍보물", "휠체어", "엘리베이터", "화장실", "관람석(좌석)"]),
        cellData(opened: false, title: "시각장애", sectionData: ["점자블록", "안내요원", "음성안내", "큰활자/점자홍보물", "점자표지판", "유도안내설비"]),
        cellData(opened: false, title: "청각장애", sectionData: ["수어안내", "자막"]),
        cellData(opened: false, title: "영유아 가족", sectionData: ["유아차 대여"]),
        cellData(opened: false, title: "고령자", sectionData: ["휠체어 대여, 이동보조도구 대여"]),
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureConstraints()
    }
    
    private func configureConstraints() {
        view.addSubview(tableView)
        
        tableView.snp.makeConstraints {
            $0.edges.equalTo(view.safeAreaLayoutGuide)
        }
    }
}

extension MainViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        /*
         전제조건: 제목 셀 부분을 터치했을 때만
         1. tableViewData[section].opened의 값을 반전
         2. 반전된 tableViewData[section].opened의 값에 따라 터치된 섹션을 리로드(reload)해서 해당 섹션을 다시 그려준다.
         */
        
        // 셀 선택 시 회색에서 다시 변하게 바꿔줌
        tableView.deselectRow(at: indexPath, animated: true)
        
        if indexPath.row == 0 {
            tableViewData[indexPath.section].opened.toggle()
            
            tableView.reloadSections([indexPath.section], with: .none)
        }
    }
}

extension MainViewController: UITableViewDataSource {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return tableViewData.count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        /*
         분기처리
         1. 섹션이 펴졌을 때
         - 제목 셀 포함 표시할 테이블 데이터 개수만큼 반환
         2. 섹션이 졉혔을 때
         - 제목 셀만큼의 개수만 반환
        */
        if tableViewData[section].opened {
            return 1 + tableViewData[section].sectionData.count
        } else {
            return 1
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        /*
         분기처리
         1. indexPath.row == 0 일때는 title을 뿌려준다.
         2. 이외에는 나머지 셀에 대해선 sectionData를 뿌려준다.
         */
        if indexPath.row == 0 {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell") else { return UITableViewCell() }
            cell.textLabel?.text = tableViewData[indexPath.section].title
            cell.textLabel?.font = .boldSystemFont(ofSize: 20.0)
            return cell
        } else {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell") else { return UITableViewCell() }
            
            /*
             주의점: sectionData[indexPath.row - 1]
             - -1을 해주는 이유는 제목 부분 셀을 제외하고 그 아래부터 데이터를 뿌려주기 위함임
             */
            cell.textLabel?.text = tableViewData[indexPath.section].sectionData[indexPath.row - 1]
            cell.textLabel?.font = .systemFont(ofSize: 16.0)
            return cell
        }
    }
}

 

마무리

처음에는 이걸 어떻게 구현하지 하고 생각했었는데 생각보다 구현이 어렵지 않았던 것 같습니다.

그래도 이렇게 구현을 마치니 개인적으로 뿌듯함을 느끼네요

긴 글 읽어주셔서 감사합니다!