Invariante   Algumas coisas nunca mudam

Construindo um UIButton

O UIButton é uma classe extensamente utilizada, muito testada e configurável, então:

Porque 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 à servidores, 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 desse 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 idéia é 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

estados:

  • UIControlState.Normal
  • UIControlState.Highlighted
  • UIControlState.Disabled
  • UIControlState.Selected

E outros detalhes não óbvios como reagir à 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 complicado do que eu imaginava são as animações, especialmente a do titleLabel:

UIButton label animation

Note que nunca os dois textos aparecem ao mesmo tempo, o texto do estado Normal desaparece e depois o Highlighted aparece, mas porque 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 ai 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:

Button view hierarchy

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 este não tem uma string definda 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 coisa ficam interessantes. No estado .Normal e .Highlighted é utilizada a tintColor, e quanto 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 é utilizado. No caso do 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 lidam com estados do titleLabel ficam 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 tem 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 dos 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 ao 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. Criticas, 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 à 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
    }
}

Diogo Tridapalli
@diogot

Storyboards e Injeções de Dependências.

##Eu sou fã de Storyboards.

Acho a discussão “Storyboards versus UI programaticamente” totalmente válida. É importante entender que existem duas (ou até mais) formas diferentes de resolver os mesmos problemas. Sempre gostei da forma que XIBs e Storyboards facilitam e agilizam o desenvolvimento de views e view controllers. Além disso, acho que são ferramentas insubstituíveis para o aprendizado de alguns conceitos do UIKit.

Assim como em código, você precisa saber o que está fazendo ao usar um Storyboard. O propósito do Storyboard é você simplificar um fluxo de view controllers que estão interconectadas. Como o meu caro amigo Diogo apontou no último post do Invariante, ao usar Storyboards você acaba acoplando o seu código, pois de uma forma ou outra suas view controllers vão saber da existência uma das outras. Mas será que isso é tão ruim assim quando você está falando de um Storyboard que representa um fluxo atômico no seu app (um fluxo de cadastro ou de tutorial, por exemplo) onde realmente não existe uma complexidade que justifique esse isolamento entre suas view controllers? Para mim, faz parte do toolset de um bom desenvolvedor iOS saber desenvolver fluxos simples e prototipar utilizando Storyboards.

E existem também os (vários) casos em que Storyboards atrapalham mais do que ajudam. O modo como os Storyboards são implementados no UIKit fazem com que muitas vezes ele nos force a usar anti-patterns como God Object (onde um objeto sabe ou faz muito, violando o Princípio de Responsabilidade Única), Magic Strings, ou BaseBean (forçar herança ao invés de delegação). Os Storyboards também são (justamente) conhecidos por aumentar o acoplamento do seu código, dificultando injeções de dependências.

##O que é Injeção de Dependências mesmo? O conceito de Injeção de Dependências pode ser difícil de se entender para desenvolvedores com pouca experiência, principalmente quando se está começando a programar (e muitas vezes é esquecido em projetos reais). Uma explicação simples seria mais ou menos assim:

Teoricamente, você quer sempre deixar o seu código com o menor nível de acoplamento possível. Isso significa que cada módulo do seu sistema deve saber o mínimo possível sobre os outros módulos existentes para cumprir a sua funcionalidade. Injetar dependências signfica que cada módulo, ao ser criado, terá suas dependências configuradas por outro módulo do sistema, mantendo assim um baixo acoplamento.

##Exemplo usando Storyboard

Para ilustrar, temos o app abaixo, onde há um tela que busca uma imagem na internet e apresenta para usuário. A idéia é que o FetchAndDisplayImageViewController consiga buscar uma imagem através do FetchImageService e mostrar na sua imageView.

Storyboard do projeto<3

Em uma implementação mais ingênua do FetchAndDisplayImageViewController teríamos algo assim:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FetchAndDisplayImageViewController: UIViewController {

    //Image Service to fetch the image to display
    var imageService: FetchImageService?
    
    //Image View utilizada para mostrar a imagem
    @IBOutlet weak var imageView: UIImageView!
        
    override func viewDidLoad() {
        super.viewDidLoad()
        imageService = FetchImageService(param1: "Invariante", param2: "Rocks")
        if let imageService = imageService {
            imageView.image = imageService.fetchImage()
        }
    }
}

Acredito que seja fácil de perceber que essa implementação é, no mínimo, questionável, pois o FetchAndDisplayImageViewController sabe como instanciar o FetchImageService. Isso deixa o nosso código mais amarrado e faz com que nossa FetchAndDisplayImageViewController seja praticamente impossível de ser testada. Uma solução melhor é apresentada abaixo. Num post futuro mostrarei porque esse maior acoplamento (e a falta de injeção de dependência) torna o nosso código menos testável.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FetchAndDisplayImageViewController: UIViewController {

    //Image Service to fetch the image to display
    var imageService: FetchImageService?
    
    //Image View utilizada para mostrar a imagem
    @IBOutlet weak var imageView: UIImageView!
        
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //Esperamos que alguém externo tenha configurado nossa dependência:
        if let imageService = imageService {
            imageView.image = imageService.fetchImage()
        } else {
            print("Não era para isso acontecer...")
        }
    }
}

Desta forma, nós não instanciamos a nossa propriedade imageService, mas ao invés disso, no viewDidLoad esperamos que a propriedade imageService tenha sido configurada por alguém.

