Este é um aplicativo feito para o estudo de diferentes abordagens de desenvolvimento da camada de View
. São duas muito comuns, uma que lista raças de cachorros em uma collection, e a outra é uma tela de detalhe que aparece quando você seleciona uma das raças.
- Gerar API Key em thedogapi.com
- Instalar o Bundler:
$ sudo gem install bundler
💎 O Bundler é um gerenciador de gemas (aplicações em ruby), e neste caso estamos instalando ele para usar a ferramenta cocoapods-keys
, como pode ser visto no arquivo Gemfile
na raiz do projeto. Esta ferramenta nos ajuda a evitar que nossas chaves privadas subam para o repositório.
- Clonar o repositório
- Instalar Gemas
$ Bundler install
- Instalar dependências do projeto
$ Bundle exec pod install
- Quando solicitado, entrar com API Key solicitada no terminal
O passo a passo a seguir detalha a refatoração da camada de de View deste projeto, de Storyboard + Xibs para View Coding usando UIKit.
Você pode dar um checkout para a branch viewCode/Storyboard
, ou para a tag live-code-start
para navegar até o momento inicial deste tutorial.
🏷 As tags marcadas ao longo do passo a passo te levam para o ponto do desenvolvimento onde estão. Navegue até a pasta do projeto, e digite o comando $ git checkout <tag-name>
no terminal para usar uma tag. (e.g. o comando $ git checkout live-code-start
te leva ao commit inicial do passo a passo a seguir)
2. Instanciar UINavigationController passando a BreedCollectionViewController com o root, e setar ela como window?.rootViewController no SceneDelegate
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
let controller = BreedsCollectionViewController()
let navigation = UINavigationController(rootViewController: controller)
window?.rootViewController = navigation
window?.backgroundColor = BackgroundColor.main
window?.makeKeyAndVisible()
}
3. Substituir outlets por views criadas programaticamente na BreedCollectionViewCell
// MARK: Views
let imageView: UIImageView = {
let image = UIImageView(frame: .zero)
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.translatesAutoresizingMaskIntoConstraints = false
return image
}()
let overlayView: UIView = {
let view = UIImageView(frame: .zero)
view.backgroundColor = BackgroundColor.overlay
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let nameLabel: UILabel = {
let label = UILabel(frame: .zero)
label.numberOfLines = 1
label.textColor = TextColor.primary
label.font = UIFont.systemFont(ofSize: 13, weight: .bold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
4. Criar extension adicionando views e configurando constraints
// MARK: Autolayout
extension BreedCollectionViewCell {
func setupViewHierarchy() {
addSubview(imageView)
addSubview(overlayView)
overlayView.addSubview(nameLabel)
}
func setupConstraints() {
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
overlayView.heightAnchor.constraint(equalToConstant: 24),
overlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
overlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
overlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
nameLabel.topAnchor.constraint(equalTo: overlayView.topAnchor),
nameLabel.bottomAnchor.constraint(equalTo: overlayView.bottomAnchor),
nameLabel.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor, constant: 16),
nameLabel.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor, constant: -16)
])
}
}
5. Criar init chamando os métodos na ordem correta
// MARK: Life Cycle
override init(frame: CGRect) {
super.init(frame: .zero)
setupViewHierarchy()
setupConstraints()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
6. Criar protocolo ViewCodable, e atualiza init com método setupViews()
protocol ViewCodable {
func setupViews()
func setupViewHierarchy()
func setupConstraints()
func setupAditionalConfiguration()
}
extension ViewCodable {
func setupViews() {
setupViewHierarchy()
setupConstraints()
setupAditionalConfiguration()
}
func setupAditionalConfiguration() { }
}
// MARK: Life Cycle
override init(frame: CGRect = .zero) {
super.init(frame: frame)
setupViews()
}
7. Escrever teste para visualizar interface da classe BreedCollectionViewCellSpec
import Quick
import Nimble
import Nimble_Snapshots
@testable import Breeds
class BreedCollectionViewCellSpec: QuickSpec {
override func spec() {
describe("BreedCollectionViewCell") {
var sut: BreedCollectionViewCell!
context("when initialized") {
beforeEach {
sut = BreedCollectionViewCell()
sut.setup(image: .stub(url: AssetHelper.LocalImage.carameloDog.url))
sut.frame.size = CGSize(width: 200, height: 200)
}
it("should layout itself properly") {
// expect(sut).toEventually(haveValidSnapshot(named: "BreedCollectionViewCell_Layout"), timeout: 0.5)
expect(sut).toEventually(recordSnapshot(named: "BreedCollectionViewCell_Layout", identifier: nil, usesDrawRect: false), timeout: 0.5)
}
}
}
}
}
8. Referência da pasta ReferenceImages no target de BreedsTests (opcional)
Atenção às configurações Copy items if needed, e Create folder references
🏷️live-code-goal
9. Atualizar o método setupNavigation() da BreedsCollectionViewController setando o title
func setupNavigation() {
title = "Breeds"
navigationController?.applyCustomAppearence()
navigationController?.navigationBar.prefersLargeTitles = true
}
10. Criar FlowLayout e Collection programaticamente
// MARK: Views
let collectionFlowLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.minimumLineSpacing = .zero
layout.minimumInteritemSpacing = .zero
return layout
}()
lazy var collectionView: UICollectionView = {
let collection = UICollectionView(frame: .zero, collectionViewLayout: collectionFlowLayout)
collection.delegate = self
collection.dataSource = self
collection.prefetchDataSource = self
collection.backgroundColor = BackgroundColor.main
collection.register(BreedCollectionViewCell.self, forCellWithReuseIdentifier: Identifier.Cell.breedCell)
collection.translatesAutoresizingMaskIntoConstraints = false
return collection
}()
11. Criar extension implementando o protocolo ViewCodable
// MARK: Autolayout
extension BreedsCollectionViewController: ViewCodable {
func setupViewHierarchy() {
view.addSubview(collectionView)
}
func setupConstraints() {
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
}
}
12. Chamar setupViews() no viewDidLoad()
// MARK: Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
setupNavigation()
viewModel.fetchImages()
}
🏷️live-code-extra-collection
13. Criar classe para componente BreedDetailLabel
class BreedDetailLabel: UILabel {
// MARK: Init
init() {
super.init(frame: .zero)
self.numberOfLines = 0
self.translatesAutoresizingMaskIntoConstraints = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Setup
func setup(title: String, description: String?) {
guard let description = description, !description.isEmpty else {
removeFromSuperview()
return
}
let attributedText = NSMutableAttributedString()
attributedText.bold(title)
attributedText.regular(description)
self.attributedText = attributedText
}
}
14. Substituir outlets por views criadas programaticamente na BreedDetailView, adicionando scrollView e stackView
// MARK: Views
let scrollView: UIScrollView = {
let scrollView = UIScrollView(frame: .zero)
scrollView.showsHorizontalScrollIndicator = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
return scrollView
}()
let breedImage: UIImageView = {
let image = UIImageView(frame: .zero)
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.translatesAutoresizingMaskIntoConstraints = false
return image
}()
let stackView: UIStackView = {
let stack = UIStackView(frame: .zero)
stack.spacing = 16.0
stack.axis = .vertical
stack.alignment = .fill
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
let nameLabel = BreedDetailLabel()
let weightLabel = BreedDetailLabel()
let heightLabel = BreedDetailLabel()
let lifespanLabel = BreedDetailLabel()
let temperamentLabel = BreedDetailLabel()
let bredForLabel = BreedDetailLabel()
let breedGroupLabel = BreedDetailLabel()
let originLabel = BreedDetailLabel()
15. Criar extension implementando o protocolo ViewCodable
// MARK: Autolayout
extension BreedDetailView: ViewCodable {
func setupViewHierarchy() {
addSubview(scrollView)
scrollView.addSubview(breedImage)
scrollView.addSubview(stackView)
stackView.addArrangedSubview(nameLabel)
stackView.addArrangedSubview(weightLabel)
stackView.addArrangedSubview(heightLabel)
stackView.addArrangedSubview(lifespanLabel)
stackView.addArrangedSubview(temperamentLabel)
stackView.addArrangedSubview(bredForLabel)
stackView.addArrangedSubview(breedGroupLabel)
stackView.addArrangedSubview(originLabel)
stackView.addArrangedSubview(nameLabel)
}
func setupConstraints() {
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: topAnchor),
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
breedImage.topAnchor.constraint(equalTo: scrollView.topAnchor),
breedImage.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
breedImage.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
breedImage.widthAnchor.constraint(equalTo: widthAnchor),
breedImage.heightAnchor.constraint(equalTo: breedImage.widthAnchor, multiplier: 1),
stackView.topAnchor.constraint(equalTo: breedImage.bottomAnchor, constant: 16),
stackView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.bottomAnchor, constant: -16),
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16)
])
}
func setupAditionalConfiguration() {
backgroundColor = BackgroundColor.main
}
}
16. Criar Init para a BreedDetailView
init() {
super.init(frame: .zero)
setupViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
super.init(coder: coder)
}
17. Atualizar setup com método do componente BreedDetailLabel e remover setup da BreedDetailView
func setup(breed: Breed?, imageUrl: String?) {
guard
let breed = breed,
let imageUrl = imageUrl
else { return }
breedImage.setImage(url: URL(string: imageUrl))
nameLabel.setup(title: "Name: ", description: breed.name)
weightLabel.setup(title: "Weight: ", description: breed.weight.metric)
heightLabel.setup(title: "Height: ", description: breed.height.metric)
lifespanLabel.setup(title: "Life Span: ", description: breed.lifeSpan)
temperamentLabel.setup(title: "Temperament: ", description: breed.temperament)
bredForLabel.setup(title: "Breed For: ", description: breed.bredFor)
breedGroupLabel.setup(title: "BreedGroup: ", description: breed.breedGroup)
originLabel.setup(title: "Origin: ", description: breed.origin)
}
// func setup(title: String, description: String?, label: UILabel) {
// guard let description = description, !description.isEmpty else {
// label.removeFromSuperview()
// return
// }
//
// let attributedText = NSMutableAttributedString()
// attributedText.bold(title)
// attributedText.regular(description)
// label.attributedText = attributedText
// }
18. Escrever teste para visualizar interface da classe BreedDetailView
import Quick
import Nimble
import Nimble_Snapshots
@testable import Breeds
class BreedDetailViewSpec: QuickSpec {
override func spec() {
describe("BreedCollectionView") {
var sut: BreedDetailView!
context("when initialized") {
beforeEach {
sut = BreedDetailView()
sut.setup(breed: .stub(), imageUrl: AssetHelper.LocalImage.carameloDog.url)
sut.frame.size = CGSize(width: 375, height: 600)
}
it("should layout itself properly") {
// expect(sut).toEventually(recordSnapshot(named: "BreedDetailView_Layout", identifier: nil, usesDrawRect: false), timeout: 0.5)
expect(sut).toEventually(haveValidSnapshot(named: "BreedDetailView_Layout"), timeout: 1)
}
}
}
}
}
19. Substituir outlet da baseView na BreedDetailViewController
// MARK: Views
let baseView = BreedDetailView()
20. Adaptar init da BreedDetailViewController, removendo o coder
// MARK: Init
init(breed: Breed, imageUrl: String) {
self.breed = breed
self.imageUrl = imageUrl
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
21. Atribuir baseView à view no override do método loadView()
// MARK: Life Cycle
override func loadView() {
view = baseView
}
22. Atualizar setup() da BreedDetailViewController com configuração do navigationItem
// MARK: Setup
private func setup() {
title = breed.name
navigationItem.largeTitleDisplayMode = .never
baseView.setup(breed: breed, imageUrl: imageUrl)
}
23. Substituir storyboard por instância direta da classe BreedDetailViewController no método showDetailForSelectedBreed()
func showDetailForSelectedBreed() {
guard
let selectedBreed = viewModel.currentSelectedBreed,
let selectedImageUrl = viewModel.currentSelectedImage?.url
else { return }
let breedDetailController = BreedDetailViewController(breed: selectedBreed, imageUrl: selectedImageUrl)
show(breedDetailController, sender: self)
}
🏷️live-code-extra-detail