Prólogo
Em um dos meus projetos, eu precisava criar uma interface como essa no Snepchat. Quando um cartão com informações sai da câmera em cima da imagem, substitua-o suavemente por uma cor sólida e também na direção oposta. Pessoalmente, fiquei particularmente fascinado com a transição da janela da câmera para o cartão lateral e, com grande prazer, recontamos maneiras de resolver esse problema.
À esquerda, um exemplo do Snepchat, à direita, um exemplo de aplicativo que criaremos.


Provavelmente a primeira solução que vem à mente é adaptar o UIScrollView
, organizar de alguma forma as visualizações, usar a paginação, mas, francamente, o pergaminho é pensado para resolver problemas completamente diferentes, captando animações adicionais que consomem tempo e não possuem a flexibilidade necessária configurações. Portanto, usá-lo para resolver esse problema é absolutamente injustificado.
A rolagem entre a janela da câmera e a guia lateral é enganosa - não é uma rolagem, é uma transição interativa entre as visualizações pertencentes a diferentes controladores. Os botões na parte inferior são guias comuns, clicando nos quais nos lança entre os controladores.

Dessa maneira, o Snatch usa sua própria versão de um controlador de navegação, como o UITabBarController
com transições interativas personalizadas.
UIKit
inclui duas opções para controladores de navegação que permitem personalizar transições - são UINavigationController
e UITabBarController
. Ambos têm navigationController(_:interactionControllerFor:)
métodos navigationController(_:interactionControllerFor:)
tabBarController(_:interactionControllerFor:)
navigationController(_:interactionControllerFor:)
e tabBarController(_:interactionControllerFor:)
em seus delegados, respectivamente, o que nos permite usar nossa própria animação interativa para a transição.
tabBarController (_ :actionControllerFor :)
navigationController (_ :actionControllerFor :)
Mas eu não gostaria de ser limitado pela implementação de UITabBarController
ou UINavigationController
, especialmente porque não podemos controlar sua lógica interna. Portanto, decidi escrever meu controlador semelhante e agora quero contar e mostrar o que veio dele.
Declaração do problema
Crie seu próprio controlador de contêiner, no qual você pode alternar entre controladores filhos usando animações interativas para transições, usando o mecanismo padrão em UITabBarController
e UINavigationController
. Precisamos desse mecanismo padrão para usar animações de transição prontas do tipo UIViewControllerAnimatedTransitioning
já gravadas.
Preparação do projeto
Normalmente, tento mover os módulos para estruturas separadas, para isso crio um novo projeto de aplicativo e adiciono um destino adicional do Cocoa Touch Framework
lá e depois disperso as fontes no projeto para os destinos correspondentes. Dessa forma, obtenho uma estrutura separada com um aplicativo de teste para depuração.
Crie um Single View App
.

Product Name
será nosso alvo.

Clique em +
para adicionar o alvo.

Escolha Cocoa Touch Framework
.

Chamamos nossa estrutura de nome apropriado, o Xcode seleciona automaticamente o projeto para o nosso destino e nos oferece vincular o binário diretamente ao aplicativo. Nós concordamos.

Não precisaremos do Main.storyboard
e do Main.storyboard
padrão, os Main.storyboard
.

Além disso, não se esqueça de remover o valor da Main Interface
no destino do aplicativo na guia General
.

Agora vamos para AppDelegate.swift
e deixamos apenas o método de application
do seguinte conteúdo:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Aqui, colocamos nosso controlador no local principal para que apareça após o iniciador.
Agora crie esse mesmo MasterViewController
. Será relacionado ao aplicativo, por isso é importante escolher o destino certo ao criar o arquivo.

