본문 바로가기

Project/MovieClip

✅ 영화 평점 기능 구현

 

📌 평점 선택 기능 (RatingViewController)

✅ 목표

  • RatingViewController에서 슬라이더로 영화/TV 평점을 선택하고
  • 선택된 평점을 DetailHeaderView의 myScroeLabel에 반영

 

🔹 구현 흐름

  1. 사용자가 myScroeLabel을 누르면 DetailViewController에서 RatingViewController를 띄움
  2. RatingViewController에서 슬라이더 조작 시 값이 변경됨
  3. "완료" 버튼을 클릭하면 선택한 평점을 DetailViewController로 전달
  4. DetailViewController에서 받은 값을 DetailHeaderViewmyScroeLabel에 표시

 

1️⃣ RatingViewController (평점 선택 화면)

평점을 슬라이더를 이용해 0.5 단위로 선택할 수 있음.

📌 기능

  • 슬라이더 (0~10 범위, 0.5 단위로 조절)
  • 라벨에 선택한 값 표시
  • 완료 버튼 클릭 시, 델리게이트를 통해 값 전달
  • Bottom Sheet 형태로 표시됨

슬라이더 값 설정

  • isContinuous = true → 슬라이더 이동 시 즉시 반영
  • round(sender.value / stepValue) * stepValue → 0.5 단위로 설정
  • 슬라이더에 최소값(0)과 최대값(10) 아이콘 추가
  • 슬라이더 트랙 색상 지정 (초록색~노랑색)
import UIKit

class RatingViewController: UIViewController {
    
    // MARK: - Variable
    weak var delegate: RatingViewControllerDelegate?   // ✅ Delegate 선언
    private var selectedRatingPoint: String = ""       // ✅ 선택한 평점 저장 
    
    
    // MARK: - UI Component
    private let ratingLabel: UILabel = {
        let label = UILabel()
        label.text = "평점을 선택해주세요 😁"
        label.font = .systemFont(ofSize: 20, weight: .bold)
        label.textColor = .white
        label.textAlignment = .center
        return label
    }()
    
    private let slider: UISlider = {
        let slider = UISlider()
        slider.minimumValue = 0
        slider.maximumValue = 10
        
        let largeConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .bold)
        
        slider.minimumValueImage = UIImage(systemName: "0.square", withConfiguration: largeConfig)
        slider.maximumValueImage = UIImage(systemName: "10.square", withConfiguration: largeConfig)
        
        slider.backgroundColor = .black
        slider.thumbTintColor = .systemGray
        slider.value = 0
        
        slider.minimumValue = 0
        slider.maximumValue = 10
        
        slider.maximumTrackTintColor = .systemYellow
        slider.minimumTrackTintColor = .systemGreen
        
        slider.layer.cornerRadius = 10
        slider.layer.masksToBounds = true
        
        slider.isContinuous = true // ✅ 슬라이더를 특정 값에서 멈추도록 설정
        
        return slider
    }()
    
    private let selectedRatingButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setTitle("완료", for: .normal)
        button.layer.cornerRadius = 10
        button.layer.masksToBounds = true
        
        button.backgroundColor = .systemBlue
        button.tintColor = .white
        
        button.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold)
        return button
    }()
    
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        
        configureConstraints()
    
        slider.addTarget(self, action: #selector(sliderValueChange(_:)), for: .valueChanged)
        
        selectedRatingButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside)
    }
    
    
    // MARK: - Action
    @objc private func sliderValueChange(_ sender: UISlider) {
        
        let stepValue: Float = 0.5    // ✅ 0.5 단위로 눈금 끊기
        let roundedValue = round(sender.value / stepValue) * stepValue
        sender.value = roundedValue
        
        let showValue = String(format: "%.1f", roundedValue)
        self.selectedRatingPoint = showValue
        
        ratingLabel.text = "평점을 선택해주세요 😁" + "\(showValue)"
    }
    
    // ✅ 완료 버튼 클릭 시 델리게이트를 통해 값 전달
    @objc private func didTapDoneButton() {
        delegate?.didSlidedValue(with: selectedRatingPoint)
        dismiss(animated: true)
    }
    
    
    // MARK: - Layout
    private func configureConstraints() {
        view.addSubview(ratingLabel)
        view.addSubview(slider)
        view.addSubview(selectedRatingButton)
        
        ratingLabel.translatesAutoresizingMaskIntoConstraints = false
        slider.translatesAutoresizingMaskIntoConstraints = false
        selectedRatingButton.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            
            ratingLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            ratingLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 30),
            ratingLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            ratingLabel.heightAnchor.constraint(equalToConstant: 30),
            
            slider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30),
            slider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30),
            slider.topAnchor.constraint(equalTo: ratingLabel.bottomAnchor, constant: 30),
            slider.heightAnchor.constraint(equalToConstant: 60),
            
            selectedRatingButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30),
            selectedRatingButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30),
            selectedRatingButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
            selectedRatingButton.heightAnchor.constraint(equalToConstant: 50)
            
        ])
    }
}