No post anterior o Diogo também comentou sobre a forma de se passar informações entre o nosso ViewController e o FetchAndDisplayImageViewController quando utilizamos Storyboards. Essa seria a forma correta de setar a propriedade imageService nessa abordagem:

1
2
3
4
5
6
7
8
9
10
11
class ViewController: UIViewController {

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if segue.identifier == "ViewControllerToFetchAndDisplayImageViewControllerSegue" {
            if let destinationViewController = segue.destinationViewController as? FetchAndDisplayImageViewController {
                let imageService = FetchImageService(param1: "Invariante", param2: "Rocks")
                destinationViewController.imageService = imageService
            }
        }
    }
}

E é aí que o Storyboard mostra sua fragilidade. Quantos problemas você consegue ver nos dois últimos trechos de código acima? Eu consigo citar alguns:

1) A property imageService é uma var. Gostaríamos muito que ela fosse imutável (let). Para isso precisaríamos setá-la no init (e, por estarmos utilizando Storyboards, não temos como fazer isso);

2) Essa mesma propriedade precisa ser public ou internal pois ela é setada por outra classe (ViewController). Idealmente, queremos que ela seja private;

3) Além disso, imageService precisa ser um optional, o que acaba deixando o código mais verboso, pois precisamos sempre fazer o unwrap dessa variável ao usá-la;

4) Por ser possível apresentar a FetchAndDisplayImageViewController sem setar a var imageService, precisamos definir o comportamento da view controller caso isso aconteça.

Que bagunça! E como a gente pode melhorar isso? Simples: não use Storyboards :)

##Exemplo em código

Veja que no código abaixo, agora é mandatório que o imageService seja passado na inicialização da nossa classe. Assim, conseguimos fazer que a nossa propriedade imageService seja privada e imutável (e, claro, não-opcional).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class FetchAndDisplayImageViewController: UIViewController {

    //Image Service to fetch the image to display
    //Note que agora nossa propriedade é imutável (let), além de ser privada.
    private let imageService: FetchImageService
    
    //Image View utilizada para mostrar a imagem
    weak var imageView: UIImageView!
    
    init(imageService: FetchImageService) {
        self.imageService = imageService
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        super.loadView()
        
        imageView = UIImageView()
        //Adicionar constraints manualmente.
        
        view.addSubview(imageView)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //Sabemos que nossa dependência foi configurada no `init`
        imageView.image = imageService.fetchImage()
    }
}

##Entendi! Agora eu também odeio Storyboards!

Pode ter certeza que você não está sozinho, mas infelizmente não compartilho da mesma opinião. Como eu falei, sou fã de Storyboards e acho sim que eles tem um papel importantíssimo no desenvolvimento de apps. É importante saber discernir os momentos certos de utilizar ou não Storyboards. Não acredito que as abordagens acima utilizando Storyboards estejam necessariamente erradas. É tudo uma questão de saber o que está fazendo e escolher a ferramenta certa para cada problema.


Fique a vontade para discutir os artigos do Invariante no Slack do iOSDevBR. Temos alguns canais como o #general, #code-help, #learn, #swift, entre outros. A galera lá está sempre disposta a ajudar e esclarecer dúvidas :)


Bruno Koga
@brunokoga

Todo View Controller deveria ter um delegate

Qualquer pessoa que frequente o slack do iOS Dev BR sabe que ando insatisfeito com Storyboard. Os motivos são vários mas hoje vou falar apenas de um, as segues.

As segues facilitam a visualização do fluxo do app para uma pessoa que não está habituada com o projeto. É só abrir o storyboard (ou storyboards) e está tudo lá, todas as setinhas ligando seus controllers. Ai você me pergunta: “Mas isso é lindo, porque te incomoda?”.

[self performSegueWithIdentifier:@"Segue" sender:result];

O que você acha desse trecho de código? Não me refiro a aquela bela string mágica, mas onde essa linha normalmente fica. Essa instrução está contida no controller A e é executada quando o mesmo terminou seu propósito e o controller B deve ser instanciado para continuar o fluxo.

Isso implica que o controller A tem algum conhecimento do que deve acontecer depois dele, se eu quiser trocar o controller B por um controller C eu poderia manter o nome da segue e fazer a alteração somente no storyboard, isso seria deselegante mas não um problema. Agora imagine que o controller a ser instanciado a seguir dependa de algum resultado anterior a segue, quem deve decidir qual segue deve ser chamada? Na grande maioria dos códigos que vi (talvez não sejam tantos assim) o próprio controller A é responsável por tomar essa decisão. Isso não me cheira bem (vulgo code smell), mas vamos continuar…

Bom, em qualquer app, alguma hora, você vai precisar passar informação entre os controllers e como você faz isso?

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([segue.identifier isEqualToString:@"SegueB"]) {
        MYControllerB *controller = segue.destinationViewController;
        controller.propertyB = sender;
    } else if ([segue.identifier isEqualToString:@"SegueC"]) {
        MYControllerC *controller = segue.destinationViewController;
        controller.propertyC = sender;
    }
}

Se antes não estava cheirando bem, agora o cheiro está pior que o Rio Pinheiros ali na estação Vila Olímpia! As strings mágicas continuam por aí, temos um sender que pode ser qualquer coisa e, por último mas o pior de todos, o controller A sabe sobre o controller B e controller C. “Mas porque isso é tão ruim?” Esse acoplamento dificulta muito a substituição de qualquer uma das três classes, faz com que seja muito mais complexo para testar essa classe pois não há como fazer injeção de dependência (Dependency Injection) e esse if else if ... interminável é deselegante (sim, eu sei que em Swift seria um switch menos deselegante).

