[UIKit] NetflixClone: View & Data Binding

Junyoung Park·2022년 11월 2일
0

UIKit

목록 보기
75/142
post-thumbnail
post-custom-banner

Building Netflix App in Swift 5 and UIKit - Episode 13 - Hooking things together

NetflixClone: View & Data Binding

구현 목표

  • 컨텐츠 클릭 시 디테일 뷰 네비게이션 푸쉬
  • 재생 버튼 클릭 시 유튜브 API를 통한 동영상 자동재생

구현 태스크

  • 검색 버튼 클릭 시 검색 결과를 통해 새로운 셀 UI 그리기
  • 검색 결과 셀 클릭 시 디테일 뷰 네비게이션 푸쉬
  • 홈 테이블 뷰 내 컬렉션 뷰 셀 클릭 시 네비게이션 푸쉬

핵심 코드

import UIKit
import Combine
import YouTubeiOSPlayerHelper

class ContentDetailViewController: UIViewController {
    private let viewModel: ContentDetailViewModel
    private let scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.backgroundColor = .clear
        scrollView.showsVerticalScrollIndicator = false
        return scrollView
    }()
    private let contentView: UIView = {
        let view = UIView()
        return view
    }()
    private let playerView: YTPlayerView = {
        let playerView = YTPlayerView(frame: .zero)
        playerView.isHidden = true
        return playerView
    }()
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.image = UIImage(named: "video")
        return imageView
    }()
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.font = .preferredFont(forTextStyle: .headline)
        label.textAlignment = .left
        label.numberOfLines = 0
        return label
    }()
    private let overviewLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.font = .preferredFont(forTextStyle: .body)
        label.textColor = .label
        label.numberOfLines = 0
        return label
    }()
    private let playButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        let title = NSAttributedString(string: "재생", attributes: [NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)])
        config.attributedTitle = AttributedString(title)
        config.image = UIImage(systemName: "play.fill")?.withTintColor(.black, renderingMode: .alwaysOriginal)
        config.imagePlacement = .leading
        config.imagePadding = 5
        config.baseBackgroundColor = .white
        config.baseForegroundColor = .black
        button.configuration = config
        return button
    }()
    private let downloadButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        let title = NSAttributedString(string: "저장", attributes: [NSAttributedString.Key.foregroundColor: UIColor.white, NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)])
        config.attributedTitle = AttributedString(title)
        config.image = UIImage(systemName: "square.and.arrow.down")?.withTintColor(.white, renderingMode: .alwaysOriginal)
        config.imagePlacement = .leading
        config.imagePadding = 5
        config.baseBackgroundColor = .systemGray
        config.baseForegroundColor = .white
        button.configuration = config
        return button
        
    }()
    private let metadataLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textColor = .systemGray
        label.textAlignment = .left
        label.font = .preferredFont(forTextStyle: .footnote)
        return label
    }()
    private var cancellables = Set<AnyCancellable>()
    
    init(viewModel: ContentDetailViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    private func bind() {
        playButton
            .tapPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                if let id = self?.viewModel.videoId {
                    let playerVars = [
                        "playsinline" : 1,
                        "showinfo" : 0,
                        "rel" : 0,
                        "modestbranding" : 1,
                        "controls" : 1
                    ]
                    self?.playVideo()
                    self?.playerView.load(withVideoId: id, playerVars: playerVars)
                } else {
                    print("Cannot Fetch Id")
                }
            }
            .store(in: &cancellables)
    }
    
    private func playVideo() {
        imageView.isHidden = true
        playerView.isHidden = false
    }
    
    func stopVideo() {
        if !playerView.isHidden {
            playerView.stopVideo()
            imageView.isHidden = false
            playerView.isHidden = true
        }
    }
    
    private func setUI() {
        view.backgroundColor = .black
        view.addSubview(scrollView)
        playerView.delegate = self
        scrollView.delegate = self
        scrollView.addSubview(contentView)
        contentView.addSubview(imageView)
        contentView.addSubview(playerView)
        contentView.addSubview(nameLabel)
        contentView.addSubview(playButton)
        contentView.addSubview(downloadButton)
        contentView.addSubview(overviewLabel)
        contentView.addSubview(metadataLabel)
        
        var cancellables: AnyCancellable?
        cancellables = viewModel.getImage()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { _ in
                cancellables?.cancel()
            }, receiveValue: { [weak self] image in
                self?.imageView.image = image
            })
        
        applyConstraints()
        if let title = viewModel.contentModel.title {
            nameLabel.text = title
        } else if let anotherTitle = viewModel.contentModel.original_title {
            nameLabel.text = anotherTitle
        } else {
            nameLabel.text = "Default Title"
        }
        overviewLabel.text = viewModel.contentModel.overview
        metadataLabel.text = "\(viewModel.contentModel.original_title ?? "")" + " " + "\(viewModel.contentModel.release_date ?? "")"
    }
    
    private func applyConstraints() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        contentView.translatesAutoresizingMaskIntoConstraints = false
        imageView.translatesAutoresizingMaskIntoConstraints = false
        playButton.translatesAutoresizingMaskIntoConstraints = false
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        overviewLabel.translatesAutoresizingMaskIntoConstraints = false
        playerView.translatesAutoresizingMaskIntoConstraints = false
        downloadButton.translatesAutoresizingMaskIntoConstraints = false
        metadataLabel.translatesAutoresizingMaskIntoConstraints = false
        let scrollViewConstraints = [
            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ]
        
        let contentViewConstraints = [
            contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
            contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
            contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor)
        ]
        
        NSLayoutConstraint.activate(scrollViewConstraints)
        NSLayoutConstraint.activate(contentViewConstraints)
        contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
        let contentViewHeight = contentView.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor)
        contentViewHeight.priority = .defaultLow
        contentViewHeight.isActive = true
        
        let imageViewConstraints = [
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            imageView.heightAnchor.constraint(equalToConstant: 500)
        ]
        let playerViewConstraints = [
            playerView.topAnchor.constraint(equalTo: contentView.topAnchor),
            playerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            playerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            playerView.heightAnchor.constraint(equalToConstant: 500)
        ]
        let nameLabelConstraints = [
            nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10),
            nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
        ]
        let playButtonConstraints = [
            playButton.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20),
            playButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            playButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            playButton.heightAnchor.constraint(equalToConstant: 20)
        ]
        let downloadButtonConstraints = [
            downloadButton.topAnchor.constraint(equalTo: playButton.bottomAnchor, constant: 10),
            downloadButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            downloadButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            downloadButton.heightAnchor.constraint(equalToConstant: 20)
        ]
        let overviewLabelConstraints = [
            overviewLabel.topAnchor.constraint(equalTo: downloadButton.bottomAnchor, constant: 10),
            overviewLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            overviewLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        ]
        let metadataLabelConstraints = [
            metadataLabel.topAnchor.constraint(equalTo: overviewLabel.bottomAnchor, constant: 10),
            metadataLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            metadataLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(imageViewConstraints)
        NSLayoutConstraint.activate(playerViewConstraints)
        NSLayoutConstraint.activate(nameLabelConstraints)
        NSLayoutConstraint.activate(playButtonConstraints)
        NSLayoutConstraint.activate(downloadButtonConstraints)
        NSLayoutConstraint.activate(overviewLabelConstraints)
        NSLayoutConstraint.activate(metadataLabelConstraints)
    }
}

