[UIKit] TikTok Feed

Junyoung Park·2022년 12월 16일
0

UIKit

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

Swift 5: Build TikTok Feed in App (Xcode 11, 2020) - iOS Development

TikTok Feed

구현 목표

  • 틱톡 피드 구현

구현 태스크

  • 틱톡 피드 컬렉션 뷰 구현
  • 커스텀 컬렉션 뷰 셀 구현
  • 유튜브 데이터 API 설정
  • 유튜브 데이터 로드
  • 현재 셀의 동영상 플레이어 자동재생 로직

핵심 코드

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TikTokCollectionViewCell.identifier, for: indexPath) as? TikTokCollectionViewCell else { fatalError() }
        let model = viewModel.data.value[indexPath.row]
        let input = cell.configure(with: model)
        bindCell(with: input)
        return cell
    }
  • 뷰 컨트롤러의 컬렉션 뷰의 셀을 구성하는 데이터 소스 함수
  • 커스텀 함수 configure이 특정 데이터를 입력받아 UI를 그리고, 동시에 리턴하는 퍼블리셔를 뷰 컨트롤러가 구독함으로써 일종의 델리게이트 역할을 컴바인이 대체
private func bindCell(with input: AnyPublisher<TikTokCollectionViewCell.Input, Never>) {
        input
            .sink { [weak self] result in
                switch result {
                case .didTapProfile(model: let model):
                    print("didTapProfile: \(model.snippet.title)")
                case .didTapLike(model: let model):
                    print("didTapLike: \(model.snippet.title)")
                case .didTapShare(model: let model):
                    print("didTapShare: \(model.snippet.title)")
                case .didTapComment(model: let model):
                    print("didTapComment: \(model.snippet.title)")
                }
            }
            .store(in: &cancellables)
    }
  • 특정 셀의 특정 버튼을 누른 이벤트를 뷰 컨트롤러가 감지하기 위한 바인딩 함수
  • 기존의 델리게이트 패턴이 아닌 다른 패턴으로 구현
  • 부모 뷰가 자식 뷰의 이벤트를 감지하고 싶을 때 사용 가능
override func prepareForReuse() {
        super.prepareForReuse()
        titleLabel.text = nil
        descriptionLabel.text = nil
        channelTitleLabel.text = nil
        input.send(completion: .finished)
    }
  • 뷰 컨트롤러가 해당 셀의 퍼블리셔를 구독하기 때문에 더 이상 셀을 사용하지 않고 셀을 재사용하는 시점에서 해당 퍼블리셔를 종료(컴플리션)
func configure(with model: MetaDataModel) -> AnyPublisher<Input, Never> {
        let playerVars = [
            "playsinline" : 1,
            "showinfo" : 0,
            "rel" : 0,
            "modestbranding" : 1,
            "controls" : 0
        ]
        playerView.load(withVideoId: model.id.videoId, playerVars: playerVars)
        input = .init()
        self.model = model
        
        titleLabel.text = model.snippet.title
        descriptionLabel.text = model.snippet.description
        channelTitleLabel.text = model.snippet.channelTitle
        return input.eraseToAnyPublisher()
    }
  • let이 아닌 var로 선언된 데이터 퍼블리셔를 configure 함수가 호출될 때마다 이니셜라이즈함으로써 이전의 completion된 퍼블리셔를 새롭게 생성 가능
extension TikTokViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        (cell as? TikTokCollectionViewCell)?.playVideo()
    }
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        (cell as? TikTokCollectionViewCell)?.stopVideo()
    }
}
  • 틱톡 피드의 핵심인 동영상 자동 재생을 구현하기 위한 코드
  • configure 상태에서 자동 재생이 호출된다면 동시에 여러 개의 셀이 구성되면서 여러 개의 동영상이 재생되기 때문에 현재 디바이스에 노출된 셀의 비디오만을 재생하고, 디바이스에서 벗어난다면 곧바로 비디오 중지

소스 코드

struct YoutubeDataService {
    static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL) throws -> Data {
        guard
            let response = output.response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else
        { throw APIError.badURLResponses(url: url) }
        return output.data
    }
    
    static func handleCompletion(completion: Subscribers.Completion<Error>) {
        switch completion {
        case .failure(let error):
            print(error.localizedDescription)
        case .finished: break
        }
    }
    
    static func downloadThumbnailImage(with model: MetaDataModel) -> AnyPublisher<Data, Error> {
        guard let url = URL(string: model.snippet.thumbnails.highThumbnail.url) else { return Fail(error: APIError.invalidURL).eraseToAnyPublisher() }
        return URLSession
            .shared
            .dataTaskPublisher(for: url)
            .tryMap({ try YoutubeDataService.handleURLResponse(output: $0, url: url) })
            .retry(3)
            .eraseToAnyPublisher()
    }
    
    func fetchVideoMetaData(with query: String) -> AnyPublisher<[MetaDataModel], Error> {
        guard let url = getURL(with: query) else {
            return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
        }
        return URLSession
            .shared
            .dataTaskPublisher(for: url)
            .tryMap({ try YoutubeDataService.handleURLResponse(output: $0, url: url) })
            .retry(3)
            .decode(type: MetaDataContentResponse.self, decoder: JSONDecoder())
            .map({ $0.items })
            .eraseToAnyPublisher()
    }
    
    private func getURL(with query: String) -> URL? {
        var urlComponents = URLComponents()
        urlComponents.scheme = "https"
        urlComponents.host = "www.googleapis.com"
        urlComponents.path = "/youtube/v3/search"
        urlComponents.queryItems = [
            .init(name: "part", value: "snippet"),
            .init(name: "q", value: query),
            .init(name: "videoDuration", value: "short"),
            .init(name: "type", value: "video"),
            .init(name: "key", value: Constants.Youtube_API_KEY.rawValue),
            .init(name: "maxResults", value: "10")
        ]
        guard let url = urlComponents.url else { return nil }
        return url
    }
    
}
  • 유튜브 API으로 쿼리문을 검색, 해당 결과를 리턴받아 유튜브 플레이어가 사용할 영상 아이디 및 기타 데이터를 얻어내기 위한 데이터 서비스
  • 쿼리 아이템을 통해 검색할 내용의 디테일을 설정 가능