Depois de todo esse discurso anti-Storyboard imagino os defensores dessa “tecnologia” estejam incomodados, para eles tenho duas coisas a dizer: Primeiro eu acredito que existem situações em que Storyboards são adequados, projetos grandes e que vão durar muitos anos não se enquadram nessas situações (estou disposto a discutir esse assunto em uma outra ocasião); Segundo, fazendo a transição de controllers sem segue vemos o mesmo acoplamento:

if (something) {
    MYControllerB *controller = [[MYControllerB alloc] initWithThing:aThing];
    [self.navigationController pushViewController:controller animated:YES];
} else if (otherthing) {
    MYControllerC *controller = [[MYControllerC alloc] initWithThing:aThing2];
    [self presentViewController:controller animated:YES completion:nil];
}

Nesse caso algo ruim que era resolvido pelo segue acontece, o controller A tem a responsabilidade de escolher como os outros controllers serão apresentados e ainda esperar que ele próprio esteja dentro de um UINavigationController, nada bom.

Existe um conceito chamado de Princípio de responsabilidade única (não sei se essa seria a tradução mais adequada, Single responsibility principle), ele diz cada classe deve ter apenas uma responsabilidade ou, como diria o Agent Smith, um propósito. Imaginemos que o controller A tenha o propósito de obter a idade do usuário, então dele deve ser criado quando o app precisar obter essa informação e a única coisa que o controller precisa fazer é obter esse informação. Não faz parte do propósito dele ter conhecimento (importar) classes que não tenham relação direta com seu propósito, em particular, classes que venham antes ou depois dele no fluxo. Muito menos decidir, com base nessa informação, qual seria o próximo passo no fluxo do app, instanciar o próximo controller e passar a informação para ele, decidir como esse controller será apresentado e apresentá-lo.

Esse é um problema que vem me incomodando faz algum tempo, há um ano li um artigo falando sobre Flow Controllers, achei interessante, ele resolve o problema da injeção de dependência, mas o controller ainda tem a responsabilidade de dizer qual é o próximo passo no fluxo do app e eu acredito que isso não faz parte do propósito dele.

Uma possível solução para isso é postular que:

  • Todo view controller tem que ter um delegate;
  • Um view controller não deve usar referências a parentViewController, navigationController, tabBarController, splitViewControoler, ou presentingViewController ou qualquer outro parent controller que inventarem;
  • Um view controller só pode fazer (#include) de outros view controllers se esses forem necessários para cumprir seu propósito;
  • Quando um view controller completar seu propósito ele notifica seu delegate e esse é responsável por continuar o fluxo;
  • Um view controller nunca deve usar segues, isso não faz parte do seu propósito.

Uma maneira de satisfazer essas condições é ter uma classe que é delegate de todos os view controllers, que sabe instanciar todos os view controller e inclusive quais modelos são necessários para isso. Isso não me cheira muito bem, mas é melhor que antes. Como ainda é uma das primeiras interações alguma hora deve aparecer alguma idéia (aceito sugestões).

Penso que esse delegate deve ser o primeiro controller do app, por exemplo, uma subclasse do UINavigationController. Essa abordagem tem algumas vantagens:

  • Pode ser uma maneira de começar a migrar um app para Swift pois, normalmente, a essa controller inicial é padrão e não tem muita interação com outros controllers. Além disso os novos controllers irão interagir somente com esse, além dos modelos e a camada de rede;
  • Todo o fluxo do app fica em apenas uma classe e não espalhado por vários lugares;
  • Como o delegate sabe instanciar todos os controllers, ele pode receber o roteamento vindo de deep links ou NSUserActivity sem dificuldade;
  • Se você quiser fazer uma classe especial para mostrar notificações ou popups personalizados, o delegate seria o cara ideal para gerenciar quando e como eles devem ser apresentados.

Um exemplo pode deixar as coisas mais claras. Um app tem dois view controllers. O responsável pelo primeira tela (DTRootViewController) em Objective-c, o propósito dele é obter do usuário um texto, sua interface seria:

@interface DTRootViewController : UIViewController

@property (nonatomic, weak) id<DTRootViewControllerDelegate> delegate;

@end

@protocol DTRootViewControllerDelegate <ViewControllerDelegate>

- (void)didSelectedText:(nullable NSString *)text
   onRootViewController:(nonnull DTRootViewController *)controller;

@end

O segundo view controller, em Swift, (OtherViewController) tem como propósito mostrar um texto, e sua interface pública seria:

protocol OtherViewControllerDelegate : ViewControllerDelegate 
{
    func shouldDismissOtherViewController(controller: OtherViewController)
}

class OtherViewController : UIViewController
{
    init(text: NSString, navigationCloseButton: Bool, delegate: OtherViewControllerDelegate?)
}

O view controller primário desse app (NavigationController) é uma subclasse do UINavigationController (também em Swift) e sua implementação seria:

class NavigationController: UINavigationController, DTRootViewControllerDelegate, OtherViewControllerDelegate
{
    override func awakeFromNib()
    {
        let controller = DTRootViewController()
        controller.delegate = self
        self.setViewControllers([controller], animated: false)
    }

    // MARK: DTRootViewController

    func didSelectedText(text: String?, onRootViewController controller: DTRootViewController)
    {
        if let text = text {
            self.presentOtherViewControllerWithText(text)
        } else {
            print("do nothing")
        }
    }

    // MARK: OtherViewController

    func presentOtherViewControllerWithText(text: String)
    {
        if text.localizedCaseInsensitiveContainsString("modal") {
            let controller = OtherViewController(text: text, navigationCloseButton: true, delegate: self)
            self.presentViewControllerWithNavigationController(controller, animated: true)
        } else if text.localizedCaseInsensitiveContainsString("push") {
            let controller = OtherViewController(text: text, navigationCloseButton: false, delegate: nil)
            self.pushViewController(controller, animated: true)
        }
    }

    func shouldDismissOtherViewController(controller: OtherViewController)
    {
        self.dismissViewControllerAnimated(true, completion: nil)
    }

    // MARK:
    func presentViewControllerWithNavigationController(controller: UIViewController,
                                                         animated: Bool)
    {
        let navigation = UINavigationController(rootViewController: controller)
        self.presentViewController(navigation, animated: animated, completion: nil)
    }
}

O projeto completo se encontra no github, mas olhando apenas a implementação do NavigationController vemos que toda a lógica de fluxo de app está contida em apenas uma classe, dessa forma é possível apresentar o OtherViewController tanto modalmente quanto dentro do navigation controller de maneira transparente, sem ter que alterar o controller apresentado.

Como eu disse anteriormente, eu ainda estou começando a utilizar essa abordagem e novos problemas e dificuldades devem aparecer com o uso. Minha intenção e fazer outros artigos sobre esse assunto conforme eu for desenvolvendo o tema, críticas, comentários e sugestões são bem vindos, o Twitter e o slack do iOS Dev BR são os canais mais fáceis.


Update 2015/10/20 13h - O Igor levantou um ponto que eu não tinha pensado, é possível se livrar sem grande dificuldade do acoplamento no prepareForSegue:sender: usando uma subclasse da UIStoryboardSegue, fazendo o acoplamento do controller A com o controller B dentro dessa classe. Acho uma solução bem razoável.

Update 2015/10/20 23h - O Fabri comentou que existem várias iniciativas como o Natalie para resolver o problema das strings mágicas, acho válido, mas preferia que houvesse alguma coisa nativa.

Update 2014/11/22 - O Tales apontou um ponto importante, nem sempre é possível usar um delegate. Por exemplo quando o view controller é subclasse de UITableViewController. Casos em que um delegate não é conveniente seria melhor usar propriedades que contém blocos que são chamados no lugar dos métodos do protocolo do delegate. Nada impede que as duas maneiras sejam implementadas.


Como o Invariante foi citado no Podcast do CocoaHeads Brasil, acho justo retribuir a gentileza ;-)

