[UIKit] UITableView: Expandable Cells

Junyoung Park·2022년 12월 22일
0

UIKit

목록 보기
124/142
post-thumbnail

Creating Expandable TableView Cells (Collapsable) - Xcode 12, Swift 5, iOS Development 2022

UITableView: Expandable Cells

구현 목표

  • 테이블 뷰 셀 선택을 통한 셀 접기/펴기 구현

구현 태스크

  • 섹션/로우 데이터 이넘화
  • 선택 여부 토글을 확인하기 위한 데이터 변수
  • 섹션, 로우에 따른 인덱스 패스 체크를 통한 셀 렌더링

핵심 코드

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        if indexPath.row == 0 {
            sections[indexPath.section].isOpen.toggle()
            tableView.reloadSections([indexPath.section], with: .automatic)
        } else {
            print("\(sections[indexPath.section].section.contents[indexPath.row - 1].name) is selected")
        }
    }
  • 테이블 뷰의 섹션을 사용하지 않고 각 섹션 별 0번째 로우를 섹션 로우로 활용한 형태
  • isOpen 값 여부를 통해 컨텐츠 뷰 셀을 보여줄 지 결정
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let sectionPair = sections[section]
        if sectionPair.isOpen {
            return sectionPair.section.contents.count + 1
        } else {
            return 1
        }
    }
  • 현재 전역 변수로 관리되는 isOpen 값에 따라 섹션 셀만을 보여줄지, 섹션 셀 개수 + 각 섹션의 컨텐츠 뷰를 모두 보여줄 지 결정
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let sectionPair = sections[indexPath.section]
        if indexPath.row == 0 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell", for: indexPath)
            cell.textLabel?.text = sectionPair.section.title
            return cell
        } else {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ExpandableTableViewCell.identifier, for: indexPath) as? ExpandableTableViewCell else { fatalError() }
            let model = sectionPair.section.contents[indexPath.row - 1]
            cell.configure(with: model)
            return cell
        }
    }
  • 각 섹션 별은 디폴트 테이블 뷰 셀로, 로우 셀은 커스텀 테이블 뷰 셀로 렌더링

소스 코드

import Foundation

struct PokemonModel {
    let name: String
    let imageURLString: String
}
  • 데이터는 이름 및 해당 캐릭터에 대한 이미지를 다운로드받을 URL 주소로 구성
import Foundation

enum PokemonSection: CaseIterable {
    case fire
    case electric
    case water
    case grass
    
    var title: String {
        switch self {
        case .fire: return "Fire 🔥"
        case .electric: return "Electric ⚡️"
        case .water: return "Water 💧"
        case .grass: return "Grass 🌿"
        }
    }
    
    var contents: [PokemonModel] {
        switch self {
        case .electric:
            return [
                .init(name: "피카츄", imageURLString: "https://external-preview.redd.it/nBDdAwKA-k5APbu2bhP-1o1Yp9T0uz2moXOH_NFJqXQ.jpg?auto=webp&s=52a81b709960e3ecc902862fdf3a6c70ae68e9bd"),
                .init(name: "라이츄", imageURLString: "https://static.wikia.nocookie.net/pokemon/images/4/49/Tierno_Raichu.png/revision/latest?cb=20150331013126")
            ]
        case .fire:
            return [
                .init(name: "파이리", imageURLString: "https://mblogthumb-phinf.pstatic.net/MjAxNzA0MjFfMTkx/MDAxNDkyNzU0NDI5OTI0.mj6eO3WJYc_kzucy4qEHPIEAeWNyjxt7yRmKofp04twg.jXhG9fB3fjGcayrD5HYRoGUAWw0UF7-0O0B6-44VuSUg.PNG.azimong71/07.PNG?type=w2"),
                .init(name: "리자드", imageURLString: "https://mblogthumb-phinf.pstatic.net/20120619_252/ahn3607_1340035118014tH6qc_JPEG/Pokemon046_-_%BA%CE%C8%B0%A3%BF_%C8%AD%BC%AE_%C6%F7%C4%CF%B8%F3.avi_000955133.jpg?type=w2"),
                .init(name: "파이리", imageURLString: "https://t1.daumcdn.net/cfile/tistory/99BB433359E8C2BF32"),
                .init(name: "파이리", imageURLString: "https://t1.daumcdn.net/cfile/tistory/9984553359E8C2C21D"),
                .init(name: "파이리", imageURLString: "https://t1.daumcdn.net/cfile/tistory/99F5223359E8C2C02B")
            ]
        case .grass:
            return [
                .init(name: "이상해씨", imageURLString: "https://static0.gamerantimages.com/wordpress/wp-content/uploads/2021/12/pokemon-bulbasaur-smile-feature.jpg")
            ]
        case .water:
            return [
                .init(name: "꼬부기", imageURLString: "https://t1.daumcdn.net/cfile/tistory/9906A93359E8C85107")
            ]
        }
    }
}
  • 테이블 뷰를 통해 보여줄 데이터를 섹션 및 해당 섹션의 컨텐츠로 구별