import Foundation
import Combine

class TikTokViewModel {
    let data: CurrentValueSubject<[MetaDataModel], Never> = .init([])
    private let dataService = YoutubeDataService()
    
    init() {
        fetchData()
    }
    
    private func fetchData() {
        var dataSubscription: AnyCancellable?
        dataSubscription = dataService.fetchVideoMetaData(with: "Youtube Shorts")
            .sink(receiveCompletion: YoutubeDataService.handleCompletion, receiveValue: { [weak self] returnedValue in
                self?.data.send(returnedValue)
                dataSubscription?.cancel()
            })
    }
}
  • 뷰 컨트롤러와 매칭되는 뷰 모델
  • 이니셜라이즈 단에서 곧바로 API를 통해 유튜브 동영상 배열 데이터를 리턴
  • 최초의 데이터 패치만을 고려했으므로 곧바로 cancel을 통해 one-shot 퍼블리셔 구현
  • 쿼리 검색을 통한 새로운 동영상 데이터 패치 등 연결이 필요하다면 store를 통해 계속해서 연결하도록 구현
import UIKit
import Combine

class TikTokViewController: UIViewController {
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.itemSize = .init(width: view.frame.size.width, height: view.frame.size.height)
        layout.sectionInset = .zero
        layout.minimumLineSpacing = .zero
        layout.minimumInteritemSpacing = .zero
        
        layout.scrollDirection = .vertical
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.isPagingEnabled = true
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(TikTokCollectionViewCell.self, forCellWithReuseIdentifier: TikTokCollectionViewCell.identifier)
        collectionView.contentInsetAdjustmentBehavior = .never
        return collectionView
    }()
    private let viewModel = TikTokViewModel()
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(collectionView)
    }
    
    private func bind() {
        viewModel
            .data
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.collectionView.reloadData()
            }
            .store(in: &cancellables)
    }
}
  • 세이프 에리어를 무시하는 컬렉션 뷰 (contentInsetAdjustmentBehavior.never로 설정)
extension TikTokViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return viewModel.data.value.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TikTokCollectionViewCell.identifier, for: indexPath) as? TikTokCollectionViewCell else { fatalError() }
        let model = viewModel.data.value[indexPath.row]
        let input = cell.configure(with: model)
        bindCell(with: input)
        return cell
    }
    
    private func bindCell(with input: AnyPublisher<TikTokCollectionViewCell.Input, Never>) {
        input
            .sink { [weak self] result in
                switch result {
                case .didTapProfile(model: let model):
                    print("didTapProfile: \(model.snippet.title)")
                case .didTapLike(model: let model):
                    print("didTapLike: \(model.snippet.title)")
                case .didTapShare(model: let model):
                    print("didTapShare: \(model.snippet.title)")
                case .didTapComment(model: let model):
                    print("didTapComment: \(model.snippet.title)")
                }
            }
            .store(in: &cancellables)
    }
}

extension TikTokViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        (cell as? TikTokCollectionViewCell)?.playVideo()
    }
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        (cell as? TikTokCollectionViewCell)?.stopVideo()
    }
}
  • 컬렉션 뷰의 델리게이트 및 데이터 소스 함수
import UIKit
import YouTubeiOSPlayerHelper
import Combine

class TikTokCollectionViewCell: UICollectionViewCell {
    enum Input {
        case didTapProfile(model: MetaDataModel)
        case didTapLike(model: MetaDataModel)
        case didTapShare(model: MetaDataModel)
        case didTapComment(model: MetaDataModel)
    }
    