Essa semana saiu a terceira edição do Podcast semanal do CocoaHeads Brasil, nessa edição eu, Bruno Koga, Douglas Fischer e Tales Pinheiro conversamos sobre as novidades do iOS 9, o podcast está disponível no iTunes e no SoundCloud.


Diogo Tridapalli
@diogot

Tratamento de Erros em Swift

Uma das grandes novidades do Swift 2 foi o suporte para tratamento de erros (em inglês, error handling).

Mas o que isso quer dizer?

Algumas operações (geralmente funções) não oferecem a garantia de completar sua execução ou mesmo de produzir um retorno útil. Em Swift, usamos optionals para representar uma ausência de valor (nil). Porém, quando uma função retorna nil pode ter acontecido um erro e, muitas vezes, queremos entender o que causou este erro, para que nosso programa possa responder de acordo. É importante diferenciar as diversas formas que uma operação pode falhar e comunicar ao usuário adequadamente.

A forma mais comum de resolver o problema de tratamentos de erros com Objective-C é passar uma variável adicional de erro no método e, caso haja algum erro, o método fica responsável por popular essa variável com o objeto de erro, além de retornar nil.

Essa abordagem é confusa e não intuitiva. Esse é um exemplo comum em Objective-C:

1
2
3
NSString *path = @"..."; // caminho para um arquivo
NSError *error;
NSData *data = [NSData dataWithContentsOfFile:path options:NULL error: &error];

O problema aqui é que não temos informações claras sobre a relação entre o retorno data e o erro error. Se data for nil isso significa que error é não-nil? E se data for um objeto NSData válido, significa que error vai ser sempre nil? Existe algum caso em que ambos data e error são populados? Existe algum caso em que ambos data e error são nil?

Uma forma ingênua de resolver o problema em Swift de maneira similar (e carregar os mesmos efeitos colaterais da abordagem) seria termos funções que retornam uma tupla:

1
func dataWithContentsOfFile(path: String) -> (NSData?, NSError?) { ... }

Aqui, novamente, não existe relação entre os valores retornados na tupla e, pior, como precisamos retornar optionals (afinal, os valores podem ser nulos), o código fica totalmente deselegante.

E agora?

O Swift 2 resolve o problema introduzindo uma sintaxe adequada para o tratamento de erros.

Para os exemplos desse artigo, vamos criar uma camada de abstração sobre o AddressBook. Apesar de muitas das funcionalidades do AddressBook terem sido deprecated no iOS 9 (graças ao Contacts Framework), ele ainda é importante para apps que suportam acesso aos contatos no iOS 8. A idéia dessa camada é, exatamente, facilitar a transição para o Contacts Framework no futuro, minimizando o impacto no nosso código.