import UIKit

class ExpandableTableViewCell: UITableViewCell {
    static let identifier = "ExpandableTableViewCell"
    private let pokemonImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.layer.masksToBounds = true
        return imageView
    }()
    private let pokemonNameLabel: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .headline)
        label.textAlignment = .center
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        pokemonImageView.image = nil
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let size = contentView.frame.size.height
        pokemonImageView.frame = CGRect(x: contentView.safeAreaInsets.left + 40, y: 0, width: size, height: size)
        pokemonImageView.layer.cornerRadius = size / 2
        pokemonNameLabel.frame = CGRect(x: contentView.safeAreaInsets.left + size + 50, y: 0, width: contentView.frame.size.width - size - 10 - contentView.safeAreaInsets.left - 40, height: size)
    }
    
    private func setUI() {
        contentView.addSubview(pokemonImageView)
        contentView.addSubview(pokemonNameLabel)
    }
    
    func configure(with model: PokemonModel) {
        pokemonNameLabel.text = model.name
        guard let url = URL(string: model.imageURLString) else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard
                let data = data,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 400,
                error == nil else { return }
            DispatchQueue.main.async { [weak self] in
                self?.pokemonImageView.image = UIImage(data: data)
            }
        }
        .resume()
    }
}
  • 이미지 및 이름 라벨을 보여줄 커스텀 테이블 뷰 셀
class ExpandableTableViewController: UIViewController {
    
    typealias SectionPair = (section: PokemonSection, isOpen: Bool)
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(ExpandableTableViewCell.self, forCellReuseIdentifier: ExpandableTableViewCell.identifier)
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tableViewCell")
        tableView.dataSource = self
        tableView.delegate = self
        return tableView
    }()
    private var sections: [SectionPair] = PokemonSection.allCases.map({($0, false)})

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = view.bounds
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(tableView)
    }
}
  • typeAlias를 통해 각 섹션 및 토글 여부를 간단하게 표현
  • 해당 데이터 배열을 통해 셀 UI 표현 가능
extension ExpandableTableViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        if indexPath.row == 0 {
            sections[indexPath.section].isOpen.toggle()
            tableView.reloadSections([indexPath.section], with: .automatic)
        } else {
            print("\(sections[indexPath.section].section.contents[indexPath.row - 1].name) is selected")
        }
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        if indexPath.row == 0 {
            return view.frame.size.height / 16
        } else {
            return view.frame.size.height / 8
        }
    }
}

extension ExpandableTableViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return sections.count
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let sectionPair = sections[section]
        if sectionPair.isOpen {
            return sectionPair.section.contents.count + 1
        } else {
            return 1
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let sectionPair = sections[indexPath.section]
        if indexPath.row == 0 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell", for: indexPath)
            cell.textLabel?.text = sectionPair.section.title
            return cell
        } else {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ExpandableTableViewCell.identifier, for: indexPath) as? ExpandableTableViewCell else { fatalError() }
            let model = sectionPair.section.contents[indexPath.row - 1]
            cell.configure(with: model)
            return cell
        }
    }
}
  • 각 섹션의 0번째 로우를 선택하는 경우가 곧 섹션을 선택하는 경우(각 테이블 섹션을 직접 구현하지는 않음)
  • 토글 여부를 통해 각 컨텐츠 셀을 보여줄 지를 결정, reloadSections를 통해 해당 섹션 셀만 렌더링

구현 화면

profile
JUST DO IT

0개의 댓글