Construir uma tela no Xcode a partir de um projeto do Sketch pode ser uma tarefa bem trabalhosa. Caso o projeto tenha sido impresso não é possível obter as medidas que foram utilizadas pelo designer. Cabe ao desenvolvedor tentar vários valores diferentes para chegar a um resultado aproximado do design. Mesmo que a equipe de desenvolvimento tenha licenças do Sketch e possa abrir o projeto, ainda assim, terão um pouco de trabalho para ver os espaços entre os componentes. O Zeplin veio como uma ferramenta que preenche este espaço entre o design do projeto e o desenvolvimento do produto.
O Zeplin permite que o desenvolvedor clique em um item do design e consiga ver todas as medidas necessárias para a construção do elemento. As medidas já estão no formato utilizado pelo Xcode. Além das medidas do elemento é possível ver as medidas em relação aos outros itens que estão em volta do elemento. Também é possível ver detalhes como o código das cores e os dados das fontes utilizadas pelo elemento selecionado.
Todas as cores e fontes utilizadas no projeto podem ser exportadas para código Swift ou Objective-C e incorporadas ao projeto. Ao invés de criar a cor informando o código RGB no UIColor
basta chamar o nome da cor gerada pelo Zeplin. Os assets também podem ser baixados pelo Zeplin permitindo que o desenvolvedor não dependa do designer para esta tarefa.
Visualisando o Design no Zeplin
Para exemplificar a utilização destas ferramentas irei utilizar o projeto do Rafael Conde disponível no GitHub.
O Sketch possui uma versão de avaliação que pode ser baixada no site. O Zeplin é grátis com a limitação de um projeto mas sem limitação de usuários. Ele pode ser baixado aqui.
Abra o projeto no Sketch, selecione o artboard e envie o mesmo para o Zeplin através do menu Plugins => Zeplin => Export Selected Artboards… ⌘E.
O artboard ficará disponível no Dashboard do Zeplin. Ele possui duas telas principais no projeto. O Dashboard (⌘D) e o Styleguide (⌘G).
No Dashboard você poderá visualizar todo o layaute e as propriedades de cada componente do layout.
No Styleguide ficam os recursos que serão exportados para o Xcode como fontes e cores. Veremos a seguir como selecionar estes recursos.
No Dashboard vamos selecionar o nome do usuário do segundo tweet “Myke Hurley”. Ao selecioná-lo no lado esquerdo será apresentado as propriedades do label como tamanho, fonte, cor, posição, etc.
Ao lado da fonte e da cor temos botões para inserir estes dados no Styleguide. Então você terá que selecionar todas as fontes e cores que serão exportadas para o Xcode.
Seguindo com o label do usuário podemos ver a distancia para os outros componentes ao passar o mouse sobre eles e mantendo o label selecionado. Vamos verificar as distancias top, leading, bottom e trealing.
Para baixar os assets basta selecioná-lo e depois clicar na botão na barra de propriedades.
Gerando os Tweets
Para testar o nosso projeto iremos utilizar uma resposta da API home_timeline do Twitter. Como o foco deste artigo é mostrar a criação da view, iremos obter a resposta através do console disponível no apigee. Acesse este endereço para abrir o console.
Selecione https://api.twitter.com/1.1
como Service e Oauth 1
como Authentication. Será aberta uma tela solicitando a permissão de acesso ao Twitter. Logue na sua conta e autorize o aplicativo.
No menu lateral esquerdo, selecione o método /statuses/home_timeline.json
.
Nos parâmetros da consulta coloque 50
no count. Clique em Send.
Será retornado um Response com o código 200 e mais abaixo teremos o json com o resultado.
Copie o json e cole em um arquivo. Você pode utilizar o site Code Beautify para formatar o json antes de colar no arquivo.
OBS: Talvez você tenha que substituir as strings "<a href="
por "<a href=\"
e " rel="nofollow">
por \" rel=\"nofollow\">
.
Eu salvei uma versão formatada com o nome Tweets.json
e uma versão compacta com o nome Tweets.min.json
.
Criando o Projeto
Crie um novo projeto iOS no Xcode. Escolha o template Single View Application. Dê o nome de TwitterClone e escolha a linguagem Swift.
Recentemente tenho visto muitas discussões sobre a utilização de Storyboard e xib versus construir a interface via código. Sempre utilizei storyboard desde que comecei a desenvolver para iOS. Na minha opinião o grande problema em utilizar storyboards é o controle de versão do xml gerado. Em equipes com muitos analistas o gerenciamento de conflitos torna-se um pesadelo para o desenvolvedores. Em função disto, comecei recentemente a criar interfaces utilizando apenas código para ver como seria minha adaptação. Recomendo o site Lets Build That App do Brian Voong que sempre monta os componentes via código em seus tutoriais.
Neste projeto não utilizaremos storyboards. Criaremos toda a interface via código.
Exclua o arquivo Main.storyboard
do projeto. Limpe o campo Main Interface da guia General do projeto.
Importando os assets, fontes e cores do Zeplin
Exporte os dez assets do projeto para o Xcode. Temos três na navigationBar que nomeei de Add Contact
, Search
e Compose Tweet
. Quatro na tabBar que nomeei de Home
, Notifications
, Messages
e Me
.E três abaixo do texto do tweet que nomeei de Reply
, Retweet
e Favorite
.
Também precisamos selecionar as cores utilizadas no design e clicar no botão como explicado anteriormente. Selecionei as seguintes cores: darkSkyBlue
, cloudyBlue
, blueyGrey
, charcoalGrey
, paleGrey
e windowsBlue
.
Para as fontes selecionei o título da navigationBar e nomeei de titleMenubarFont
, o label com o nome do usuário do tweet que nomeei de nameFont
, o texto do tweet que nomeei de textFont
, o tempo passado desde a criação do tweet que nomeei de timeLabelFont
e o tótulo da tabBar que nomeei de tabBarItem
.
Criando os modelos
Criei algumas structs para servir de modelo para os dados do Twitter. São structs simples com apenas parte dos dados retornado pela API para que possamos montar a nossa tela. Implementei o método load(fromJson:) -> Self? para carregar os dados do JSON.
Tweet
import Foundation
struct Tweet {
let id: Int
let text: String
let created_at: Date
let favorited: Bool
let retweet_count: Int
let retweeted: Bool
let favorite_count: Int
let user: User?
let entities: TwitterEntity?
let extendedEntities: TwitterExtendedEntity?
}
extension Tweet {
static func load(fromJson json: JSON) -> Tweet? {
guard let id = json["id"] as? Int else { return nil }
guard let text = json["text"] as? String else { return nil }
guard let created_at_string = json["created_at"] as? String, let created_at = dateFormatter.date(from: created_at_string) else { return nil }
guard let favorited = json["favorited"] as? Bool else { return nil }
guard let retweet_count = json["retweet_count"] as? Int else { return nil }
guard let retweeted = json["retweeted"] as? Bool else { return nil }
guard let favorite_count = json["favorite_count"] as? Int else { return nil }
guard let userJson = json["user"] as? JSON else { return nil }
guard let entitiesJson = json["entities"] as? JSON else { return nil }
let extendedEntitiesJson: JSON? = json["extended_entities"] as? JSON
return Tweet(id: id, text: text, created_at: created_at, favorited: favorited, retweet_count: retweet_count, retweeted: retweeted, favorite_count: favorite_count, user: User.load(fromJson: userJson), entities: TwitterEntity.load(fromJson: entitiesJson), extendedEntities: TwitterExtendedEntity.load(fromJson: extendedEntitiesJson))
}
}
extension Tweet: Equatable {
static func == (lhs: Tweet, rhs: Tweet) -> Bool {
return lhs.id == rhs.id
}
}
TwitterEntity
import Foundation
struct TwitterEntity {
let hashtags: [TwitterHashtag]
let mentions: [TwitterMention]
let urls: [TwitterURL]
let medias: [TwitterMedia]
}
extension TwitterEntity {
static func load(fromJson json: JSON) -> TwitterEntity? {
var hashtags = [TwitterHashtag]()
var mentions = [TwitterMention]()
var urls = [TwitterURL]()
var medias = [TwitterMedia]()
if let hashtagsJson = json["hashtags"] as? [JSON] {
for hashtagJson in hashtagsJson {
if let hashtag = TwitterHashtag.load(fromJson: hashtagJson) {
hashtags.append(hashtag)
}
}
}
if let mentionsJson = json["user_mentions"] as? [JSON] {
for mentionJson in mentionsJson {
if let mention = TwitterMention.load(fromJson: mentionJson) {
mentions.append(mention)
}
}
}
if let urlsJson = json["urls"] as? [JSON] {
for urlJson in urlsJson {
if let url = TwitterURL.load(fromJson: urlJson) {
urls.append(url)
}
}
}
if let mediasJson = json["media"] as? [JSON] {
for mediaJson in mediasJson {
if let media = TwitterMedia.load(fromJson: mediaJson) {
medias.append(media)
}
}
}
return TwitterEntity(hashtags: hashtags, mentions: mentions, urls: urls, medias: medias)
}
}
extension TwitterEntity: Equatable {
static func == (lhs: TwitterEntity, rhs: TwitterEntity) -> Bool {
return lhs.hashtags == rhs.hashtags && lhs.mentions == rhs.mentions && lhs.urls == rhs.urls && lhs.medias == rhs.medias
}
}
TwitterExtendedEntity
import Foundation
struct TwitterExtendedEntity {
let photos: [TwitterMedia]
}
extension TwitterExtendedEntity {
static func load(fromJson json: JSON?) -> TwitterExtendedEntity? {
var photos = [TwitterMedia]()
if let mediasJson = json?["media"] as? [JSON] {
for mediaJson in mediasJson {
if let type = mediaJson["type"] as? String, type == "photo" {
if let media = TwitterMedia.load(fromJson: mediaJson) {
photos.append(media)
}
}
}
}
return TwitterExtendedEntity(photos: photos)
}
}
extension TwitterExtendedEntity: Equatable {
static func == (lhs: TwitterExtendedEntity, rhs: TwitterExtendedEntity) -> Bool {
return lhs.photos == rhs.photos
}
}
TwitterHashtag
import Foundation
struct TwitterHashtag {
let text: String
}
extension TwitterHashtag {
static func load(fromJson json: JSON) -> TwitterHashtag? {
guard let text = json["text"] as? String else { return nil }
return TwitterHashtag(text: text)
}
}
extension TwitterHashtag: Equatable {
static func == (lhs: TwitterHashtag, rhs: TwitterHashtag) -> Bool {
return lhs.text == rhs.text
}
}
TwitterMedia
import Foundation
struct TwitterMedia {
let id: Int
let url: URL
let expanded_url: URL
let display_url: String
let media_url_https: URL
let mediaSize: TwitterSize
}
extension TwitterMedia {
static func load(fromJson json: JSON) -> TwitterMedia? {
guard let id = json["id"] as? Int else { return nil }
guard let urlString = json["url"] as? String, let url = URL(string: urlString) else { return nil }
guard let expanded_urlString = json["expanded_url"] as? String, let expanded_url = URL(string: expanded_urlString) else { return nil }
guard let display_url = json["display_url"] as? String else { return nil }
guard let media_url_httpsString = json["media_url_https"] as? String, let media_url_https = URL(string: "\(media_url_httpsString):small") else { return nil }
guard let sizes = json["sizes"] as? JSON, let small = sizes["small"] as? JSON, let width = small["w"] as? Int, let height = small["h"] as? Int else { return nil }
let mediaSize = TwitterSize(width: width, height: height)
return TwitterMedia(id: id, url: url, expanded_url: expanded_url, display_url: display_url, media_url_https: media_url_https, mediaSize: mediaSize)
}
}
extension TwitterMedia: Equatable {
static func == (lhs: TwitterMedia, rhs: TwitterMedia) -> Bool {
return lhs.id == rhs.id
}
}
TwitterMention
import Foundation
struct TwitterMention {
let screen_name: String
let name: String
}
extension TwitterMention {
static func load(fromJson json: JSON) -> TwitterMention? {
guard let screen_name = json["screen_name"] as? String else { return nil }
guard let name = json["name"] as? String else { return nil }
return TwitterMention(screen_name: screen_name, name: name)
}
}
extension TwitterMention: Equatable {
static func == (lhs: TwitterMention, rhs: TwitterMention) -> Bool {
return lhs.screen_name == rhs.screen_name
}
}
TwitterURL
import Foundation
struct TwitterURL {
let url: URL
let expanded_url: URL
let display_url: String
}
extension TwitterURL {
static func load(fromJson json: JSON) -> TwitterURL? {
guard let urlString = json["url"] as? String, let url = URL(string: urlString) else { return nil }
guard let expanded_urlString = json["expanded_url"] as? String, let expanded_url = URL(string: expanded_urlString) else { return nil }
guard let display_url = json["display_url"] as? String else { return nil }
return TwitterURL(url: url, expanded_url: expanded_url, display_url: display_url)
}
}
extension TwitterURL: Equatable {
static func == (lhs: TwitterURL, rhs: TwitterURL) -> Bool {
return lhs.expanded_url == rhs.expanded_url
}
}
User
import UIKit
struct User {
let id: Int
let name: String
let screen_name: String
let profile_image_url_https: URL
}
extension User {
static func load(fromJson json: JSON) -> User? {
guard let id = json["id"] as? Int else { return nil }
guard let name = json["name"] as? String else { return nil }
guard let screen_name = json["screen_name"] as? String else { return nil }
guard let profile_image_https = json["profile_image_url_https"] as? String, let profile_image_url_https = URL(string: profile_image_https) else { return nil }
return User(id: id, name: name, screen_name: screen_name, profile_image_url_https: profile_image_url_https)
}
}
extension User: Equatable {
static func == (lhs: User, rhs: User) -> Bool {
return lhs.id == rhs.id
}
}
No model utilizamos o tipo TwitterSize
que é um alias para o tipo CGSize
. Crie um novo arquivo e adicione o código abaixo.
typealias JSON = [String: AnyObject]
typealias TwitterSize = CGSize
let dateFormatter: DateFormatter = {
var df = DateFormatter()
df.dateFormat = "ccc MMM dd HH:mm:ss '+'zzzz yyyy"
return df
}()
Também iremos utilizar o alias JSON
que é um dicionário do tipo [String: AnyObject]
.
Como o objeto DateFormatter
é um objeto que iremos utilizar para obter a data de todos os tweets iremos defini-lo globalmente.
Instanciando o controle inicial
Como excluímos o storyboards teremos que criar o controle via código no AppDelegate.swift
. Iremos criá-lo no método application(_:didFinishLaunchingWithOptions:)
.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
window?.makeKeyAndVisible()
window?.rootViewController = createMainScreen()
customizeAppearance()
application.statusBarStyle = .lightContent
return true
}
Primeiramente criamos um novo UIWindow
com o tamanho da tela do device e chamamos o método makeKeyAndVisible()
. Depois definimos o controller principal com a propriedade rootViewController
.
Em seguida alteramos a aparência de alguns componentes. A propriedade statusBarStyle
deixa a statusBar na cor branca.
Crie o método createMainScreen() e adicione o código a seguir.
private func createMainScreen() -> UITabBarController {
let navigationHomeViewController: UINavigationController = {
let homeTabBarItem = UITabBarItem(title: "Timelines", image: #imageLiteral(resourceName: "Home"), tag: 0)
let homeViewController = HomeViewController()
homeViewController.tabBarItem = homeTabBarItem
let navigationHomeViewController = UINavigationController(rootViewController: homeViewController)
return navigationHomeViewController
}()
let navigationNotificationsViewController: UINavigationController = {
let notificationsTabBarItem = UITabBarItem(title: "Notifications", image: #imageLiteral(resourceName: "Notifications"), tag: 1)
let notificationsViewController = NotificationsViewController()
notificationsViewController.tabBarItem = notificationsTabBarItem
let navigationNotificationsViewController = UINavigationController(rootViewController: notificationsViewController)
return navigationNotificationsViewController
}()
let navigationMessagesViewController: UINavigationController = {
let messagesTabBarItem = UITabBarItem(title: "Messages", image: #imageLiteral(resourceName: "Messages"), tag: 3)
let messagesViewController = MessagesViewController()
messagesViewController.tabBarItem = messagesTabBarItem
let navigationMessagesViewController = UINavigationController(rootViewController: messagesViewController)
return navigationMessagesViewController
}()
let navigationMeViewController: UINavigationController = {
let meTabBarItem = UITabBarItem(title: "Me", image: #imageLiteral(resourceName: "Me"), tag: 4)
let meViewController = MeViewController()
meViewController.tabBarItem = meTabBarItem
let navigationMeViewController = UINavigationController(rootViewController: meViewController)
return navigationMeViewController
}()
let mainTabBarController: MainTabBarController = {
let mainTabBarController = MainTabBarController()
return mainTabBarController
}()
mainTabBarController.addChildViewController(navigationHomeViewController)
mainTabBarController.addChildViewController(navigationNotificationsViewController)
mainTabBarController.addChildViewController(navigationMessagesViewController)
mainTabBarController.addChildViewController(navigationMeViewController)
mainTabBarController.selectedIndex = 0
return mainTabBarController
}
Como o nosso aplicativo possui quatro abas criaremos quatro UINavigationController
. Para cada UINavigationController
temos um bloco onde criamos um UITabBarItem
que irá mostrar a descrição e o ícone da aba. Depois instanciarmos um novo objeto UIViewController
que será o controller principal da aba. Atribuímos o UITabBarItem
a propriedade tabBarItem
do controller e criamos o UINavigationController
passando o UIViewController
para o initializer init(rootViewController:)
.
A tabBar é criada como um objeto MainTabBarController
e recebe os navigationControllers com o método addChildViewController(_:)
. O método finaliza retornando a tabBar criada.
Agora adicione o método customizeAppearance()
para alterarmos a aparência da navigationBar e da tabBar.
func customizeAppearance() {
let TabBarItemNormalAttributes = [NSFontAttributeName : UIFont.tabBarItemFont(), NSForegroundColorAttributeName: UIColor.blueyGrey]
let TabBarItemSelectedAttributes = [NSFontAttributeName : UIFont.tabBarItemFont(), NSForegroundColorAttributeName: UIColor.darkSkyBlue]
UITabBarItem.appearance().setTitleTextAttributes(TabBarItemNormalAttributes, for: .normal)
UITabBarItem.appearance().setTitleTextAttributes(TabBarItemSelectedAttributes, for: .selected)
UINavigationBar.appearance().isTranslucent = false
UINavigationBar.appearance().barTintColor = .darkSkyBlue
UINavigationBar.appearance().tintColor = .white
UITabBar.appearance().isTranslucent = false
UITabBar.appearance().barTintColor = .white
UITabBar.appearance().tintColor = .darkSkyBlue
}
Para alterar a aparência e todos os componentes do projeto de uma determinada classe utilizamos o método estático appearance()
que retorna um proxy com a aparência da classe.
No nosso caso precisamos alterar as cores da navigationBar e da tabBar de acordo com o design. Também alterei a fonte do tabBarItem.
O nosso código no momento não irá compilar pois precisamos criar os UIViewController
s que serão mostrados nas abas. São eles:
HomeViewController.swift
import UIKit
class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}
NotificationsViewController.swift
import UIKit
class NotificationsViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
}
MessagesViewController.swift
import UIKit
class MessagesViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}
MeViewController.swift
import UIKit
class MeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}
Rodando o projeto teremos as abas com uma tela preta pois não criamos os componentes dos controllers.
Carregando os Tweets
Para carregar os dados dos tweets iremos criar a classe TwitterClient
. Crie o arquivo TwitterClient.swift
e adicione o código abaixo.
import UIKit
public class TwitterClient: NSObject {
public static let sharedInstance: TwitterClient = TwitterClient()
private override init() {
super.init()
}
func getTweetsFromFile(callback: @escaping ([Tweet]) -> Void) {
var tweets = [Tweet]()
DispatchQueue.global(qos: DispatchQoS.QoSClass.background).async {
if let path = Bundle.main.path(forResource: "Tweets.min", ofType: "json") {
let url = URL(fileURLWithPath: path)
do {
let jsonFile = try Data(contentsOf: url, options: .mappedIfSafe)
let tweetsJson = try JSONSerialization.jsonObject(with: jsonFile, options: .allowFragments) as! [JSON]
for tweetJson in tweetsJson {
if let tweet = Tweet.load(fromJson: tweetJson) {
tweets.append(tweet)
}
}
} catch {
dump(error)
}
}
DispatchQueue.main.async {
callback(tweets)
}
}
}
}
A classe será um singleton com o método getTweetsFromFile(callback:)
. O método carrega o arquivo Tweets.min.json
que possui um array de tweets. Pra cada tweet do arquivo um objeto da classe Tweet
será criado com o método load(fromJson:)
. O resultado é retornado para a closure recebida como parâmetro.
Na classe HomeViewController
adicione a propriedade tweets.
var tweets: [Tweet]?
No método viewDidLoad()
adicione o seguinte código.
TwitterClient.sharedInstance.getTweetsFromFile() { tweets in
self.tweets = tweets
dump(tweets)
}
Rode o projeto e os tweets irão aparecer na janela de log.
Para apresentar os tweets irei utilizar a UICollectionView
ao invés do UITableView
.
Adicione a seguinte propriedade a classe HomeViewController
.
let twitterCellId = "twitterCellId"
let collectionView: UICollectionView = {
let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
cv.translatesAutoresizingMaskIntoConstraints = false
cv.backgroundColor = .white
return cv
}()
A propriedade collectionView
retorna um UICollectionView
com o layout do tipo UICollectionViewFlowLayout
e com a cor de fundo branca.
No método viewDidLoad()
substitua o dump(tweets)
por self.collectionView.reloadData()
e adicione o seguinte código.
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(TwitterCell.self, forCellWithReuseIdentifier: twitterCellId)
view.addSubview(collectionView)
collectionView.topAnchor.activeConstraint(equalTo: view.layoutMarginsGuide.topAnchor)
collectionView.trailingAnchor.activeConstraint(equalTo: view.trailingAnchor)
collectionView.bottomAnchor.activeConstraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
collectionView.leadingAnchor.activeConstraint(equalTo: view.leadingAnchor)
No código acima atribuimos o HomeViewController
como delegate
e datasource
do collectionView, registramos uma clase TwitterCell
, que criaremos a seguir, adicionamos o collectionView a view do controller e definimos as constraints de autolayout.
Para simplificar a criação das constraints do autolayout eu criei extensões de alguns métodos. Por exemplo:
collectionView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor).isActive = true
ficou
collectionView.topAnchor.activeConstraint(equalTo: view.layoutMarginsGuide.topAnchor)
Crie o arquivo Extensions.swift
e adicione o código abaixo.
extension NSLayoutAnchor {
func activeConstraint(equalTo anchor: NSLayoutAnchor) {
return self.constraint(equalTo: anchor).isActive = true
}
func activeConstraint(equalTo anchor: NSLayoutAnchor, constant c: CGFloat) {
return self.constraint(equalTo: anchor, constant: c).isActive = true
}
func activeConstraint(greaterThanOrEqualTo anchor: NSLayoutAnchor) {
return self.constraint(greaterThanOrEqualTo: anchor).isActive = true
}
func activeConstraint(greaterThanOrEqualTo anchor: NSLayoutAnchor, constant c: CGFloat) {
return self.constraint(greaterThanOrEqualTo: anchor, constant: c).isActive = true
}
func activeConstraint(lessThanOrEqualTo anchor: NSLayoutAnchor) {
return self.constraint(lessThanOrEqualTo: anchor).isActive = true
}
func activeConstraint(lessThanOrEqualTo anchor: NSLayoutAnchor, constant c: CGFloat) {
return self.constraint(lessThanOrEqualTo: anchor, constant: c).isActive = true
}
}
extension NSLayoutDimension {
func activeConstraint(equalTo anchor: NSLayoutDimension, multiplier m: CGFloat) {
return constraint(equalTo: anchor, multiplier: m).isActive = true
}
func activeConstraint(equalTo anchor: NSLayoutDimension, multiplier m: CGFloat, constant c: CGFloat) {
return constraint(equalTo: anchor, multiplier: m, constant: c).isActive = true
}
func activeConstraint(equalToConstant c: CGFloat) {
return constraint(equalToConstant: c).isActive = true
}
func activeConstraint(greaterThanOrEqualTo anchor: NSLayoutDimension, multiplier m: CGFloat) {
return constraint(greaterThanOrEqualTo: anchor, multiplier: m).isActive = true
}
func activeConstraint(greaterThanOrEqualTo anchor: NSLayoutDimension, multiplier m: CGFloat, constant c: CGFloat) {
return constraint(greaterThanOrEqualTo: anchor, multiplier: m, constant: c).isActive = true
}
func activeConstraint(greaterThanOrEqualToConstant c: CGFloat) {
return constraint(greaterThanOrEqualToConstant: c).isActive = true
}
func activeConstraint(lessThanOrEqualTo anchor: NSLayoutDimension, multiplier m: CGFloat) {
return constraint(lessThanOrEqualTo: anchor, multiplier: m).isActive = true
}
func activeConstraint(lessThanOrEqualTo anchor: NSLayoutDimension, multiplier m: CGFloat, constant c: CGFloat) {
return constraint(lessThanOrEqualTo: anchor, multiplier: m, constant: c).isActive = true
}
func activeConstraint(lessThanOrEqualToConstant c: CGFloat) {
return constraint(lessThanOrEqualToConstant: c).isActive = true
}
}
Vamos implementar o datasource do collectionView. Adicione o código abaixo ao final do arquivo HomeViewController.swift
.
// MARK: -
extension HomeViewController: UICollectionViewDataSource {
// MARK: - UICollectionViewDataSource
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return tweets?.count ?? 0
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: twitterCellId, for: indexPath) as! TwitterCell
cell.tweet = tweets?[indexPath.item]
return cell
}
}
No método collectionView(_:numberOfItemsInSection:)
retornamos a quantidade de itens da propriedade tweets
.
No método collectionView(_:cellForItemAt:)
obtemos um TwitterCell
com o método dequeueReusableCell(withReuseIdentifier:for:)
e atribuimos o tweet a célula.
No delegate iremos adicionar o método collectionView(_:layout:sizeForItemAt:)
para retornar uma célula com a largura da tela e com 200 de altura.
// MARK: -
extension HomeViewController: UICollectionViewDelegateFlowLayout {
// MARK: - UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: view.frame.width, height: 200)
}
}
Criando a Interface
Agora vamos criar a célula que irá mostrar o tweet. Crie um novo arquivo TwitterCell.swift e adicione o código abaixo.
import UIKit
class TwitterCell: UICollectionViewCell {
var tweet: Tweet?
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
backgroundColor = .green
}
}
Se rodarmos o programa agora iremos ver 50 células na cor verde ocupando todo o espaço horizontal da tela.
Agora vem a parte trabalhosa. Iremos criar todos os componentes que serão exibidos na tela. São eles profileImageView
do tipo UIImageView
, nameLabel
, screenNameLabel
e timeLabel
do tipo UILabel
, textTextView
do tipo UITextView
, replyButton
, retweetButton
e favoriteButton
do tipo UIButton
, replyCountLabel
, retweetCountLabel
e favoriteCountLabel
do tipo UILabel
, dividerLine
do tipo UIView
e alguns UIStackView
s para organizar o layout.
No final teremos o mesmo que o layout a seguir.
let profileImageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.layer.cornerRadius = 9
iv.layer.masksToBounds = true
iv.translatesAutoresizingMaskIntoConstraints = false
return iv
}()
let nameLabel: UILabel = {
let label = UILabel()
label.font = UIFont.nameFont()
label.textColor = UIColor.charcoalGrey
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let screenNameLabel: UILabel = {
let label = UILabel()
label.font = UIFont.timeLabelFont()
label.textColor = UIColor.blueyGrey
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let timeLabel: UILabel = {
let label = UILabel()
label.font = UIFont.timeLabelFont()
label.textColor = UIColor.blueyGrey
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let textTextView: UITextView = {
let tv = UITextView()
tv.font = UIFont.textFont()
tv.textColor = UIColor.charcoalGrey
tv.contentInset = UIEdgeInsets(top: -4, left: -4, bottom: -4, right: -4)
tv.isEditable = false
tv.isScrollEnabled = false
tv.translatesAutoresizingMaskIntoConstraints = false
tv.backgroundColor = .clear
let paragraphStyle = NSMutableParagraphStyle()
//paragraphStyle.lineSpacing = 16.5
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.headIndent = 0
tv.linkTextAttributes = [NSFontAttributeName: UIFont.textFont(), NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: UIColor.windowsBlue]
return tv
}()
let stackView: UIStackView = {
let sv = UIStackView()
sv.axis = .horizontal
sv.distribution = .fillEqually
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
let replyButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(#imageLiteral(resourceName: "Reply"), for: .normal)
button.tintColor = UIColor.cloudyBlue
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
let replyCountLabel: UILabel = {
let label = UILabel()
label.font = UIFont.replyFont()
label.textColor = UIColor.blueyGrey
label.textAlignment = .left
label.setContentHuggingPriority(1, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let replyStackView: UIStackView = {
let sv = UIStackView()
sv.axis = .horizontal
sv.distribution = .fill
sv.spacing = 3
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
let retweetButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(#imageLiteral(resourceName: "Retweet"), for: .normal)
button.tintColor = UIColor.cloudyBlue
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
let retweetCountLabel: UILabel = {
let label = UILabel()
label.font = UIFont.replyFont()
label.textColor = UIColor.blueyGrey
label.textAlignment = .left
label.setContentHuggingPriority(1, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let retweetStackView: UIStackView = {
let sv = UIStackView()
sv.axis = .horizontal
sv.distribution = .fill
sv.spacing = 3
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
let favoriteButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(#imageLiteral(resourceName: "Favorite"), for: .normal)
button.tintColor = UIColor.cloudyBlue
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
let favoriteCountLabel: UILabel = {
let label = UILabel()
label.font = UIFont.replyFont()
label.textColor = UIColor.blueyGrey
label.textAlignment = .left
label.setContentHuggingPriority(1, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let favoriteStackView: UIStackView = {
let sv = UIStackView()
sv.axis = .horizontal
sv.distribution = .fill
sv.spacing = 3
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
let emptyStackView: UIStackView = {
let sv = UIStackView()
sv.axis = .horizontal
sv.distribution = .fill
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}()
let dividerLine: UIView = {
let view = UIView()
view.backgroundColor = UIColor.paleGrey
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
No método setupViews()
iremos substituir o código backgroundColor = .green
pelo código abaixo.
addSubview(profileImageView)
addSubview(nameLabel)
addSubview(screenNameLabel)
addSubview(timeLabel)
addSubview(textTextView)
addSubview(stackView)
addSubview(dividerLine)
replyStackView.addArrangedSubview(replyButton)
replyStackView.addArrangedSubview(replyCountLabel)
stackView.addArrangedSubview(replyStackView)
retweetStackView.addArrangedSubview(retweetButton)
retweetStackView.addArrangedSubview(retweetCountLabel)
stackView.addArrangedSubview(retweetStackView)
favoriteStackView.addArrangedSubview(favoriteButton)
favoriteStackView.addArrangedSubview(favoriteCountLabel)
stackView.addArrangedSubview(favoriteStackView)
stackView.addArrangedSubview(emptyStackView)
profileImageView.topAnchor.activeConstraint(equalTo: topAnchor, constant: 13)
profileImageView.leadingAnchor.activeConstraint(equalTo: leadingAnchor, constant: 12)
profileImageView.heightAnchor.activeConstraint(equalToConstant: 48)
profileImageView.widthAnchor.activeConstraint(equalToConstant: 48)
nameLabel.topAnchor.activeConstraint(equalTo: topAnchor, constant: 10)
nameLabel.leadingAnchor.activeConstraint(equalTo: profileImageView.trailingAnchor, constant: 9)
screenNameLabel.topAnchor.activeConstraint(equalTo: topAnchor, constant: 12)
screenNameLabel.leadingAnchor.activeConstraint(equalTo: nameLabel.trailingAnchor, constant: 4)
timeLabel.topAnchor.activeConstraint(equalTo: topAnchor, constant: 12)
timeLabel.leadingAnchor.activeConstraint(greaterThanOrEqualTo: screenNameLabel.trailingAnchor, constant: 4)
timeLabel.trailingAnchor.activeConstraint(equalTo: trailingAnchor, constant: -13)
textTextView.topAnchor.activeConstraint(equalTo: nameLabel.bottomAnchor, constant: 2)
textTextView.leadingAnchor.activeConstraint(equalTo: profileImageView.trailingAnchor, constant: 9)
textTextView.trailingAnchor.activeConstraint(equalTo: trailingAnchor, constant: -11)
stackView.topAnchor.activeConstraint(equalTo: textTextView.bottomAnchor, constant: 12)
stackView.leadingAnchor.activeConstraint(equalTo: profileImageView.trailingAnchor, constant: 9)
stackView.trailingAnchor.activeConstraint(equalTo: trailingAnchor, constant: -11)
stackView.heightAnchor.activeConstraint(equalToConstant: 17)
dividerLine.topAnchor.activeConstraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: 13)
dividerLine.leadingAnchor.activeConstraint(equalTo: leadingAnchor)
dividerLine.trailingAnchor.activeConstraint(equalTo: trailingAnchor)
dividerLine.bottomAnchor.activeConstraint(equalTo: bottomAnchor)
dividerLine.heightAnchor.activeConstraint(equalToConstant: 1.5)
Se rodarmos o aplicativo agora teremos apenas as imagens.
Vamos preencher as informações do tweet com o objeto Tweet
passado pelo HomeViewController
. A forma mais simples de fazer isto é com o didSet
da propriedade tweet
.
private var dataTask: URLSessionDataTask?
var tweet: Tweet? {
didSet {
if let tweet = tweet {
getProfileImage()
timeLabel.text = Date.getFormattedDiffTime(tweet.created_at)
textTextView.text = tweet.text
retweetCountLabel.text = (tweet.retweet_count > 0) ? "\(tweet.retweet_count)" : ""
favoriteCountLabel.text = (tweet.favorite_count > 0) ? "\(tweet.favorite_count)" : ""
if let user = tweet.user {
nameLabel.text = user.name
screenNameLabel.text = "@\(user.screen_name)"
}
}
}
}
Chamamos o método getProfileImage()
para obtermos a imagem do profile do usuário que postou o tweet.
O método estático Date.getFormattedDiffTime(_:) transforma um Date em uma string com a quantidade de tempo e a unidade. Exemplo 1d para um dia ou 21m para 21 minutos.
Os outros campos são preenchidos com as propriedades do tweet.
extension Date {
static func getFormattedDiffTime(_ date: Date) -> String {
let diffComponents = Calendar.autoupdatingCurrent.dateComponents([.second, .minute, .hour, .day, .month, .year], from: date, to: Date())
if let years = diffComponents.year, years > 0 {
return "\(years)y"
}
if let months = diffComponents.month, months > 0 {
return "\(months)m"
}
if let days = diffComponents.day, days > 0 {
return "\(days)d"
}
if let hours = diffComponents.hour, hours > 0 {
return "\(hours)h"
}
if let minutes = diffComponents.minute, minutes > 0 {
return "\(minutes)m"
}
if let seconds = diffComponents.second {
return "\(seconds)s"
}
return ""
}
}
Rodando o aplicativo temos as informações do tweet.
O nosso tweet está meio sem graça porque os links, mentions e hashtags estão sem destaques no texto do tweet. Vamos mudar isto utilizando NSAttributedString
para destacar estes itens.
private func formattedTweet() -> NSAttributedString {
if let tweet = tweet {
let paragraphStyle = NSMutableParagraphStyle()
//paragraphStyle.lineSpacing = 16.5
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.headIndent = 0
let attributedString = NSMutableAttributedString(string: tweet.text, attributes: [NSFontAttributeName: UIFont.textFont(), NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: UIColor.charcoalGrey])
if let urls = tweet.entities?.urls {
for url in urls {
let linkAttributedString = NSAttributedString(string: url.display_url, attributes: [NSLinkAttributeName: url.expanded_url])
attributedString.replaceCharacters(in: (attributedString.string as NSString).range(of: url.url.absoluteString), with: linkAttributedString)
}
}
if let hashtags = tweet.entities?.hashtags {
for hashtag in hashtags {
let hashtagAttributedString = NSAttributedString(string: "#\(hashtag.text)", attributes: [NSFontAttributeName: UIFont.textFont(), NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: UIColor.windowsBlue])
attributedString.replaceCharacters(in: (attributedString.string as NSString).range(of: "#\(hashtag.text)"), with: hashtagAttributedString)
}
}
if let mentions = tweet.entities?.mentions {
for mention in mentions {
let hashtagAttributedString = NSAttributedString(string: "@\(mention.screen_name)", attributes: [NSFontAttributeName: UIFont.textFont(), NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: UIColor.windowsBlue])
attributedString.replaceCharacters(in: (attributedString.string as NSString).range(of: "@\(mention.screen_name)"), with: hashtagAttributedString)
}
}
if let medias = tweet.entities?.medias {
for media in medias {
let linkAttributedString = NSAttributedString(string: media.display_url, attributes: [NSLinkAttributeName: media.expanded_url])
attributedString.replaceCharacters(in: (attributedString.string as NSString).range(of: media.url.absoluteString), with: linkAttributedString)
getImage(from: media.media_url_https) { image in
if let thumbnailImage = image.getThumbnail(width: self.frame.width - 12 - 48 - 9 - 11 - 6) {
let attributedText = NSMutableAttributedString(attributedString: self.textTextView.attributedText)
let mediaAttachment = NSTextAttachment()
mediaAttachment.image = thumbnailImage
let mediaAttributedString = NSAttributedString(attachment: mediaAttachment)
attributedText.append(NSAttributedString(string: "\n\n"))
attributedText.append(mediaAttributedString)
self.textTextView.attributedText = attributedText
}
}
}
}
// Fix the link font
attributedString.enumerateAttributes(in: NSRange.init(location: 0, length: attributedString.length), options: .reverse) { attributes, range, _ in
if let _ = attributes[NSLinkAttributeName] {
attributedString.removeAttribute(NSFontAttributeName, range: range)
attributedString.addAttribute(NSFontAttributeName, value: UIFont.textFont(), range: range)
}
}
return attributedString
}
return NSAttributedString(string: "")
}
O código acima é meio extenso mas é bem simples. Primeiro formatamos o texto do tweet de acordo com o design. Alterando a cor a fonte e o parágrafo.
Em seguida criamos links para os endereços dos urls. O Twitter resume um endereço web e portanto temos que utilizar valores diferentes pro links exibidos e pros endereços enviados para o browser.
Depois é a vez dos mentions e hashtags que são destacados com a cor azul.
Outro item que vamos alterar são as imagens que serão exibidas no tweet. Utilizamos um NSTextAttachment
para inseri-las junto ao texto. Ao obtermos a imagem do endereço do link usamos o método getThumbnail(width:)
do UIImage
para redimensionar a imagem. A largura da imagem deverá ser a largura do textView menos uma margem e pra chegarmos no valor precisamos de um pouco de matemática.
Temos a largura da célula menos o leading margin, width e trailing margin do profileImageView
. Em seguida subtraimos o trailing margin do textTextView
, menos 6 pixels para sobrar uma margem entre a imagem e o textView. Também inseri duas quebras de linha para termos um espaço entre o final do texto e a imagem.
Por último, passo por todo o attributedText procurando o atributo NSLinkAttributeName
para alterar as fontes dos links. Não sei porque não consegui alterá-las via propriedade linkTextAttributes
do UITextView
.
extension UIImage {
func getThumbnail(width: CGFloat) -> UIImage? {
let size = self.size
let aspectRatio = size.width / size.height
// Figure out what our orientation is, and use that to form the rectangle
var newSize: CGSize
if(size.width > size.height) {
newSize = CGSize(width: width, height: width / aspectRatio)
} else {
newSize = CGSize(width: width / aspectRatio, height: width)
}
// This is the rect that we've calculated out and this is what is actually used below
let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)
// Actually do the resizing to the rect using the ImageContext stuff
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
self.draw(in: rect)
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage
}
}
pra finalizar vamos trocar o código textTextView.text = tweet.text
por textTextView.attributedText = formattedTweet()
.
Rodando o aplicativo novamente temos as formatações das urls, mentions e hashtags mas não vemos as imagens. Isto acontece porque a célula não tem o tamanho necessário para exibir as imagens.
Para calcular o tamanho da célula usaremos o método getThumbnail(size:)
para obtermoso tamanho das imagens. Voltando ao arquivo HomeViewController.swift
, adicione o seguinte código ao início do método collectionView(_:layout:sizeForItemAt:)
.
if let tweet = tweets?[indexPath.item] {
let textViewWidth: CGFloat = view.frame.width - 12 - 48 - 9 - 11
let height = tweet.text.heightWithConstrained(width: textViewWidth, font: UIFont.textFont()) + 20
let mediaSize: CGFloat = (tweet.entities?.medias.map({ self.getThumbnail(size: $0.mediaSize).height + 25 }).reduce(0, +))!
return CGSize(width: view.frame.width, height: 10 + 17 + 2 + height + mediaSize + 12 + 17 + 13 + 1.5)
}
Primeiro calculamos o tamanho do textView igual fizemos anteriormente. Depois chamamos o método heightWithConstrained(width:font:)
a partir do texto do tweet. Este método serve para calcular a altura de determinado texto de acordo com a fonte e a largura do componente. O método não é muito preciso e adicionei 20 ao valor retornado pra compensar as margens internas do textView.
Na sequencia utilizamos o método getThumbnail(size:)
para calcular a altura da imagem. Ele recebe a largura da imagem retornada pela API e retorna o tamanho da imagem redimensionada para caber no textView. O método map
percorre o array das imagens e retorna um array com a altura delas mais 25 para compensar a quebra de linha. O método reduce
soma todos os itens do map
calculando a altura total.
No final retornarmos a altura de todos os componentes da célula mais os espaços entre eles.
O método getThumbnail(size:)
possui o seguinte código.
private func getThumbnail(size: CGSize) -> CGSize {
let aspectRatio = size.width / size.height
let width: CGFloat = view.frame.width - 12 - 48 - 9 - 11 - 6
if size.width > size.height {
return CGSize(width: width, height: width / aspectRatio)
}
return CGSize(width: width / aspectRatio, height: width)
}
extension String {
func heightWithConstrained(width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return boundingBox.height
}
}
Ainda dentro da extension do UICollectionViewDelegateFlowLayout
vamos adicionar o método collectionView(_:layout:minimumLineSpacingForSectionAt:)
para retirar os espaços entre as células.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
Rodando o aplicativo temos os tweets com os links e as imagens.
Para finalizarmos o projeto vamos criar os itens da navigationBar. No fonte HomeViewController.swift
adicione o código a seguir abaixo do método viewDidLoad()
.
private func makeNavigationBar() {
let homeLabel: UILabel = {
let label = UILabel()
label.font = UIFont.titleMenubarFont()
label.textColor = .white
label.text = "HOME"
label.translatesAutoresizingMaskIntoConstraints = false
label.sizeToFit()
return label
}()
let addContactBarButtonItem: UIBarButtonItem = {
let button = UIBarButtonItem(image: #imageLiteral(resourceName: "Add Contact"), style: .plain, target: self, action: nil)
return button
}()
let searchBarButtonItem: UIBarButtonItem = {
let button = UIBarButtonItem(image: #imageLiteral(resourceName: "Search"), style: .plain, target: self, action: nil)
return button
}()
let composeTweetBarButtonItem: UIBarButtonItem = {
let button = UIBarButtonItem(image: #imageLiteral(resourceName: "Compose Tweet"), style: .plain, target: self, action: nil)
return button
}()
navigationItem.titleView = homeLabel
navigationItem.leftBarButtonItem = addContactBarButtonItem
navigationItem.rightBarButtonItems = [composeTweetBarButtonItem, searchBarButtonItem]
}
O método makeNavigationBar()
é bem simples. Criamos um UILabel
com a fonte e a cor do design e atribuímos à propriedade navigationItem.titleView
. Depois criamos os três botões da navigationBar e os atribuimos às propriedades navigationItem.leftBarButtonItem
e navigationItem.rightBarButtonItems
.
Pra finalizar precisamos adicionar a chamada ao método makeNavigationBar()
dentro do método viewDidLoad()
.
Conclusão
A versão final ficou assim:
O Zeplin facilita bastante o trabalho de visualizar o layout criado no Sketch. As funcionalidades de exportação de código de cor e fonte são bem práticas. A ferramenta permite colocar comentários sobre os itens do projeto para poder tirar dúvidas com o designer e poupa o custo de ter que comprar licenças para as maquinas dos desenvolvedores.
Outro ponto que vimos neste artigo foi a criação da interface via código. As modificações do autolayout no iOS 9 simplificaram a construção das constraints o que deixou o código mais fácil de ler e escrever.
Não desenvolvemos todas as propriedades do tweet que ainda possui extended_entities com gifs animados, vídeos e coleções de fotos. Outra coisa que vocês podem explorar e o retweet e reply que também não abordamos por completo aqui no artigo.
Espero que tenham gostado do artigo. Qualquer dúvida, crítica ou sugestão de novos assuntos deixe um comentário abaixo. Caso queira entrar em contato pode me chamar no twitter @mateusfsilva. Se gostou compartilhe.