관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 테이블 뷰 안의 수평 스크롤 뷰 (UITableView, UICollectionVIew, HorizontalScrollView) 본문

UI 컴포넌트 (swift)

[iOS - swift] 테이블 뷰 안의 수평 스크롤 뷰 (UITableView, UICollectionVIew, HorizontalScrollView)

jake-kim 2022. 3. 10. 23:36

TableView Cell에 CollectionView가 있는 형태

구현 아이디어

메인1, 메인2는 TableView의 Header

  • tableView의 커스텀 Cell에 collectionView를 넣어서 구현
  • tableView의 커스텀 Cell은 collectionView를 가지고 있으므로, 커스텀 Cell에서 컬렉션 뷰에 뿌려줄 dataSource를 가지고 있는 상태

예제코드에서 사용한 프레임워크

  • 코드로 UI 구현 시 편리함을 위해 사용
pod 'SnapKit'
pod 'Then'

샘플 Model 정의

  • tableView와 collectionView에 표출될 샘플 모델 정의
    • collectionViewCell에 사용될 데이터: Subcategory의 colors
    • tableViewCell에 사용될 데이터: SubCategory의 name
    • tableViewHeader에 사용될 데이터: CategoryModel의 name
struct CategoryModel {
  let name: String
  let subCategoryList: [SubCategory]
}

struct SubCategory {
  let name: String
  let colors: [ColorModel]
}

struct ColorModel {
  let name: String
  let color: UIColor
}

let sampleModel = [
  CategoryModel(
    name: "메인1",
    subCategoryList: [
      SubCategory(
        name: "서브1.1",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브1.2",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      )
    ]
  ),
  CategoryModel(
    name: "메인2",
    subCategoryList: [
      SubCategory(
        name: "서브2.1",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브2.2",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브123",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      )
    ]
  ),
  CategoryModel(
    name: "메인3",
    subCategoryList: [
      SubCategory(
        name: "서브3.1",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      )
    ]
  ),
  CategoryModel(
    name: "메인4",
    subCategoryList: [
      SubCategory(
        name: "서브4.1",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브4.2",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브4.3",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브4.4",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브4.5",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      )
    ]
  ),
  CategoryModel(
    name: "메인5",
    subCategoryList: [
      SubCategory(
        name: "서브5.1",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브5.2",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      ),
      SubCategory(
        name: "서브5.3",
        colors: UIColor.getRandomColorList().map { ColorModel(name: "컬러(\($0.hexString)", color: $0) }
      )
    ]
  )
]

extension UIColor {
  static var randomColor: UIColor {
    UIColor(red: CGFloat(drand48()), green: CGFloat(drand48()), blue: CGFloat(drand48()), alpha: 1.0)
  }
  
  static func getRandomColorList() -> [UIColor] {
    (0...((10...30).randomElement() ?? 10))
      .map { _ in Self.randomColor }
  }
  
  var hexString: String {
    let components = self.cgColor.components
    let r = components?[0] ?? 0.0
    let g = components?[1] ?? 0.0
    let b = components?[2] ?? 0.0
    return String(
      format: "#%02lX%02lX%02lX",
      lroundf(Float(r * 255)),
      lroundf(Float(g * 255)),
      lroundf(Float(b * 255))
    )
  }
}
  • UI 상 가장 안쪽에 위치할 CollectionViewCell 정의
    • 컬러 UIView
    • 컬러 이름 UILabel

// ColorCollectionViewCell.swift

import UIKit
import SnapKit
import Then

final class ColorCollectionViewCell: UICollectionViewCell {
  static let id = "ColorCollectionViewCell"
  
  private let colorView = UIView()
  private let nameLabel = UILabel().then {
    $0.textColor = .black
    $0.font = .systemFont(ofSize: 14)
  }
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    self.addSubview(self.colorView)
    self.addSubview(self.nameLabel)
    self.colorView.snp.makeConstraints {
      $0.left.top.right.equalToSuperview()
    }
    self.nameLabel.snp.makeConstraints {
      $0.top.equalTo(self.colorView.snp.bottom)
      $0.left.bottom.right.equalToSuperview()
    }
  }
  required init?(coder: NSCoder) {
    fatalError()
  }
  override func prepareForReuse() {
    super.prepareForReuse()
    self.prepare(color: nil, name: nil)
  }
  func prepare(color: UIColor?, name: String?) {
    self.colorView.backgroundColor = color
    self.nameLabel.text = name
  }
}
  • TableViewCell 정의
    • 서브1.1 - UILabel
    • 수평 스크롤 뷰 - UICollectionView
    • collectionView에 뿌려줄 dataSource도 준비

//  CategoryTableViewCell.swift

import UIKit
import SnapKit
import Then

final class CategoryTableViewCell: UITableViewCell {
  static let id = "CategoryTableViewCell"
  
  private let titleLabel = UILabel().then {
    $0.textColor = .black
    $0.font = .systemFont(ofSize: 15)
  }
  private lazy var collectionView = UICollectionView(
    frame: .zero,
    collectionViewLayout: UICollectionViewFlowLayout().then {
      $0.scrollDirection = .horizontal
      $0.minimumLineSpacing = 2.0
      $0.minimumInteritemSpacing = 5.0
      $0.itemSize = CGSize(width: 150, height: 180)
    }
  ).then {
    $0.showsHorizontalScrollIndicator = false
    $0.contentInset = .init(top: 10, left: 10, bottom: 10, right: 10)
    $0.backgroundColor = .clear
    $0.dataSource = self
    $0.register(ColorCollectionViewCell.self, forCellWithReuseIdentifier: ColorCollectionViewCell.id)
  }
  private let bottomPaddingView = UIView().then {
    $0.backgroundColor = .white
  }
  
  private var colorModelList = [ColorModel]()
  
  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    self.backgroundColor = .systemGray4
    self.contentView.addSubview(self.titleLabel)
    self.contentView.addSubview(self.collectionView)
    self.contentView.addSubview(self.bottomPaddingView)
    self.titleLabel.snp.makeConstraints {
      $0.top.equalToSuperview()
      $0.left.equalToSuperview().inset(10)
    }
    self.collectionView.snp.makeConstraints {
      $0.top.equalTo(self.titleLabel.snp.bottom)
      $0.left.right.equalToSuperview()
    }
    self.bottomPaddingView.snp.makeConstraints {
      $0.left.right.bottom.equalToSuperview()
      $0.top.equalTo(self.collectionView.snp.bottom)
      $0.height.equalTo(10).priority(999)
    }
  }
  required init?(coder: NSCoder) {
    fatalError()
  }
  
  override func prepareForReuse() {
    super.prepareForReuse()
    self.prepare(subTitle: nil, colorModelList: [])
  }
  
  func prepare(subTitle: String?, colorModelList: [ColorModel]) {
    self.titleLabel.text = subTitle
    self.colorModelList = colorModelList
    self.collectionView.reloadData()
  }
}

