구현 목표
- 테이블뷰의 셀을 누르면 모달 형태의 sheet가 올라옵니다.
- 올라온 sheet를 보면 각 카테고리를 보여줍니다.
- 각 카테고리를 누르면 눌린 카테고리는 배경색과 글자 색이 변경됩니다.
- 선택완료 버튼을 누르면 sheet가 내려가고 테이블뷰의 셀에 선택된 카테고리가 표시됩니다.
🔥 카테고리 목록을 표시하는 방식
- UIStackView 사용:
- UIStackView는 고정된 아이템 수를 표시할 때 간단하게 사용할 수 있습니다.
- 이미지처럼 아이템이 고정되어 있고, 레이아웃이 바뀌지 않는다면 UIStackView를 사용해 각 카테고리를 레이블로 추가할 수 있습니다.
- 그러나 화면 크기에 따라 자동으로 줄바꿈을 하거나, 동적으로 아이템 수가 변할 경우 레이아웃 조정이 어렵습니다.
- UICollectionView 사용:
- 유동적인 카테고리 아이템 수와 크기에 대응하기 위해서는 UICollectionView를 사용하는 것이 좋습니다.
- UICollectionView는 셀의 크기와 레이아웃을 자동으로 조정할 수 있어서 다양한 화면 크기와 유동적인 데이터에 대응하기 용이합니다.
- 화면 회전이나, 다른 크기의 디바이스에 맞게 자동으로 레이아웃이 조정되므로, 유지보수와 확장성이 더 좋습니다.
🔥 여기서는 UICollectionView를 사용했습니다.
구현 순서
1. CategorySheetViewController
이 코드는 사용자가 카테고리를 선택할 수 있는 시트 형태의 CategorySheetViewController를 구현한 것입니다. 이 뷰 컨트롤러는 카테고리를 선택하고, 선택된 카테고리를 상위 뷰 컨트롤러에 전달하는 기능을 제공합니다.
import UIKit
class CategorySheetViewController: UIViewController {
// MARK: - Variables
private let categories = ["반려동물 🐶", "애인 💑", "가족 👫", "아이 👶", "여행 🛫", "커피 ☕️","차 🫖","알코올 🚰", "드라이브 🚗","맛집 🥘", "산책 🌲", "뷰맛집 🌇",]
var onCategorySelected: ((String) -> Void)? // 선택된 카테고리를 전달할 클로저
private var selectedCategory: String? // 현재 선택된 카테고리를 저장하는 변수
private var selectedIndexPath: IndexPath? // 선택된 셀의 인덱스를 저장하는 변수
// MARK: - UI Components
let basicView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
return view
}()
let titleLabel: UILabel = {
let label = UILabel()
label.text = "여행로그의 카테고리를 선택해주세요 😀"
label.font = .systemFont(ofSize: 18, weight: .semibold)
label.textColor = .label
label.textAlignment = .left
return label
}()
let categoryCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 10
layout.minimumInteritemSpacing = 5
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize // 셀 크기를 자동으로 조정
// layout.itemSize = CGSize(width: 100, height: 30)
layout.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.register(CategoryCell.self .self, forCellWithReuseIdentifier: CategoryCell.identifier)
collectionView.backgroundColor = .clear
return collectionView
}()
let applyButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("선택 완료", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .black
button.layer.cornerRadius = 10
button.isEnabled = true
return button
}()
// MARK: - Initializations
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
configureConstraints()
configureCollectionView()
applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let sheet = self.sheetPresentationController {
sheet.detents = [.medium()] // 시트 높이 설정
sheet.prefersGrabberVisible = true // 손잡이 표시
}
}
// MARK: - Layouts
private func configureConstraints() {
view.addSubview(basicView)
basicView.addSubview(titleLabel)
basicView.addSubview(categoryCollectionView)
basicView.addSubview(applyButton)
basicView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
categoryCollectionView.translatesAutoresizingMaskIntoConstraints = false
applyButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
basicView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
basicView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
basicView.topAnchor.constraint(equalTo: view.topAnchor), // 상단 제약 추가
basicView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
titleLabel.leadingAnchor.constraint(equalTo: basicView.leadingAnchor, constant: 10),
titleLabel.trailingAnchor.constraint(equalTo: basicView.trailingAnchor, constant: -10),
titleLabel.topAnchor.constraint(equalTo: basicView.topAnchor, constant: 20),
categoryCollectionView.leadingAnchor.constraint(equalTo: basicView.leadingAnchor, constant: 10),
categoryCollectionView.trailingAnchor.constraint(equalTo: basicView.trailingAnchor, constant: -10),
categoryCollectionView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20),
categoryCollectionView.heightAnchor.constraint(equalToConstant: 250),
//categoryCollectionView.bottomAnchor.constraint(equalTo: applyButton.topAnchor, constant: -10), // applyButton과의 제약 추가
applyButton.leadingAnchor.constraint(equalTo: basicView.leadingAnchor, constant: 20),
applyButton.trailingAnchor.constraint(equalTo: basicView.trailingAnchor, constant: -20),
applyButton.bottomAnchor.constraint(equalTo: basicView.safeAreaLayoutGuide.bottomAnchor, constant: -10),
applyButton.heightAnchor.constraint(equalToConstant: 50)
])
}
// MARK: - Functions
func configureCollectionView() {
categoryCollectionView.delegate = self
categoryCollectionView.dataSource = self
categoryCollectionView.register(CategoryCell.self, forCellWithReuseIdentifier: CategoryCell.identifier)
}
// MARK: - Actions
@objc private func applyButtonTapped() {
if let selectedCategory = selectedCategory {
onCategorySelected?(selectedCategory) // 선택된 카테고리 전달
}
dismiss(animated: true, completion: nil)
}
}
extension CategorySheetViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return categories.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CategoryCell.identifier, for: indexPath) as? CategoryCell else {
return UICollectionViewCell()
}
let category = categories[indexPath.item]
cell.configure(with: category)
// 선택된 카테고리에 따라 셀 배경색 변경
if indexPath == selectedIndexPath {
cell.basicView.backgroundColor = .black
cell.categoryLabel.textColor = .white
} else {
cell.basicView.backgroundColor = .secondarySystemBackground
cell.categoryLabel.textColor = .label
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let previousSelectedIndexPath = selectedIndexPath
selectedIndexPath = indexPath // 새로 선택된 인덱스로 업데이트
let selectedItem = categories[indexPath.item]
self.selectedCategory = selectedItem
// 이전에 선택된 셀이 있으면 해당 셀을 리로드하여 원래 상태로 복구
if let previousSelectedIndexPath = previousSelectedIndexPath {
collectionView.reloadItems(at: [previousSelectedIndexPath])
}
// 현재 선택된 셀 리로드하여 선택 상태로 표시
collectionView.reloadItems(at: [indexPath])
}
}
1. 변수 선언 (Variables)
- categories: 카테고리의 목록을 문자열 배열로 정의합니다. 각 카테고리는 이모지와 함께 표현됩니다.
- onCategorySelected: 선택된 카테고리를 전달하기 위한 클로저로, 선택이 완료되면 이 클로저를 호출하여 상위 뷰 컨트롤러에 선택된 카테고리를 전달합니다.
- selectedCategory: 현재 선택된 카테고리를 저장하는 변수입니다.
- selectedIndexPath: 현재 선택된 셀의 인덱스를 저장하는 변수입니다.
2. UI 컴포넌트 (UI Components)
- basicView: 배경 역할을 하는 뷰입니다.
- titleLabel: 카테고리 선택 안내 문구를 보여주는 레이블입니다.
- categoryCollectionView: 카테고리 목록을 보여줄 컬렉션 뷰로, 플로우 레이아웃을 사용하며 셀 크기는 자동으로 조정됩니다.
- applyButton: 선택을 완료하는 버튼입니다. 버튼을 눌렀을 때 applyButtonTapped 메서드가 호출됩니다.
3. 초기화 및 뷰 설정 (Initialization)
- viewDidLoad: 뷰가 로드될 때 호출됩니다. 여기서 configureConstraints()와 configureCollectionView()를 호출하여 레이아웃과 컬렉션 뷰를 설정하고, 버튼 액션을 추가합니다.
- viewWillAppear: 시트가 화면에 나타날 때 호출되며, sheetPresentationController를 통해 시트의 높이를 중간 크기(.medium())로 설정하고, 손잡이 표시(prefersGrabberVisible)를 활성화합니다.
4. 레이아웃 설정 (Layouts)
- configureConstraints: 뷰에 추가된 UI 요소의 제약 조건을 설정하여 레이아웃을 구성합니다.
- basicView, titleLabel, categoryCollectionView, applyButton의 위치와 크기를 지정합니다.
- categoryCollectionView는 제목 레이블 아래에 위치하고, applyButton 위에 위치하게 제약이 설정됩니다.
- applyButton은 화면의 안전 영역 하단에 붙어 표시됩니다.
5. 컬렉션 뷰 설정 (configureCollectionView)
- 컬렉션 뷰의 delegate와 dataSource를 현재 뷰 컨트롤러(self)로 설정하고, CategoryCell 셀을 등록합니다.
6. 액션 메서드 (Actions)
- applyButtonTapped: 사용자가 카테고리를 선택한 후 '선택 완료' 버튼을 누르면 호출됩니다.
- selectedCategory가 존재하면 onCategorySelected 클로저를 호출해 상위 뷰 컨트롤러에 선택된 카테고리를 전달합니다.
- 이후 시트를 닫기 위해 dismiss를 호출합니다.
7. 컬렉션 뷰 데이터 소스 및 델리게이트 (UICollectionViewDelegate, UICollectionViewDataSource)
- numberOfItemsInSection: categories 배열의 개수를 반환하여 카테고리 개수만큼 셀이 표시되도록 합니다.
- cellForItemAt: 각 셀에 대해 CategoryCell을 가져와 카테고리 데이터를 설정하고, 선택 상태에 따라 셀의 배경색과 텍스트 색상을 다르게 지정합니다.
- selectedIndexPath와 일치하는 셀은 배경을 검정색, 텍스트 색상을 흰색으로 설정하여 선택 상태를 시각적으로 나타냅니다.
- didSelectItemAt: 셀이 선택되었을 때 호출됩니다.
- 선택된 셀의 indexPath를 selectedIndexPath에 저장하고, 선택된 카테고리를 selectedCategory에 저장합니다.
- 이전에 선택된 셀이 있으면 해당 셀을 리로드하여 원래 상태로 복구하고, 새로 선택된 셀도 리로드하여 선택 상태로 표시합니다.
이 전체 코드의 흐름은 사용자가 카테고리를 선택하고 '선택 완료' 버튼을 통해 선택한 카테고리를 상위 뷰 컨트롤러에 전달하며, 시각적으로 선택 상태를 나타내는 기능을 구현하고 있습니다.
2. FeedViewController
- 사용자가 카테고리 선택 셀을 탭합니다.
- CategorySheetViewController가 모달 시트로 표시됩니다.
- 사용자가 카테고리를 선택하고 완료 버튼을 누르면, 선택한 카테고리가 onCategorySelected 클로저를 통해 FeedViewController로 전달됩니다.
- CategoryTableViewCell의 selectedCategoryLabel이 업데이트되어 선택된 카테고리가 반영됩니다.
import UIKit
import PhotosUI
class FeedViewController: UIViewController {
// MARK: - Variables
var selectedDate: Date?
var selectedCategories: String?
...
func setupTableView() {
...
feedTableView.register(CategoryTableViewCell.self, forCellReuseIdentifier: CategoryTableViewCell.identifier)
}
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch (indexPath.section, indexPath.row) {
...
case (2, 2):
guard let cell = tableView.dequeueReusableCell(withIdentifier: CategoryTableViewCell.identifier, for: indexPath) as? CategoryTableViewCell else { return UITableViewCell() }
cell.selectionStyle = .none
return cell
default:
return UITableViewCell()
}
}
...
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
...
if indexPath.section == 2 && indexPath.row == 2 { // 카테고리 선택 셀을 눌렀을 때
let categorySheetVC = CategorySheetViewController()
categorySheetVC.modalPresentationStyle = .pageSheet
if let sheet = categorySheetVC.sheetPresentationController {
sheet.detents = [.medium()]
sheet.prefersGrabberVisible = true
}
// 카테고리 선택 시 호출될 클로저
categorySheetVC.onCategorySelected = { [weak self] selectedCategory in
guard let self = self else { return }
self.selectedCategories = selectedCategory
guard let cell = tableView.cellForRow(at: indexPath) as? CategoryTableViewCell else { return }
cell.selectedCategoryLabel.text = selectedCategory
}
present(categorySheetVC, animated: true, completion: nil)
}
}
3. CategoryCell
import UIKit
class CategoryCell: UICollectionViewCell {
// MARK: - Variables
static let identifier: String = "CategoryCell"
// MARK: - UI Components
let basicView: UIView = {
let view = UIView()
view.backgroundColor = .secondarySystemBackground
view.layer.borderWidth = 1
view.layer.borderColor = UIColor.label.cgColor
view.layer.cornerRadius = 10
view.clipsToBounds = true
return view
}()
let categoryLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .bold)
label.textColor = .label
label.textAlignment = .center
label.numberOfLines = 1 // 한 줄로 제한
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
// MARK: - Initializations
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(basicView)
basicView.addSubview(categoryLabel)
basicView.translatesAutoresizingMaskIntoConstraints = false
categoryLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
basicView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 5),
basicView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -5),
basicView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
basicView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5),
categoryLabel.leadingAnchor.constraint(equalTo: basicView.leadingAnchor, constant: 10),
categoryLabel.trailingAnchor.constraint(equalTo: basicView.trailingAnchor, constant: -10),
categoryLabel.topAnchor.constraint(equalTo: basicView.topAnchor, constant: 10),
categoryLabel.bottomAnchor.constraint(equalTo: basicView.bottomAnchor, constant: -10)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Functions
func configure(with text: String) {
categoryLabel.text = text
}
}
4. CategoryTableViewCell
import UIKit
class CategoryTableViewCell: UITableViewCell {
// MARK: - Variables
static let identifier: String = "CategoryTableViewCell"
// MARK: - UI Components
let basicView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
view.layer.cornerRadius = 10
view.clipsToBounds = true
return view
}()
let titleLabel: UILabel = {
let label = UILabel()
label.text = "카테고리 선택"
label.font = .systemFont(ofSize: 16, weight: .bold)
label.textColor = .label
return label
}()
let selectedCategoryLabel: UILabel = {
let label = UILabel()
label.text = "카테고리를 선택해주세요"
label.textColor = .label
label.font = .systemFont(ofSize: 16, weight: .semibold)
return label
}()
let rightImageView: UIImageView = {
let imageView = UIImageView()
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "chevron.right", withConfiguration: config)
imageView.image = image
imageView.tintColor = .label
return imageView
}()
// MARK: - Initializations
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.backgroundColor = .systemBackground
configureConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Layouts
private func configureConstraints() {
contentView.addSubview(basicView)
basicView.addSubview(titleLabel)
basicView.addSubview(selectedCategoryLabel)
basicView.addSubview(rightImageView)
basicView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
selectedCategoryLabel.translatesAutoresizingMaskIntoConstraints = false
rightImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
basicView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
basicView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
basicView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
basicView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5),
titleLabel.leadingAnchor.constraint(equalTo: basicView.leadingAnchor, constant: 10),
titleLabel.centerYAnchor.constraint(equalTo: basicView.centerYAnchor),
rightImageView.trailingAnchor.constraint(equalTo: basicView.trailingAnchor, constant: -10),
rightImageView.centerYAnchor.constraint(equalTo: basicView.centerYAnchor),
rightImageView.heightAnchor.constraint(equalToConstant: 20),
rightImageView.widthAnchor.constraint(equalToConstant: 20),
selectedCategoryLabel.trailingAnchor.constraint(equalTo: rightImageView.leadingAnchor, constant: -10),
selectedCategoryLabel.centerYAnchor.constraint(equalTo: basicView.centerYAnchor)
])
}
// MARK: - Functions
func updateSelectedDate(with selectedCategory: String) {
selectedCategoryLabel.text = selectedCategory
}
}
'iOS > UIKIT' 카테고리의 다른 글
UIImage와 UIImageView의 차이 (1) | 2024.11.10 |
---|---|
갤러리에서 선택한 내용을 컬렉션 뷰에 보이는 방법 (0) | 2024.11.08 |
날짜 선택하기 (0) | 2024.11.06 |
UILabel 에서 패딩 효과를 주고 싶다면? (0) | 2024.10.30 |
검색한 결과에서 중복된 데이터 통합하기 (1) | 2024.10.30 |