Primeiramente, vamos falar sobre o protocolo ErrorType. Ele é declarado na biblioteca padrão do Swift da seguinte forma:

1
2
public protocol ErrorType {
}

Isso mesmo. Ele é um protocolo vazio. Isso quer dizer que qualquer tipo de dado pode ser usado para representar um erro.

Em Swift, a melhor forma de representar erros é com enums (adotando o protocolo ErrorType). É importante lembrar que podemos passar valores associados a esses enums, possibilitando adicionar alguma informação relevante sobre a natureza do erro. Nesse artigo, porém, não falaremos de valores associados.

Para o nosso exemplo, temos a seguinte struct, que representará a nossa camada sobre o AddressBook. O importante aqui é saber que essa struct pode ser inicializada tanto passando um ABAddressBook como parâmetro ou inicializar com o ABAddressBook padrão:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public struct AddressBookPermission {
    
    private let addressBookRef: ABAddressBook?

    public init() {
        let unmanagedAddressBookRef = ABAddressBookCreateWithOptions(nil, nil)
        if let addressBookRef = unmanagedAddressBookRef {
            self.addressBookRef = addressBookRef.takeRetainedValue()
        } else {
            addressBookRef = nil
        }
    }
    
    public init(addressBookRef: ABAddressBookRef) {
        self.addressBookRef = addressBookRef
    }
}

E, para representar nossos erros, vamos declarar o seguinte enum (dentro de uma extension de AddressBookPermission):

1
2
3
4
5
6
public extension AddressBookPermission {
    public enum Error: ErrorType {
        case NotAuthorized
        case ContactCouldNotBeCreated
    }
}

Agora, vamos criar uma função no nosso AddressBookPermission onde recebemos um CFData contendo o vCard a ser adicionado ao Address Book e retornamos um array de Strings com os IDs adicionados:

1
public func addContactsFromVcard(vCardData: CFData) -> [String] { ... }

Note que o retorno da nossa função não é um optional, ou seja, nós garantimos que vamos retornar um Array de Strings (nem que ele seja vazio). Mas nossa função pode não conseguir completar a sua tarefa e se deparar com algum erro no seu caminho. Além disso, queremos determinar de forma clara a diferença entre retornar um Array vazio (ou seja, não havia nenhum contato no vCard) ou “retornar” um erro (ou seja, alguma coisa realmente deu errado).

Para isso, vamos adicionar o keyword throws na nossa função. Como somos bons cidadãos, também vamos documentar (uso o VVDocumenter para isso):

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
    Adds contacts (in form of vCard data) to the Address Book.
    
    - parameter vCardData: The vCardData to be added.
    
    - throws: AddressBookPermissionError.NotAuthorized if the user has denied access to the Address Book.
    
    	AddressBookPermissionError.ContactCouldNotBeCreated contact couldn't not be created for any other reason.
    
    - returns: the new Contact IDs as a [String]
    */
    
	public func addContactsFromVcard(vCardData: CFData) throws -> [String] { ... }

Nota: como utilizamos a sintaxe do Swift para documentação, é assim que vemos nossos comentários ao clicarmos com ⌥+click na chamada na nossa função:

Boa documentação <3

A declaração da nossa função agora diz que ela retorna um Array de Strings, mas ao invés disso ela pode terminar a execução no meio e jogar um erro.

Nesse caso, existem dois tipos de erros que nos interessa: ou o usuário não deu permissão para acessar o Address Book (.NotAuthorized) ou o contato não pôde ser criado por qualquer outro motivo (falta de espaço em disco, dados corrompidos, etc: .ContactCouldNotBeCreated). Esse é o corpo da nossa função (não se assuste com as chamadas C-style da API do ABAddressBook):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
    Adds contacts (in form of vCard data) to the Address Book.
    
    - parameter vCardData: The vCardData to be added.
    
    - throws: AddressBookPermissionError.NotAuthorized if the user has denied access to the Address Book.
    
        AddressBookPermissionError.ContactCouldNotBeCreated contact couldn't not be created for any other reason.
    
    - returns: the new Contact IDs as a [String]
    */
    public func addContactsFromVcard(vCardData: CFData) throws -> [String] {
        if authorizationStatus() != .Authorized {
            throw Error.NotAuthorized
        }
        
        var contactIds: [String] = []
        let defaultSource = ABAddressBookCopyDefaultSource(addressBookRef).takeRetainedValue()
        let vCardPeople = ABPersonCreatePeopleInSourceWithVCardRepresentation(defaultSource, vCardData).takeRetainedValue() as [ABRecord]
        
        for person in vCardPeople {
            var addRecordError: Unmanaged<CFError>? = nil
            if ABAddressBookAddRecord(addressBookRef, person, &addRecordError) {
                let recordId = ABRecordGetRecordID(person)
                let contactIdString = String(recordId)
                contactIds.append(contactIdString)
            } else {
                if let error = addRecordError?.takeRetainedValue() as NSError? {
                    ABAddressBookRevert(addressBookRef)
                    switch error.code {
                    case kABOperationNotPermittedByUserError:
                        throw Error.NotAuthorized
                    case kABOperationNotPermittedByStoreError:
                        fallthrough
                    default:
                        throw Error.ContactCouldNotBeCreated
                    }
                } else {
                    throw Error.ContactCouldNotBeCreated
                }
            }
        }
        
        ABAddressBookSave(addressBookRef, nil);
        
        return contactIds
    }

