O UIButton é uma classe extensamente utilizada, muito testada e configurável, então:
Por que alguém em sã consciência perderia tempo fazendo um botão?
Eu acredito que o objetivo didático seria justificativa suficiente, algo como o Mike Ash faz com alguma frequência em seu blog, mas não é o caso. Por simplicidade, vamos assumir que o problema não está na minha sanidade, mas em um desconforto ao customizar um botão.
Com frequência, os botões são usados para iniciar requisições a servidores, mas no mundo real isso não é instantâneo, e o usuário deve (ou deveria) ser entretido de alguma maneira enquanto a resposta dessa requisição não chega.
Existem inúmeras maneiras de fazer isso, não vou discutir todas porque não cabe no escopo deste artigo, só digo para não colocar um spinner bem no meio da tela impedindo o usuário de interagir com seu app. Uma maneira que gosto bastante é de apresentar o estado da requisição dentro do botão que a iniciou. Mas para isso é preciso ter um botão que tenha esse novo estado. Adaptar (cof, hackear, cof) um UIButton não me pareceu uma maneira honesta, vou discutir isso em um artigo específico, então decidi construir um botão que replica o UIButton e depois adicionar o novo estado, o que não é uma tarefa fácil, mas muito instrutiva. A ideia é reproduzir o comportamento de um botão do tipo UIButtonType.System. Além disso, me pareceu uma boa oportunidade de exercitar um pouco meu Swift.
O UIButton tem muitos elementos:
- titleLabel
- attributedTitle
- titleColor
- titleShadow
- image
- backgroundImage
- tintColor
E estados:
- UIControlState.Normal
- UIControlState.Highlighted
- UIControlState.Disabled
- UIControlState.Selected
E outros detalhes não óbvios, como reagir a tintAdjustmentMode, acessibilidade, UIAppearance, animações, contentEdgeInsets, titleEdgeInsets, imageEdgeInsets e outras coisas que eu ainda não descobri, então decidi limitar os requisitos dessa versão 1.0. Uma das primeiras decisões é que o Button deve ser subclasse do UIControl – suponho que isso deve facilitar a vida. A interface, se é que existe isso em Swift, deve ser algo assim:
public class Button : UIControl { public var enabled: Bool public let titleLabel: UILabel public func titleForState(state: UIControlState) -> String? public func setTitle(title: String?, forState state: UIControlState) public func titleColorForState(state: UIControlState) -> UIColor? public func setTitleColor(color: UIColor?, forState state: UIControlState) public let imageView: UIImageView public func imageForState(state: UIControlState) -> UIImage? public func setImage(image: UIImage?, forState state: UIControlState) public func backgroundImageForState(state: UIControlState) -> UIImage? public func setBackgroundImage(image: UIImage?, forState state: UIControlState) public func addTarget(target: AnyObject?, action action: Selector, forControlEvents controlEvents: UIControlEvents) }
Vemos que os insets estão de fora, a parte de acessibilidade também vou deixar para a próxima versão, junto com UIAppearance. Uma coisa que eu gostaria de fazer, mas me pareceu bem mais complicada do que eu imaginava, são as animações, especialmente a do titleLabel:
Note que nunca os dois textos aparecem ao mesmo tempo, o texto do estado Normal desaparece e depois o Highlighted aparece, mas por que esse tempo sem texto nenhum? Nas minhas tentativas de animar a transição, percebi que a diferença no tamanho do texto obriga o redimensionamento da label, e aí tudo vai para o brejo. Uma maneira de evitar é colocar esse tempo em “branco”. Fuçando com o Reveal descobri que o label do UIButton não é uma UILabel mas uma UIButtonLabel, uma classe que não é pública e deve resolver esses detalhes das animações ¯\_(ツ)_/¯. Uma outra complicação é que essa animação pode ser cancelada, ou alterada, antes de seu fim, dependendo do tempo de duração do toque. Acho que isso daria assunto para um artigo inteiro!
A hierarquia de views consiste de uma UIImageView que vai conter a backgrondImage, uma contentView que contém UIImageView e UILabel como mostra a imagem:
O Layout foi feito usando Autolayout e não vou entrar em mais detalhes porque ele foi estruturado para não depender do conteúdo do botão – quem se interessar pode dar uma olhada no projeto do GitHub.
O UIButton tem alguns comportamentos específicos para cada um de suas “propriedades”:
- titleLabel, se não for definida uma string para um estado específico, a do estado .Normal é utilizada. O estado .Highlighted causa um comportamento diferente quando ele não tem uma string definida e nem uma titleColor, o alpha do titleLabel passa a ser 0.2, causando o efeito de selecionado;
- titleColor, se não for definida uma cor para um estado específico, a do estado .Normal é utilizada. Quando nenhuma cor específica for definida, as coisas ficam interessantes. No estado .Normal e .Highlighted, é utilizada a tintColor, e quando ela muda, por exemplo devido a alteração no tintAdjustmentMode, isso é respeitado. Esse comportamento é o que faz com que o botão fique “cinza” quando aparece um popup. Quando o estado é .Disabled, a cor é alterada para UIColor(white: 0.4, alpha: 0.35), infelizmente não consegui achar uma cor do sistema que corresponda a esse padrão 🙁
- image e backgroundImage
, se não for definida uma imagem para um estado específico, a do estado .Normal é utilizada. No caso de o estado .Highlighted não ter uma imagem, além de ser utilizada a do estado .Normal o alpha do elemento em questão passa a ser 0.2.
Dividimos essa questão em dois problemas: primeiro como armazenar os valores das propriedades para cada estado e depois aplicar a lógica para cada propriedade.
A maneira mais simples de armazenar seria utilizando um dicionário:
private var titles = [UIControlState: String]() private var titleColors = [UIControlState: UIColor]() private var images = [UIControlState: UIImage]() private var backgroundImages = [UIControlState: UIImage]()
Mas UIControlState não implementa o protocolo Hashable, isso é facilmente resolvido com uma extension que implementa o hashValue como o Int(rawValue) do protocolo RawRepresentable (esse “truque” fez Swift ganhar alguns pontos comigo):
extension UIControlState: Hashable { public var hashValue: Int { get { return Int(rawValue) } } }
A implementação dos métodos públicos lida com estados do titleLabel, e fica bem simples:
public func titleForState(state: UIControlState) -> String? { return titles[state] } public func setTitle(title: String?, forState state: UIControlState) { if let title = title { titles[state] = title } else { titles.removeValueForKey(state) } updateUI() }
Os métodos das outras propriedades têm exatamente a mesma lógica. O que vale notar aqui é a chamada updateUI(); esse método é o que atualiza as mudanças na tela e resolve grande parte do segundo problema:
private func updateUI() { let defaultState = UIControlState.Normal let state = self.state let title: String? let titleIsFallback: Bool (title, titleIsFallback) = getValeuIn(titles, forState: state, fallbackState: defaultState, fallbackValue: nil) let textColor: UIColor? let textColorIsFallback: Bool (textColor, textColorIsFallback) = getValeuIn(titleColors, forState: state, fallbackState: defaultState, fallbackValue: enabled ? tintColor : UIColor(white: 0.4, alpha: 0.35)) let image: UIImage? let imageIsFallback: Bool (image, imageIsFallback) = getValeuIn(images, forState: state, fallbackState: defaultState, fallbackValue: nil) let backgroundImage: UIImage? let backgroundImageIsFallback: Bool (backgroundImage, backgroundImageIsFallback) = getValeuIn(backgroundImages, forState: state, fallbackState: defaultState, fallbackValue: nil) let textAlpha: CGFloat = highlighted && titleIsFallback && textColorIsFallback ? highlightedAlpha : normalAlpha let imageAlpha: CGFloat = highlighted && imageIsFallback ? highlightedAlpha : normalAlpha let backgroundImageAlpha: CGFloat = highlighted && backgroundImageIsFallback ? highlightedAlpha : normalAlpha titleLabel.text = title titleLabel.textColor = textColor titleLabel.alpha = textAlpha imageView.image = image imageView.alpha = imageAlpha backgroundImageView.image = backgroundImage backgroundImageView.alpha = backgroundImageAlpha }
É um método extenso, o correto seria extrair a lógica de cada propriedade em métodos separados para poder testar somente a lógica, mas para uma primeira versão serve.
A lógica é feita em duas fases. Na primeira, é definido o valor da propriedade para o estado atual e se esse valor foi definido ou é padrão (fallback). Na segunda fase, é definido o alpha; no fim, tudo é atualizado de uma só vez.
Um método genérico é usado na primeira fase:
private func getValeuIn<T>(collection: [UIControlState: T], forState state: UIControlState, fallbackState defaultState: UIControlState, fallbackValue: T?) -> (T?, Bool) { let thing: T? let thingIsFallback: Bool if let aThing = collection[state] { thing = aThing thingIsFallback = false } else { thing = collection[defaultState] ?? fallbackValue thingIsFallback = true } return (thing, thingIsFallback) }
O único comentário pertinente seria mais uma vez ponto para o Swift com generics e tuples (já não sinto tanta falta do ;
).
Para completar a atualização do estado .Disabled, é preciso fazer chamar updateUI() quando o enabled é chamado:
override public var enabled: Bool { didSet { updateUI() } }
E o equivalente quando há uma mudança na tintColor:
override public func tintColorDidChange() { updateUI() }
A atualização para o estado .Highlighted requer alterações no tracking do touch:
override public func beginTrackingWithTouch(touch: UITouch, withEvent event: UIEvent?) -> Bool { let track = super.beginTrackingWithTouch(touch, withEvent: event) updateWithTouch(touch) return track } override public func continueTrackingWithTouch(touch: UITouch, withEvent event: UIEvent?) -> Bool { let track = super.continueTrackingWithTouch(touch, withEvent: event) updateWithTouch(touch) return track } override public func endTrackingWithTouch(touch: UITouch?, withEvent event: UIEvent?) { super.endTrackingWithTouch(touch, withEvent: event) if let touch = touch { updateWithTouch(touch) } }
O highlight depende do touch estar dentro da área do botão. A propriedade touchInside do UIControl é a ideal para saber isso, mas temos um problema. Ela só é atualizada quando o beginTrackingWithTouch(_:withEvent:) -> Bool retorna. Como nosso método updateWithTouch(_:) é chamado antes do retorno, temos que fazer um workaround:
private func updateWithTouch(touch: UITouch) { let point = touch.locationInView(self) let ended = touch.phase == .Ended // Workaround because touchInside inside is not true on beginTrackingWithTouch let insideTouch = pointInside(point, withEvent: nil) highlighted = ended ? false : insideTouch || touchInside updateUI() }
Acho que com isso consegui cobrir os requisitos da versão 1.0 e deu para entender um pouco melhor o funcionamento do UIButton. Algumas propriedades ainda permanecem um mistério para mim, como o adjustsImageWhenHighlighted, que no meu entendimento deveria habilitar e desabilitar a alteração do alpha quando falso, mas nos meus testes não consegui ver diferença.
O Button pode ser encontrado no branch Button do repositório LoadingButton. Críticas, sugestões e comentários são sempre bem-vindos, é só me pingar no @diogot ou no slack do iOS Dev BR.
Uma dica para quem usa cores e não imagens como background e quer se beneficiar dos estados é criar um UIImage a partir de uma UIColor usando a seguinte extension:
extension UIColor { func image() -> UIImage { let frame = CGRect(x: 0, y: 0, width: 1, height: 1) UIGraphicsBeginImageContextWithOptions(frame.size, false, 0) setFill() UIRectFill(frame) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image } }