extension ContentDetailViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let defaultY = imageView.frame.size.height
        let contentOffset = scrollView.contentOffset.y
        if defaultY <= contentOffset {
            stopVideo()
        }
    }
}

extension ContentDetailViewController: YTPlayerViewDelegate {
    func playerViewDidBecomeReady(_ playerView: YTPlayerView) {
        playerView.playVideo()
    }
}
  • 특정 컨텐츠가 담긴 뷰, 셀 등을 클릭했을 때 네비게이션 푸쉬로 쌓아주는 디테일 컨텐츠 뷰
  • 스크롤 뷰를 통해 디바이스 크기 이상으로 이미지 및 정보 컴포넌트가 추가되더라도 자동으로 스크롤 가능하게 구현
  • 재생 버튼을 통해 기존의 이미지 뷰를 감추고 유튜브 API를 통해 데이터 바인딩 및 자동 재생
  • 스크롤 오프셋 감지를 통해 더 이상 동영상을 보고 있지 않을 때 자동으로 이미지 뷰로 전환
protocol SearchResultViewControllerDelegate: AnyObject {
    func didTapResult(_ result: ContentModel)
}

extension SearchResultViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let model = viewModel.searchResultsModel.value[indexPath.row]
        delegate?.didTapResult(model)
    }
}
  • 검색 뷰 컨트롤러에서 결과 뷰로 보여줄 컨트롤러로부터 데이터를 받아오기 위한 델리게이트 프로토콜 함수 선언
  • 컬렉션 뷰가 선택될 때 해당 셀이 가지고 있는 모델 데이터를 기존의 검색 뷰 컨트롤러에게 전달
extension SearchViewController: SearchResultViewControllerDelegate {
    func didTapResult(_ result: ContentModel) {
        let detailVC = ContentDetailViewController(viewModel: ContentDetailViewModel(contentModel: result))
        navigationController?.pushViewController(detailVC, animated: true)
    }
}
  • 해당 검색 뷰의 델리게이트 함수를 사용하는 검색 뷰 컨트롤러
  • 전달받은 데이터를 통해 디테일 뷰의 뷰 모델을 이니셜라이즈 및 현재 시점의 네비게이션 컨트롤러를 통해 푸쉬

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글