Nas duas primeiras linhas, checamos se estamos autorizados a acessar o Address Book. Essa é a implementação da função authorizationStatus():

1
2
3
4
5
6
7
8
9
10
/**
    Checks our current ABAuthorizationStatus.
    
    - returns: The current ABAuthorizationStatus
    */
    private func authorizationStatus() -> ABAuthorizationStatus {
        let authorizationStatus = ABAddressBookGetAuthorizationStatus()
        return authorizationStatus
    }
   

Se não tivermos acesso, nós jogamos um erro. Nesse caso, a execução da função é encerrada (e não há retorno!). Caso contrário, continuamos a execução. Utilizamos a API do ABAddressBook para criar um array de ABRecord a partir do nosso vCard (um vCard pode ter mais de um contato). A partir daí iteramos sobre esse Array, criando os contatos no nosso AddressBook. Na implementação apresentada, se tivermos qualquer erro durante esse processo, nós revertemos o AddressBook para o estado inicial e jogamos o erro apropriado (fazendo um mapeamento dos CFError criados pela função ABAddressBookAddRecord para ErrorType). Caso tudo ocorra bem, salvamos o AddressBook e retornamos o Array de IDs (String) criados.

Ok, entendi. Mas como uso isso agora?

Bom, agora vamos criar o código que vai utilizar nossa função. Se tentarmos escrever algo assim:

1
2
3
4
5
6
7
let permission = AddressBookPermission()
let urlPath = NSBundle.mainBundle().pathForResource("vcard", ofType: "vcf")
if let urlPath = urlPath {
	if let vCardData = NSData(contentsOfFile: urlPath) {
		permission.addContactsFromVcard(vCardData) //ignoramos o return
	}
}

O compilador nos dará o erro “Call can throw, but it is not marked with ‘try’ and the error is not handled”.

Cadê o try?

Faça, tente, capture.

Existem quatro formas de manipular erros em Swift. Você pode propagar o erro, tratar o erro com do-catch, tratar o erro como um valor opcional ou você pode forçar a chamada sem tratar o erro (e caso o erro ocorra você terá um crash - similar a forçar um desempacotamento de opcional quando ele é nil).

É importante lembrar que quando uma função lança um erro (lançar = throw), o fluxo do seu programa sofre uma alteração. É importante identificar e tratar corretamente os lugares onde erros podem ser lançados.

Propagar

No nosso exemplo, se quisermos simplesmente propagar o erro, podemos encapsular o nosso código em uma função e declarar que ela também lança (throw) um erro. Além disso, precisamos marcar a(s) chamada(s) que podem lançar erros com try:

1
2
3
4
5
6
7
8
9
func addContacts() throws {
    let permission = AddressBookPermission()
    let urlPath = NSBundle.mainBundle().pathForResource("vcard", ofType: "vcf")
    if let urlPath = urlPath {
        if let vCardData = NSData(contentsOfFile: urlPath) {
            try permission.addContactsFromVcard(vCardData) //ignoramos o return
        }
    }
}

Tratar o erro com do-catch

Você pode tratar um erro diretamente usando o do-catch. Basicamente, você encapsula o código que pode lançar um erro dentro de um escopo do, marca as chamadas pra funções que lançam erro com try e captura os erros com catch:

1
2
3
4
5
6
7
8
9
10
11
12
13
let permission = AddressBookPermission()
let urlPath = NSBundle.mainBundle().pathForResource("vcard", ofType: "vcf")
if let urlPath = urlPath {
	if let vCardData = NSData(contentsOfFile: urlPath) {
		do {
			let ids = try permission.addContactsFromVcard(vCardData)
		} catch AddressBookPermission.Error.NotAuthorized {
		// Mostra um alert dizendo que não temos permissão e mostrando como dar permissão de acesso à agenda.
		} catch AddressBookPermission.Error.ContactCouldNotBeCreated {
		// Mostra um alert de que algo deu errado, mas que não sabemos exatamente o motivo.
		}
	}
}

Note que não precisamos ter uma cláusula catch para cada erro que possa ser lançado. Ao invés disso, podemos ter uma cláusula catch que captura todos os demais erros (semelhante a um default no switch) ou até mesmo tratar alguns erros e propagar outros (para isso, precisaríamos marcar nossa função com throws novamente). Veja os exemplos:

Aqui, capturamos todos os erros e tratamos da mesma forma:

1
2
3
4
5
6
7
8
9
10
11
let permission = AddressBookPermission()
let urlPath = NSBundle.mainBundle().pathForResource("vcard", ofType: "vcf")
if let urlPath = urlPath {
	if let vCardData = NSData(contentsOfFile: urlPath) {
		do {
			let ids = try permission.addContactsFromVcard(vCardData)
		} catch {
		// Aqui capturamos todos os erros da mesma forma.
		}
	}
}

Aqui, tratamos um tipo de erro, mas propagamos os outros:

1
2
3
4
5
6
7
8
9
10
11
func addContacts() throws {
    let permission = AddressBookPermission()
    let urlPath = NSBundle.mainBundle().pathForResource("vcard", ofType: "vcf")
    if let urlPath = urlPath {
    	do {
			let ids = try permission.addContactsFromVcard(vCardData)
		} catch AddressBookPermission.Error.NotAuthorized {
			// Tratamos esse caso
		}
	}
}

