Swift 5: Build TikTok Feed in App (Xcode 11, 2020) - iOS Development
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
}
}
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()
})
}
}
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
이라는 매우 효율적인 컬렉션 뷰 델리게이트 함수의 존재를 깨달았다! 사실 델리게이트 함수야말로 공식 사용법의 기본 중에 기본일 텐데... 사용법을 깨닫고 익숙하게 떠올리는 게 쉽지 않다.