// MARK: - Protocol: RatingViewControllerDelegate
protocol RatingViewControllerDelegate: AnyObject {
    func didSlidedValue(with value: String)
}

2️⃣ DetailViewController (평점 값 전달 및 UI 업데이트)

RatingViewController에서 선택한 값을 DetailHeaderView에 전달하여 myScroeLabel을 업데이트.

 

Bottom Sheet 설정

  • .custom(resolver: { context in return 300 }) → 300px 높이로 설정
  • sheet.prefersGrabberVisible = true → 상단 Grabber 표시
  • sheet.prefersScrollingExpandsWhenScrolledToEdge = false → 스크롤이 확장되지 않도록 설정

 

델리게이트를 통한 값 전달

  • RatingViewControllerDelegate 채택하여 didSlidedValue(with:) 구현
  • DetailHeaderViewmyScroeLabel 업데이트
extension DetailViewController: DetailHeaderViewDelegate {
    func didTapRating() {
        let ratingVC = RatingViewController()
        
        ratingVC.delegate = self  // ✅ Delegate 연결
        
        if let sheet = ratingVC.sheetPresentationController {
            sheet.detents = [
                .custom(resolver: { context in return 300 })
            ]
            sheet.prefersGrabberVisible = true
            sheet.prefersScrollingExpandsWhenScrolledToEdge = false
        }
        
        present(ratingVC, animated: true, completion: nil)
    }
}

// MARK: - Extension: RatingViewControllerDelegate
extension DetailViewController: RatingViewControllerDelegate {
    func didSlidedValue(with value: String) {
        detailHeaderView?.updateMyScoreLabel(value: value)
    }
}
 

3️⃣ DetailHeaderView (UI 업데이트)

✅ myScroeLabel 업데이트하는 메서드 추가

extension DetailHeaderView {
    func updateMyScoreLabel(value: String) {
        myScroeLabel.text = "내 맘속 별점 ⭐ \(value)"
    }
}

 

🚀 개선할 점

  1. 별점 값을 Firebase에 저장할 예정이라면 → Combine 활용 가능 (제일 중요)
  2. myScroeLabel 클릭 시 애니메이션 효과 추가 가능 (UIView.animate 활용)
  3. RatingViewController의 초기값을 DetailHeaderView에서 가져온 값으로 설정하는 기능 추가

 


 

🔥 Combine으로 구현하는 방법 (추후 Firebase 연동)

Combine을 사용하면 DetailHeaderView에서 값이 변경될 때 즉시 UI를 업데이트하고, Firebase로 데이터를 저장할 수 있음.

 

✅ Combine을 사용해야 하는 경우

  • 실시간 데이터 저장이 필요할 때
  • 여러 UI에서 동일한 값(별점)을 표시할 때
  • 추후 ViewModel을 도입할 계획이라면

 

🔹 Combine을 활용한 개선된 코드 (예시)

import Combine

class RatingViewModel {
    @Published var rating: Float = 5.0  // ⭐ 별점 값
}

class RatingViewController: UIViewController {
    
    var viewModel: RatingViewModel
    
    init(viewModel: RatingViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private let slider: UISlider = {
        let slider = UISlider()
        slider.minimumValue = 0
        slider.maximumValue = 10
        slider.value = 5
        return slider
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        
        slider.addTarget(self, action: #selector(sliderValueChanged(_:)), for: .valueChanged)
    }
    
    @objc private func sliderValueChanged(_ sender: UISlider) {
        viewModel.rating = sender.value // ✅ ViewModel을 통해 값 전달
    }
}

 

🔹 DetailViewController에서 Combine 구독 (예시)

class DetailViewController: UIViewController {
    
    private var viewModel = RatingViewModel()
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // ✅ Combine 구독: 값이 변경될 때 UI 업데이트
        viewModel.$rating
            .sink { [weak self] value in
                self?.detailHeaderView?.updateMyScoreLabel(value: value)
                print("별점이 변경됨: \(value)")
                // ✅ Firebase에 저장하는 로직 추가 가능
            }
            .store(in: &cancellables)
    }
    
    func didTapRating() {
        let ratingVC = RatingViewController(viewModel: viewModel)
        present(ratingVC, animated: true, completion: nil)
    }
}