MasterViewController
do SnapchatNavigationController
, que implementaremos posteriormente na estrutura. Não se esqueça de especificar a import
nossa estrutura. Eu não forneço o código completo do controlador aqui, as omissões são mostradas por reticências ...
, coloquei o aplicativo no GitHub , onde você pode ver todos os detalhes. Neste controlador, estamos interessados apenas no método viewDidLoad()
, que inicializa o controlador em segundo plano com a câmera + um controlador transparente (janela principal) + o controlador que contém o cartão de partida.
import MakingSnapchatNavigation class MasterViewController: SnapchatNavigationController { override func viewDidLoad() { super.viewDidLoad()
O que está acontecendo aqui? Criamos um controlador com uma câmera e o setBackground
segundo plano usando o método setBackground
do SnapchatNavigationController
. Este controlador contém uma imagem esticada para toda a visão da câmera. Em seguida, criamos um controlador transparente vazio e o adicionamos ao array, ele simplesmente passa a imagem da câmera através dele, podemos colocar controles nele, criar outro controlador transparente, adicionar um scroll a ele, adicionar uma visualização com conteúdo dentro do scroll, adicionar um segundo controlador ao matriz e defina essa matriz usando o método especial setViewControllers
do pai SnapchatNavigationController
.
Não se esqueça de adicionar uma solicitação para usar a câmera no Info.plist
<key>NSCameraUsageDescription</key> <string>Need camera for background</string>
Nisso, consideramos o aplicativo de teste pronto e passamos à parte mais interessante - a implementação da estrutura.
Estrutura do controlador pai
Primeiro, crie um SnapchatNavigationController
vazio, é importante escolher o destino certo para ele. Se tudo foi feito corretamente, o aplicativo deve ser construído. Esse status do projeto pode ser descarregado por referência .
open class SnapchatNavigationController: UIViewController { override open func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } // MARK: - Public interface /// Sets view controllers. public func setViewControllers(vcs: [UIViewController]) { } /// Sets background view. public func setBackground(vc: UIViewController) { } }
Agora adicione os componentes internos nos quais o controlador será composto. Não trago todo o código aqui, concentro-me apenas em pontos importantes.
Definimos as variáveis para armazenar a matriz de controladores filhos. Agora, definimos rigidamente a quantidade necessária - 2 peças. No futuro, será possível expandir a lógica do controlador para uso com qualquer número de controladores. Também configuramos uma variável para armazenar o controlador atual exibido.
private let requiredChildrenAmount = 2 // MARK: - View controllers /// top child view controller private var topViewController: UIViewController? /// all children view controllers private var children: [UIViewController] = []
Crie as visualizações. Precisamos de uma visão para o segundo plano, uma visão com o efeito que queremos aplicar ao segundo plano ao alterar o controlador. Também temos um contêiner de visualização para o controlador filho atual e um indicador de visualização que informará ao usuário como trabalhar com a navegação.
// MARK: - Views private let backgroundViewContainer = UIView() private let backgroundBlurEffectView: UIVisualEffectView = { let backgroundBlurEffect = UIBlurEffect(style: UIBlurEffectStyle.light) let backgroundBlurEffectView = UIVisualEffectView(effect: backgroundBlurEffect) backgroundBlurEffectView.alpha = 0 return backgroundBlurEffectView }() /// content view for children private let contentViewContainer = UIView() private let swipeIndicatorView = UIView()
No próximo bloco, definimos duas variáveis: swipeAnimator
é responsável pela animação, swipeInteractor
é responsável pela interação (a capacidade de controlar o progresso da animação), precisamos inicializá-lo durante a inicialização do controlador, para forçar o desembrulhamento.
// MARK: - Animation and transition private let swipeAnimator = AnimatedTransitioning() private var swipeInteractor: CustomSwipeInteractor!
Também definimos a transformação para o indicador. Mudamos o indicador pela largura do contêiner + deslocamento duplo da borda + a largura do próprio indicador, de modo que o indicador esteja na extremidade oposta do contêiner. A largura do contêiner será conhecida durante o aplicativo, portanto a variável é calculada em movimento.
// MARK: - Animation transforms private var swipeIndicatorViewTransform: CGAffineTransform { get { return CGAffineTransform(translationX: -contentViewContainer.bounds.size.width + (swipeIndicatorViewXShift * 2) + swipeIndicatorViewWidth, y: 0) } }
Durante o carregamento do controlador, atribuímos self
à animação (implementaremos o protocolo correspondente abaixo), inicializamos o interator com base em nossa animação, cujo progresso ele controlará. Também o designamos como delegado. O delegado responderá ao início do gesto do usuário e iniciará a animação ou cancelará dependendo do estado do controlador. Em seguida, adicionamos todas as visualizações à principal e chamamos setupViews()
, que define as restrições.
override open func viewDidLoad() { super.viewDidLoad() swipeAnimator.animation = self swipeInteractor = CustomSwipeInteractor(with: swipeAnimator) swipeInteractor.delegate = self view.addSubview(backgroundViewContainer) view.addSubview(backgroundBlurEffectView) view.addSubview(contentViewContainer) view.addSubview(swipeIndicatorView) setupViews() }
Em seguida, passamos à lógica de instalar e remover controladores filhos em um contêiner. Tudo aqui é simples, como na documentação da Apple. Usamos os métodos prescritos para esse tipo de operação.
addChildViewController(vc)
- adiciona um controlador filho ao atual.
contentViewContainer.addSubview(vc.view)
- adicione a visualização do controlador à hierarquia da visualização.
vc.view.frame = contentViewContainer.bounds
- estende a exibição para todo o contêiner. Como usamos os quadros aqui em vez do layout automático, precisamos alterar seus tamanhos sempre que o tamanho do controlador mudar, omitiremos essa lógica e assumiremos que o contêiner não alterará o tamanho do aplicativo enquanto o aplicativo estiver em execução.
vc.didMove(toParentViewController: self)
- encerra a operação de adição de um controlador filho.
swipeInteractor.wireTo
- vinculamos o controlador atual aos gestos do usuário. Mais tarde, analisaremos esse método.
// MARK: - Private methods private func addChild(vc: UIViewController) { addChildViewController(vc) contentViewContainer.addSubview(vc.view) vc.view.frame = contentViewContainer.bounds vc.didMove(toParentViewController: self) topViewController = vc let goingRight = children.index(of: topViewController!) == 0 swipeInteractor.wireTo(viewController: topViewController!, edge: goingRight ? .right : .left) } private func removeChild(vc: UIViewController) { vc.willMove(toParentViewController: nil) vc.view.removeFromSuperview() vc.removeFromParentViewController() topViewController = nil }
Existem mais dois métodos cujo código não fornecerei aqui: setViewControllers
e setBackground
. No método setViewControllers
simplesmente configuramos a matriz de controladores filhos na variável correspondente do nosso controlador e chamamos addChild
para exibir um deles na exibição. No método setBackground
fazemos o mesmo que em addChild
, apenas para o controlador em segundo plano.
Lógica de animação do controlador de contêiner
Total, a base do nosso controlador pai é:
- UIView dividido em dois tipos
- Lista de filho UIViewController
- Um objeto de controle de animação do tipo
swipeAnimator
AnimatedTransitioning
- Um objeto que controla o curso interativo de uma animação
swipeInteractor
do tipo CustomSwipeInteractor
- Delegar Animação Interativa
- Implementação do Protocolo de Animação
Agora, analisaremos os dois últimos pontos e seguiremos para a implementação do AnimatedTransitioning
e CustomSwipeInteractor
.
Delegar Animação Interativa
O delegado consiste em apenas um panGestureDidStart(rightToLeftSwipe: Bool) -> Bool
, que informa o controlador sobre o início do gesto e sua direção. Em resposta, ele aguarda informações sobre se a animação pode ser considerada iniciada.
Como delegado, verificamos a ordem atual dos controladores para entender se podemos iniciar a animação na direção especificada e, se estiver tudo bem, iniciamos o método de transition
, com os parâmetros: o controlador do qual estamos nos movendo, o controlador para o qual estamos nos movendo, direção do movimento, sinalizador de interatividade (em caso de false
, uma animação de transição com tempo determinado é iniciada).
func panGestureDidStart(rightToLeftSwipe: Bool) -> Bool { guard let topViewController = topViewController, let fromIndex = children.index(of: topViewController) else { return false } let newIndex = rightToLeftSwipe ? 1 : 0 // - if newIndex > -1 && newIndex < children.count && newIndex != fromIndex { transition(from: children[fromIndex], to: children[newIndex], goingRight: rightToLeftSwipe, interactive: true) return true } return false }
Vamos examinar imediatamente o corpo do método de transition
. Primeiro, criamos o contexto de animação para a animação CustomControllerContext
. Também analisaremos essa classe um pouco mais tarde; ela implementa o protocolo UIViewControllerContextTransitioning
. No caso de UINavigationController
e UITabBarController
instância da implementação deste protocolo é criada automaticamente pelo sistema e sua lógica é oculta para nós, precisamos criar nossa própria.
let ctx = CustomControllerContext(fromViewController: from, toViewController: to, containerView: contentViewContainer, goingRight: goingRight) ctx.isAnimated = true ctx.isInteractive = interactive ctx.completionBlock = { (didComplete: Bool) in if didComplete { self.removeChild(vc: from) self.addChild(vc: to) } };
Em seguida, chamamos simplesmente animação fixa ou interativa. No futuro, será possível travar um fixo nos botões de tabulação da navegação entre controladores; neste exemplo, não faremos isso.
if interactive { // Animate with interaction swipeInteractor.startInteractiveTransition(ctx) } else { // Animate without interaction swipeAnimator.animateTransition(using: ctx) }
Protocolo de Animação
TransitionAnimation
protocolo de animação TransitionAnimation
consiste em 4 métodos:
addTo
é um método desenvolvido para criar a estrutura correta das visualizações filho no contêiner, de modo que a exibição anterior se sobreponha à nova de acordo com a idéia da animação.
prepare
é o método chamado antes da animação para preparar a exibição.
/// Setup the views position prior to the animation start. func prepare(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)
animation
- a própria animação.
finalize
- as ações necessárias após a conclusão da animação.
Não consideraremos a implementação usada, tudo é bastante transparente por lá, iremos direto para as três classes principais, graças às quais a animação ocorre.
class CustomControllerContext: NSObject, UIViewControllerContextTransitioning
O contexto da animação. Para descrever sua função, nos referimos à ajuda do protocolo UIViewControllerContextTransitioning
:
Um objeto de contexto encapsula informações sobre as visualizações e controladores de visualização envolvidos na transição. Ele também contém detalhes sobre como executar a transição.
O mais interessante é a proibição de adaptação deste protocolo:
Não adote esse protocolo em suas próprias classes, nem crie objetos diretamente que adotem esse protocolo.
Mas nós realmente precisamos que ele execute o mecanismo de animação padrão, então o adaptamos de qualquer maneira. Quase não tem lógica, apenas armazena estado. Portanto, nem vou trazê-lo aqui. Você pode assistir no GitHub .
Funciona muito bem em animações com tempo determinado. Mas ao usá-lo para animações interativas, surge um problema - UIPercentDrivenInteractiveTransition
chama um método não documentado no contexto. A única solução certa nessa situação é adaptar outro protocolo - UIViewControllerInteractiveTransitioning
para usar seu próprio contexto.
class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning
Aqui está - o coração do projeto, permitindo que existam animações interativas em controladores de contêiner personalizados. Vamos tomá-lo em ordem.
A classe é inicializada com um parâmetro do tipo UIViewControllerAnimatedTransitioning
, este é o protocolo padrão para animar a transição entre controladores. Dessa forma, podemos usar qualquer uma das animações já escritas em conjunto com nossa classe.
init(with animator: UIViewControllerAnimatedTransitioning) { self.animator = animator }
A interface pública é bastante simples, com quatro métodos, cuja funcionalidade deve ser óbvia.
Basta observar o momento em que a animação começa, pegamos a visualização principal do contêiner e configuramos a velocidade da camada para 0, para que possamos controlar o progresso da animação manualmente.
// MARK: - Public func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { self.transitionContext = transitionContext transitionContext.containerView.superview?.layer.speed = 0 animator.animateTransition(using: transitionContext) } func updateInteractiveTransition(percentComplete: CGFloat) { setPercentComplete(percentComplete: (CGFloat(fmaxf(fminf(Float(percentComplete), 1), 0)))) } func cancelInteractiveTransition() { transitionContext?.cancelInteractiveTransition() completeTransition() } func finishInteractiveTransition() { transitionContext?.finishInteractiveTransition() completeTransition() }
Agora nos voltamos para o bloco lógico privado de nossa classe.
setPercentComplete
define o deslocamento de tempo do progresso da animação para a camada de superview, calculando o valor a partir da porcentagem de conclusão e duração da animação.
private func setPercentComplete(percentComplete: CGFloat) { setTimeOffset(timeOffset: TimeInterval(percentComplete) * duration) transitionContext?.updateInteractiveTransition(percentComplete) } private func setTimeOffset(timeOffset: TimeInterval) { transitionContext?.containerView.superview?.layer.timeOffset = timeOffset }
completeTransition
é chamado quando o usuário interrompe seu gesto. Aqui, criamos uma instância da classe CADisplayLink
, que nos permitirá concluir automaticamente a animação lindamente a partir do momento em que o usuário não controla mais seu progresso. Adicionamos nosso displayLink
ao run loop
para que o sistema chame nosso seletor sempre que precisar exibir um novo quadro na tela do dispositivo.
private func completeTransition() { displayLink = CADisplayLink(target: self, selector: #selector(tickAnimation)) displayLink!.add(to: .main, forMode: .commonModes) }
Em nosso seletor, calculamos e definimos o deslocamento temporário do progresso da animação, como fizemos anteriormente durante o gesto do usuário, ou concluímos a animação quando ela atinge seu ponto inicial ou final.
@objc private func tickAnimation() { var timeOffset = self.timeOffset() let tick = (displayLink?.duration ?? 0) * TimeInterval(completionSpeed) timeOffset += (transitionContext?.transitionWasCancelled ?? false) ? -tick : tick; if (timeOffset < 0 || timeOffset > duration) { transitionFinished() } else { setTimeOffset(timeOffset: timeOffset) } } private func timeOffset() -> TimeInterval { return transitionContext?.containerView.superview?.layer.timeOffset ?? 0 }
Terminando a animação, desligamos nosso displayLink
, retornamos a velocidade da camada e, se a animação não foi cancelada, ou seja, atingiu seu quadro final, calculamos o tempo a partir do qual a animação da camada deve começar. Você pode aprender mais sobre isso no Guia de programação da animação principal ou nesta resposta ao stackoverflow.
private func transitionFinished() { displayLink?.invalidate() guard let layer = transitionContext?.containerView.superview?.layer else { return } layer.speed = 1; let wasNotCanceled = !(transitionContext?.transitionWasCancelled ?? false) if (wasNotCanceled) { let pausedTime = layer.timeOffset layer.timeOffset = 0.0; let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime layer.beginTime = timeSincePause } animator.animationEnded?(wasNotCanceled) }
class AnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning
A última classe que ainda não examinamos é a implementação do protocolo UIViewControllerAnimatedTransitioning
, no qual controlamos a ordem de execução dos métodos de protocolo da nossa animação addTo
, prepare
, animation
, finalize
. Tudo aqui é bastante prosaico, vale a pena notar apenas o uso do UIViewPropertyAnimator
para executar animação em vez do UIView.animate(withDuration:animations:)
mais típico UIView.animate(withDuration:animations:)
. Isso é feito para que seja possível controlar ainda mais o progresso da animação e, se for cancelada, retorne-a à sua posição inicial chamando finishAnimation(at: .start)
, que evita piscar desnecessariamente o quadro final da animação na tela.
Epílogo
Criamos uma demonstração funcional de uma interface semelhante à do Snapchat. Na minha versão, configurei as constantes para que existam campos à direita e à esquerda do cartão, além disso, deixei a câmera trabalhando na exibição em segundo plano para criar um efeito atrás do cartão. Isso é feito apenas para demonstrar os recursos dessa abordagem, como isso afetará o desempenho do dispositivo e eu não verifiquei a carga da bateria.
— , - , . , - .
GitHub .
, , , !

:
Custom Container View Controller Transitions, Joachim Bondo.
Objective C. Swift.
Interactive Custom Container View Controller Transitions, Alek Åström
, Objective C, Swift.
SwipeableTabBarController
, UITabBarController
. .