Converter erros para valores opcionais

Você pode usar a sintaxe try? para tratar o erro convertendo ele para um valor opcional. Isso quer dizer que, se um erro for lançado durante uma expressão marcada com try?, o valor da expressão será nil (porém, você vai perder qualquer informação relacionada ao erro lançado, uma vez que usando try? você abdica da capacidade de capturar o erro. Nosso código ficaria assim:

1
let ids = try? permission.addContactsFromVcard(vCardData)

Caso um erro seja lançado, o valor de ids será nil.

Forçar “não-erro”

Seja porque você tem certeza que uma função não vai lançar um erro ou seja por pura displicência, você pode também usar a seguinte sintaxe para desabilitar completamente a propagação de erros. Note que, usando essa sintaxe, caso um erro seja lançado, você vai ter um erro de tempo de execução (e, claro, um crash):

1
try! permission.addContactsFromVcard(vCardData)

Defer

Quando você declara uma função que pode lançar um erro, você pode usar o defer para executar comandos momentos antes da execução do código deixar o bloco de código atual. O defer é muito útil para garantir que um um certo código irá rodar independentemente de como o seu código terminou a execução (seja por um return, throw, ou break). Caso você tenha múltiplos defer, os códigos dentro de defer são executados na ordem inversa da qual eles são declarados, ou seja, o código no primeiro defer vai rodar depois do código no segundo defer e assim por diante. Veja esse exemplo retirado do Swift Programming Language. Nesse código, o defer garante que o arquivo será fechado:

1
2
3
4
5
6
7
8
9
10
11
12
func processFile(filename: String) throws {
    if exists(filename) {
        let file = open(filename)
        defer {
            close(file)
        }
        while let line = try file.readline() {
            // Work with the file.
        }
        // close(file) is called here, at the end of the scope.
    }
}

Isso é importante mesmo?

Apesar do Swift 2 ser recente, já existem muitos artigos e exemplos sobre tratamentos de erros em Swift. Além disso, com a promessa do Swift ter seu código aberto até o fim do ano, entender todas as capacidades da linguagem, o seu funcionamento e sua biblioteca padrão podem ser importantes mesmo se você não desenvolve ou não tem planos para desenvolver especificamente para o ecossistema da Apple. E tratamento de erros está enraizado tanto na filosofia como nas boas práticas do Swift.


Bruno Koga
@brunokoga

UIScrollView ao resgate, não tão rápido...

Bem vindo ao Invariante, este é o nosso primeiro post. A idéia do blog é postar no mínimo dois artigos por mês, se quiser saber mais sobre a gente vá em Sobre.


A cada ano aumenta a variedade de dispositivos iOS com tamanhos de telas diferentes e assim o Auto Layout se torna cada vez mais importante no desenvolvimento. Já usei muito autoresizingMask e calculei muito frame na mão, mas tenho apreciado cada vez mais o Auto Layout e tenho feito dele minha principal ferramenta de layout. Entretanto essa semana me deparei com um problema imune ao Auto Layout, talvez devido minha falta de habilidade ¯\_(ツ)_/¯.

A idéia é ter uma View2 que pertence a um view controller 2 dentro de uma View1 pertencente a um view controller 1. A View2 pode ter um tamanho arbitrário definido pelo view controller 1. A View2 irá conter uma imagem que deve ser centralizada, o tamanho dessa imagem é arbitrário e deve ser redimensionado de maneira que a distância do lado maior mais próximo da borda seja de x pontos.

UIScrollView1

Até ai nada de complicado, mas também é necessário que seja possível fazer zoom e scroll da imagem e a margem de x pontos seja mantida independente da ampliação.

UIScrollView2

Bom nossa boa e velha amiga UIScrollView parece ser uma ótima candidata para salvar o dia mas, para isso, precisamos entender melhor com ela funciona. Uma ótima referência é um artigo da edição sobre views do objc.io, Understanding Scroll Views. Vou resumir alguns conceitos básicos e colocar um pouco da minha visão mas o recomendo a leitura do artigo, assim como os outros artigos dessa edição sobre views. Para entender como uma scroll view funciona precisamos entender o que significam 3 propriedades: contentOffset, contentSize e contentInset.

UIScrollView3

##contentOffset

O contentOffset define a posição do scroll, isto é, o deslocamento das sub views da scroll view. Na prática ela é a origin do bounds da scroll view, mas alguém poderia perguntar: a origem não é sempre {0,0}? Nem sempre, um ponto qualquer ({xS,yS}) de uma subView é convertido para o sistema de coordenadas ({x,y}) de sua super view (view) da seguinte forma:

x = xS + subView.frame.origin.x + view.bounds.origin.x
y = yS + subView.frame.origin.y + view.bounds.origin.y

Como normalmente view.bounds.origin = {0,0} para calcular a posição de um ponto qualquer da subView na view é só somar a origem do frame da subView. Isso significa que quando mudamos a origin do bounds de uma view, todas as suas sub views vão ser deslocadas pela a mesma quantidade, truque maroto!

Se consideramos que a UIImageView tem origin = {0,0} o contentOffset é a distância entre o canto superior esquerdo da image view e o da scroll view, como ilustrado na figura acima.

##contentSize

É o tamanho do conteúdo apresentado, no caso da figura o contentSize é igual o frame.size da image view. Num caso geral ele só depende do tamanho e disposição das sub views, nunca da scroll view.

##contentInset

Usado para definir uma margem para apresentação do conteúdo, por padrão seu valor é {0,0,0,0}, e portanto o tamanho da área que pode apresentar conteúdo é igual à scrollView.frame.

Quando o contentSize for menor que o tamanho da scroll view isso significa que as sub views irão ficar no canto superior esquerda, fixas. Uma maneira de centralizar o conteúdo é colocar um inset como metade da diferença de tamanho entre a scroll view e o contentSize:

CGFloat xInset = (CGRectGetWidth(scrollView.frame) - scrollView.contentSize.width)/2.;
CGFloat yInset = (CGRectGetHeight(scrollView.frame) - scrollView.contentSize.height)/2.;

scrollView.contentInset = UIEdgeInsetsMake(yInset, xInset, yInset, xInset);

Quando o contentSize for maior que que a scroll view o contentInset define os limites máximos de scroll. Por exemplo no caso da UIImageView que tem a frame.origin = {0,0}, os limites da UIImageView não podem “entrar” na área definida pelo frame da scrool view descontado o contentInset. A figura abaixo deve deixar isso mais claro.

UIScrollView4

Ok, agora fica fácil escrever o código que adiciona uma imagem à uma scroll view e define essas propriedades corretamente:

- (void)updateWithImage:(UIImage *)image
{
    UIScrollView *scrollView = self.scrollView;
    UIImageView *imageView = self.imageView;
    
    imageView.image = image;
    CGSize imageSize = image.size;
    CGRect imageFrame = CGRectMake(0, 0, imageSize.width, imageSize.height);
    
    imageView.frame = imageFrame;
    scrollView.contentSize = imageSize;
    
    CGRect scrollViewFrame = scrollView.frame;
    
    // Set Inset
    UIEdgeInsets insets = self.scrollViewDefaultInset;
    insets = [self insetsForContentFrame:imageFrame
               insideScrollViewWithFrame:scrollViewFrame
                       withDefaultInsets:insets];
    scrollView.contentInset = insets;
    
    // Set Zoom
    CGSize scrollViewSize = CGSizeMake(CGRectGetWidth(scrollViewFrame) - insets.left - insets.right,
                                       CGRectGetHeight(scrollViewFrame) - insets.top - insets.bottom);
    CGFloat xMinZoomScale = scrollViewSize.width/(imageSize.width + 2. * kMargin);
    CGFloat yMinZoomScale = scrollViewSize.height/(imageSize.height + 2. * kMargin);
    CGFloat minimumZoomScale = MIN(xMinZoomScale, yMinZoomScale);
    scrollView.minimumZoomScale = minimumZoomScale;
    scrollView.maximumZoomScale = minimumZoomScale * kMaxZoomFactor;
    
    // Fit on screen
    scrollView.zoomScale = minimumZoomScale;
}

Sendo que função que calcula os insets é:

- (UIEdgeInsets)insetsForContentFrame:(CGRect)contentFrame
            insideScrollViewWithFrame:(CGRect)scrollViewFrame
                    withDefaultInsets:(UIEdgeInsets)insets
{
    CGSize contentSize = contentFrame.size;
    CGSize scrollViewSize = CGSizeMake(CGRectGetWidth(scrollViewFrame) - insets.left - insets.right,
                                       CGRectGetHeight(scrollViewFrame) - insets.top - insets.bottom);
    CGFloat margin = kMargin;
    
    CGFloat xInset = MAX((scrollViewSize.width - contentSize.width)/2., margin);
    CGFloat yInset = MAX((scrollViewSize.height - contentSize.height)/2., margin);
    
    insets.left += xInset;
    insets.right += xInset;
    insets.top += yInset;
    insets.bottom += yInset;

    return insets;
}

Para habilitar o zoom só falta implementar um método do UIScrollViewDelegate:

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
    return self.imageView;
}