extension CategoryTableViewCell: UICollectionViewDataSource {
  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    self.colorModelList.count
  }
  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ColorCollectionViewCell.id, for: indexPath) as! ColorCollectionViewCell
    let colorModel = self.colorModelList[indexPath.item]
    cell.prepare(color: colorModel.color, name: colorModel.name)
    return cell
  }
}
  • TableViewHeader 정의
    • 메인1 - UILabel

// MainCategoryHeaderView.swift

import UIKit
import SnapKit
import Then

final class MainCategoryHeaderView: UITableViewHeaderFooterView {
  static let id = "MainCategoryHeaderView"
  
  private let titleLabel = UILabel().then {
    $0.textColor = .black
    $0.numberOfLines = 0
    $0.font = .systemFont(ofSize: 22, weight: .bold)
  }
  private let bottomPaddingView = UIView()
  
  override init(reuseIdentifier: String?) {
    super.init(reuseIdentifier: reuseIdentifier)
    self.addSubview(self.titleLabel)
    self.addSubview(self.bottomPaddingView)
    self.titleLabel.snp.makeConstraints {
      $0.left.top.right.equalToSuperview()
    }
    self.bottomPaddingView.snp.makeConstraints {
      $0.left.right.bottom.equalToSuperview()
      $0.top.equalTo(self.titleLabel.snp.bottom)
      $0.height.equalTo(10).priority(999)
    }
  }
  required init?(coder: NSCoder) {
    fatalError()
  }
  override func prepareForReuse() {
    super.prepareForReuse()
    self.prepare(title: nil)
  }
  func prepare(title: String?) {
    self.titleLabel.text = title
  }
}
  • 사용하는쪽 - ViewController
    • dataSource를 tableView에도 맵핑하는 동시에, cellForRowAt에서도 각 cell에 collectionView에 뿌려줄 데이터도 넘기는 형태
// ViewController.swift

import UIKit
import SnapKit
import Then

class ViewController: UIViewController {
  private lazy var tableView = UITableView().then {
    $0.separatorStyle = .none
    $0.rowHeight = 220
    $0.dataSource = self
    $0.delegate = self
    $0.register(CategoryTableViewCell.self, forCellReuseIdentifier: CategoryTableViewCell.id)
    $0.register(MainCategoryHeaderView.self, forHeaderFooterViewReuseIdentifier: MainCategoryHeaderView.id)
  }
  
  private var dataSource = [CategoryModel]()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.addSubview(self.tableView)
    self.tableView.snp.makeConstraints {
      $0.edges.equalTo(self.view.safeAreaLayoutGuide)
    }
    self.dataSource = sampleModel
    self.tableView.reloadData()
  }
}

extension ViewController: UITableViewDataSource {
  func numberOfSections(in tableView: UITableView) -> Int {
    self.dataSource.count
  }
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    self.dataSource[section].subCategoryList.count
  }
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: CategoryTableViewCell.id, for: indexPath) as! CategoryTableViewCell
    let row = self.dataSource[indexPath.section].subCategoryList[indexPath.row]
    cell.prepare(subTitle: row.name, colorModelList: row.colors)
    return cell
  }
}

extension ViewController: UITableViewDelegate {
  func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: MainCategoryHeaderView.id) as! MainCategoryHeaderView
    cell.prepare(title: self.dataSource[section].name)
    return cell
  }
}

* 전체 코드: https://github.com/JK0369/ExTableCeollectionView

* 참고

https://johncodeos.com/how-to-add-uicollectionview-inside-uitableviewcell-using-swift/

 

Comments