iOS/UIKIT

테이블뷰의 셀을 누르면 Sheet가 올라오게 하는 방법 (feat. UISheetPresentationController)

밤새는 탐험가89 2024. 11. 7. 12:46

구현 목표 

  • 테이블뷰의 셀을 누르면 모달 형태의 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
    }
}