    static let identifier = "TikTokCollectionViewCell"
    private var playerView: YTPlayerView = {
        let playerView = YTPlayerView()
        return playerView
    }()
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.text = "TITLE"
        label.textAlignment = .left
        label.textColor = .white
        label.numberOfLines = 1
        return label
    }()
    private let descriptionLabel: UILabel = {
        let label = UILabel()
        label.text = "DESCRIPTION"
        label.textAlignment = .left
        label.textColor = .white
        label.numberOfLines = 1
        return label
    }()
    private let channelTitleLabel: UILabel = {
        let label = UILabel()
        label.text = "CHANNEL TITLE"
        label.textAlignment = .left
        label.textColor = .white
        label.numberOfLines = 1
        return label
    }()
    private lazy var likeButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.borderless()
        config.image = UIImage(systemName: "heart.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal)
        button.configuration = config
        button.addTarget(self, action: #selector(didTapLike), for: .touchUpInside)
        return button
    }()
    private lazy var profileButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.borderless()
        config.image = UIImage(systemName: "person.circle")?.withTintColor(.white, renderingMode: .alwaysOriginal)
        button.configuration = config
        button.addTarget(self, action: #selector(didTapProfile), for: .touchUpInside)
        return button
    }()
    private lazy var shareButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.borderless()
        config.image = UIImage(systemName: "arrowshape.turn.up.right.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal)
        button.configuration = config
        button.addTarget(self, action: #selector(didTapShare), for: .touchUpInside)
        return button
    }()
    private lazy var commentButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.borderless()
        config.image = UIImage(systemName: "text.bubble.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal)
        button.configuration = config
        button.addTarget(self, action: #selector(didTapComment), for: .touchUpInside)
        return button
    }()
    private var input: PassthroughSubject<Input, Never> = .init()
    private var model: MetaDataModel?
    private var cancellables = Set<AnyCancellable>()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        playerView.frame = contentView.bounds
        
        let size = contentView.frame.size.width / 7
        let width = contentView.frame.size.width
        let height = contentView.frame.size.height - 100
        
        shareButton.frame = CGRect(x: width-size, y: height-size - 10, width: size, height: size)
        commentButton.frame = CGRect(x: width-size, y: height-(size * 2) - 10, width: size, height: size)
        likeButton.frame = CGRect(x: width-size, y: height-(size * 3) - 10, width: size, height: size)
        profileButton.frame = CGRect(x: width-size, y: height-(size * 4) - 10, width: size, height: size)
        
        descriptionLabel.frame = CGRect(x: 5, y: height - size - 10, width: width - size - 10, height: 50)
        titleLabel.frame = CGRect(x: 5, y: height - (size * 2) - 10, width: width - size - 10, height: 50)
        channelTitleLabel.frame = CGRect(x: 5, y: height - (size * 3) - 10, width: width - size - 10, height: 50)
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        titleLabel.text = nil
        descriptionLabel.text = nil
        channelTitleLabel.text = nil
        input.send(completion: .finished)
    }
    
    func stopVideo() {
        playerView.stopVideo()
    }
    
    func playVideo() {
        playerView.playVideo()
    }
    
    private func setUI() {        
        contentView.backgroundColor = .label
        contentView.clipsToBounds = true
        contentView.addSubview(playerView)
        
        contentView.addSubview(titleLabel)
        contentView.addSubview(descriptionLabel)
        contentView.addSubview(channelTitleLabel)
        
        contentView.addSubview(profileButton)
        contentView.addSubview(likeButton)
        contentView.addSubview(shareButton)
        contentView.addSubview(commentButton)
    }
    
    @objc private func didTapProfile() {
        guard let model = model else { return }
        input.send(.didTapProfile(model: model))
    }
    
    @objc private func didTapLike() {
        guard let model = model else { return }
        input.send(.didTapLike(model: model))
    }
    
    @objc private func didTapShare() {
        guard let model = model else { return }
        input.send(.didTapShare(model: model))
    }
    
    @objc private func didTapComment() {
        guard let model = model else { return }
        input.send(.didTapComment(model: model))
    }
    
    func configure(with model: MetaDataModel) -> AnyPublisher<Input, Never> {
        let playerVars = [
            "playsinline" : 1,
            "showinfo" : 0,
            "rel" : 0,
            "modestbranding" : 1,
            "controls" : 0
        ]
        playerView.load(withVideoId: model.id.videoId, playerVars: playerVars)
        input = .init()
        self.model = model
        
        titleLabel.text = model.snippet.title
        descriptionLabel.text = model.snippet.description
        channelTitleLabel.text = model.snippet.channelTitle
        return input.eraseToAnyPublisher()
    }
}
  • 해당 셀이 configure될 때 동영상을 유튜브로부터 로드하면서 동시에 UI를 구성
  • 버튼 이벤트를 해당 셀을 구성하는 컬렉션 뷰를 가진 뷰 컨트롤러에 전달하기 위한 방법으로 델리게이트 패턴이 아닌 컴바인을 적용
  • prepareForReuse 단에서 해당 퍼블리셔를 종료하면서 configure 단에서 다시 이니셜라이즈

구현 화면

willDisplay, didEndDisplaying이라는 매우 효율적인 컬렉션 뷰 델리게이트 함수의 존재를 깨달았다! 사실 델리게이트 함수야말로 공식 사용법의 기본 중에 기본일 텐데... 사용법을 깨닫고 익숙하게 떠올리는 게 쉽지 않다.

profile
JUST DO IT
post-custom-banner

0개의 댓글