Esse método apenas retorna qual a view que será aplicado o zoom, da primeira vez que vi achei muito estranho, mas entender como a UIScrollView faz o zoom tudo ficou muito mais claro.

##Bônus: zoomScale

A UIScrollView faz zoom aplicando uma transformação na view retornada pelo método do delagate descrito acima. Isto é, aplica uma CGAffineTransform do tipo CGAffineTransformMakeScale(zoomScale, zoomScale) na subview. Isso faz com que o frame da subView seja alterado! E, portanto, o contentSize da scrollView, por isso sempre que o contentSize e, consequentemente, o zoomScale forem alterados o contentInset deve ser recalculado. Isso pode ser feito facilmente implementando mais um método do delegate:

- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
    UIEdgeInsets insets = self.scrollViewDefaultInset;
    
    insets = [self insetsForContentFrame:self.imageView.frame
               insideScrollViewWithFrame:scrollView.frame
                       withDefaultInsets:insets];
    
    scrollView.contentInset = insets;
}

A UIScrollView é uma classe muito importante no UIKit, seu funcionamento é muito simples, mas entender como ela exatamente funciona pode não ser uma tarefa muito simples.

Um exemplo dessa solução funcionando pode ser encontrada no repositório UIScrollView-Center. Qualquer dúvida, críticas e comentários são bem vindos, a maneira mais fácil de me encontrar é no Twitter.


Diogo Tridapalli
@diogot