
Repositório público: Para aqueles que querem verificar o código imediatamente, aqui está o repositório: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator.
Nota: Esta é uma série de 3 partes. Esta é a primeira parte, e aqui está parte 2 e parte 3.
Não queria fazer muito com SwiftUI até que a próxima versão fosse anunciada devido à minha experiência anterior com Swift, quero dizer, provavelmente introduzirá muitas mudanças quebradoras. Lembro-me bem da dor de migrar entre as versões do Swift (de Swift 3 para 4?), e não queria passar por isso novamente.
Mas tudo mudou, o isolamento chegou e eu quis ajudar a construir um novo aplicativo para um amigo. Pensei que seria legal tentar SwiftUI fazendo um aplicativo real. Gostei do que vi até agora sobre ele, mas depois de usá-lo... que ferramenta incrível é! Algo que levava 2 dias para fazer com UIKit você pode fazer em 2 horas, isso simplesmente impulsiona o desenvolvimento tanto. É o que estávamos esperando enquanto olhávamos coisas como hot reloading dos colegas de frontend, ou React Native, ou Flutter... Mais um layout declarativo, nada mais para pedir.
Mas no SwiftUI nem tudo é terra de unicórnios, especialmente quando você descobre que não apenas a navegação está um pouco vinculada às visualizações, mas existem algumas coisas quebradas entre as versões menores do iOS 13 e outras plataformas.
Não posso fazer muito sobre como um framework/ferramenta da Apple está quebrado (acima de relatar radars feedbacks), mas posso explorar como a navegação pode ser desacoplada das visualizações, ou pelo menos posso tentar. Além disso, parece que há algum interesse.
Então neste post vou mostrar como usar SwiftUI com Coordinators, e usando o padrão de design MVP.
Escolhi MVP e Coordinators, porque trabalhei com ambos, e porque Coordinators se tornaram o padrão de fato para rotear nossa navegação em um aplicativo UIKit (obrigado Soroush! 😊). Não sei se esses 2 são os melhores padrões de design para usar com SwiftUI, talvez não, talvez algo como redux se encaixe melhor, eu não sei. Mas não faz mal tentar.
Não vou explicar como os Coordinators funcionam, existem várias postagens de blog explicando isso muito melhor e de pessoas mais inteligentes do que eu, as quais recomendo que você verifique se ainda não fez.
Ainda uma coisa antes de começarmos. Criei este projeto pensando em um grande aplicativo, com testes em mente. É por isso que você verá uma interface para a maioria dos tipos (ou seja, protocolos), então tudo pode ser mockado e testado.
Nesta parte, a parte 1, vamos aprender como configurar toda uma tela com o padrão MVP, criaremos um protocolo base Coordinator, e implementaremos nossos primeiros dois Coordinators. Vamos ver como envolver nossa visualização em uma NavigationView, e como podemos implementar NavigationLink de forma que não dependa de nada mais na visualização.
No próximo tópico, vamos ver como configurar nossa 1ª tela com MVP (Model-View-Presenter). Crie um novo projeto/playground e façamos nossa primeira visualização.
1. ⌨️ Configurando nossa primeira tela com MVP
Com nosso projeto criado, vamos definir a 1ª visualização do nosso aplicativo:
struct MasterView: View {
var body: some View {
Text("Vamos dominar o palco no NSSpain novamente")
}
}
Sinto muito, mas quero evitar usar palavras como "apenas", "fácil", "simples" ou "complexo"... mas isso é literalmente apenas um rótulo no meio da tela.
Agora o modelo, que apenas armazena uma data (chamarei de ViewModels porque estamos na camada UI):
struct MasterViewModel {
let date: Date
}
Agora o presenter:
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel = MasterViewModel(date: Date())
}
Sim, estamos fazendo protocolos o tempo todo. Este é um aplicativo pequeno, mas vamos tratá-lo como se fosse grande. Declaramos o protocolo do presenter para podermos injetá-lo na nossa view, o que torna coisas como testes mais fáceis.
Com a ajuda de Combine, declaramos a conformidade ao protocolo ObservableObject. Agora podemos observar as propriedades @Published da view.
Vamos mudar nossa view para adotar este presenter protocol:
struct MasterView: View {
@ObservedObject var presenter: MasterPresenting
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
Para vincular essa view ao presenter precisamos do wrapper de propriedade @ObservedObject. O dateFormatter é apenas um DateFormatter global definido em algum lugar. Agora nossa view está "ouvindo" sempre que nosso viewModel muda!
Mas como MasterPresenting está conformando com ObservableObject, é um protocolo genérico. Então temos que dizer isso à view:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
{...}
}
Ainda não queremos que MasterView saiba qual MasterPresenting estamos injetando, então vamos continuar usando o protocolo MasterPresenting, desta vez como genérico (<T: MasterPresenting>).
Nós acabamos de concluir a parte MVP da nossa primeira tela. No próximo tópico definiremos nosso tipo base Coordinator e nossos primeiros 2 coordinators.
Este é o que fizemos até agora:
struct MasterViewModel {
let date: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel = MasterViewModel(date: Date())
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
2. 🧭 Criando Coordinators
Com nosso MVP em lugar, agora vamos criar protocolos e implementações para coordinators. E como é o nosso Coordinator base?
protocol Coordinator {
func start()
}
Vamos começar com algo assim. E estendemos o protocolo para definir uma função de coordenação:
extension Coordinator {
func coordinate(to coordinator: Coordinator) {
coordinator.parent = self
coordinator.start()
}
}
Ei, como você está armazenando parent em uma extensão de protocolo? Onde é definido? Aguarde um momento, chegaremos lá.
Em seguida, podemos tentar implementar o MasterView's coordinator:
protocol MasterCoordinator: Coordinator {}
final class RootMasterCoordinator: MasterCoordinator {
func start() {
??
}
}
Hmmm, o que podemos fazer aqui? Vamos voltar ao início do aplicativo, o SceneDelegate, e ver o que precisamos.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let coordinator = AppCoordinator(window: window)
coordinator.start()
self.window = window
}
}
A parte importante são as 2 linhas do coordenador, a inicialização, onde injetamos a janela e coordinator.start(). Agora vamos definir nosso AppCoordinator, que será o ponto de partida da navegação do aplicativo:
final class AppCoordinator: Coordinator {
private weak var window: UIWindow?
init(window: UIWindow) {
self.window = window
}
func start() {
let coordinator = RootMasterCoordinator(window: window)
coordinate(to: coordinator)
}
}
Nos injetamos a UIWindow através do construtor e, em seguida, passamos para o coordenador da MasterView.
Agora precisamos lidar com a apresentação na janela. Vamos voltar ao nosso RootMasterCoordinator e configurar a função start():
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let hosting = UIHostingController(rootView: view)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
Aqui, pegamos a janela e apresentamos o rootViewController (UIHostingController é necessário para trazer Views do SwiftUI para o UIKit). Os coordenadores AppCoordinator e RootMasterCoordinator são os únicos dois que precisam de UIKit, talvez em janeiro tenhamos uma nova API para UISceneDelegate/UIApplicationDelegate?
Cool! Os coordenadores estão funcionando via AppCoordinator e RootMasterCoordinator.
Há uma linha interessante no RootMasterCoordinator, o que está por trás daquele MasterFactory? Como você pode imaginar, é uma fábrica:
enum MasterFactory {
static func make(with coordinator: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordinator)
let view = MasterView(presenter: presenter)
return view
}
}
Estou usando um enum para que ele não possa ser inicializado, mas uma estrutura com um construtor indisponível também funciona. Aqui temos um detalhe interessante, observe que estamos retornando some View, então podemos obter a view do SwiftUI no RootMasterCoordinator.
Também parece que estamos injetando nosso coordenador no presenter agora, em MasterPresenter(coordinator: coordinator), então vamos implementar isso também:
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordinator: MasterCoordinator
init(coordinator: MasterCoordinator) {
self.coordinator = coordinator
self.viewModel = MasterViewModel(date: Date())
}
}
Como você pode ver, injetamos o coordenador através do construtor assim como fizemos com a janela no coordenador. Em seguida, você pode querer vincular seu modelo/entidade aqui. Tentei usar onAppear em MasterView e reportá-lo de volta ao presenter como uma maneira de vincular o ViewModel, mas funciona como um viewDidAppear, não como um viewDidLoad.
Aprendemos a ligar tudo juntos, o MVP com os coordenadores, agora temos a estrutura básica da UI. No próximo tópico veremos como podemos navegar para uma view.
Este é o que fizemos até agora:
protocol Coordinator {
func start()
}
extension Coordinator {
func coordinate(to coordinator: Coordinator) {
coordinator.start()
}
}
final class AppCoordinator: Coordinator {
private weak var window: UIWindow?
init(window: UIWindow) {
self.window = window
}
func start() {
let coordinator = RootMasterCoordinator(window: window)
coordinate(to: coordinator)
}
}
protocol MasterCoordinator: Coordinator {}
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let hosting = UIHostingController(rootView: view)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
enum MasterFactory {
static func make(with coordinator: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordinator)
let view = MasterView(presenter: presenter)
return view
}
}
struct MasterViewModel {
let date: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordinator: MasterCoordinator
init(coordinator: MasterCoordinator) {
self.coordinator = coordinator
self.viewModel = MasterViewModel(date: Date())
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)"
}
}
3. 🗺 Navegando para outra visualização
Agora que temos nosso MVP e Coordinators em funcionamento, vamos ao MasterView e ver como podemos navegar para outra visualização com NavigationLink:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
NavigationView {
NavigationLink(destination: EmptyView()) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)"
}
}
}
}
O que está acontecendo aqui? Estamos dizendo ao MasterView que seu conteúdo é envolvido em um NavigationView, algo como um UINavigationController. Em seguida, com NavigationLink, criamos uma ação de push para EmptyView(), que será acionada quando o texto for pressionado.
Mas não queremos nem mesmo que o MasterView saiba que está sendo apresentado em um NavigationView, ou que estamos apresentando EmptyView(), ou que deve usar NavigationLink para apresentá-lo.
Então, primeiro, vamos mover o NavigationView. Para onde? Sim, o coordenador:
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let navigation = NavigationView { view }
let hosting = UIHostingController(rootView: navigation)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
NavigationLink(destination: EmptyView()) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
}
Melhor. Em seguida, vamos mover o NavigationLink também. Deveria ser uma função que podemos chamar no presenter, algo como:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
presenter.presentSuperAmazingView {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
}
Mas existem dois problemas aqui. Um, é difícil entender o que presentSuperAmazingView faz, ela transforma Text em um botão? vai empurrar a view? Segundo, estamos trabalhando com NavigationLink, mas e se quisermos apresentar uma modal?
A maneira de apresentar uma modal é usando um modificador chamado .sheet. Isso mesmo, para empurrar uma view temos a estrutura NavigationView e para apresentar uma modal temos o modificador .sheet. Se há algo que realmente quero evitar é a falta de consistência. Talvez eu seja muito burro para entender por que foi feito assim, mas na minha humilde opinião eles deveriam funcionar da mesma maneira (e não me importo se são com estruturas ou modificadores, ou ambos, mas use o mesmo tipo). Então, por favor, por favor 🙏🏻, se você é um engenheiro da Apple trabalhando no SwiftUI lendo isso, pelo amor à consistência, exponha-os de maneira igual, obrigado.
De qualquer forma, como podemos evitar isso de maneira elegante? A melhor maneira que encontrei é usando uma view .background dentro do conteúdo de um botão. É melhor ver isso no código, então agora nossa MasterView parece assim:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
@State private var isPresented = false
var body: some View {
Button(action: {
self.isPresented = true
}) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
.background(
NavigationLink(destination: EmptyView(), isActive: $isPresented) {
EmptyView()
}
)
}
}
}
Wooooow, ok, parece estranho no início, mas nos atenderá perfeitamente e o mais importante é que funciona! Basicamente, estamos escondendo o NavigationLink na parte de trás do texto do botão, e a maneira como isso funciona é através da propriedade isActive. Quando o botão é pressionado, ele altera isPresented para true, o que aciona o NavigationLink.
Cool! Aprendemos como envolver nossa view em um NavigationView e como implementar NavigationLink de forma que não dependa de nada mais na view, dessa maneira podemos facilmente alterá-lo para apresentar uma modal, por exemplo.
Este é o que fizemos até agora:
protocol Coordenador {
func iniciar()
}
extension Coordenador {
func coordenar(to coordenador: Coordenador) {
coordenador.iniciar()
}
}
final class AppCoordinator: Coordenador {
private weak var janela: UIWindow?
init(janela: UIWindow) {
self.janela = janela
}
func iniciar() {
let coordenador = RootMasterCoordinator(window: janela)
coordenar(to: coordenador)
}
}
protocol MasterCoordinator: Coordenador {}
final class RootMasterCoordinator: MasterCoordinator {
private weak var janela: UIWindow?
init(janela: UIWindow?) {
self.janela = janela
}
func iniciar() {
let view = MasterFactory.make(with: self)
let navigation = NavigationView { view }
let hosting = UIHostingController(rootView: navigation)
janela?.rootViewController = hosting
janela?.makeKeyAndVisible()
}
}
enum MasterFactory {
static func make(with coordenador: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordenador)
let view = MasterView(presenter: presenter)
return view
}
}
struct MasterViewModel {
let data: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordenador: MasterCoordinator
init(coordenador: MasterCoordinator) {
self.coordenador = coordenador
self.viewModel = MasterViewModel(data: Date())
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
@State private var isPresented = false
var body: some View {
Button(action: {
self.isPresented = true
}) {🏁 Conclusão
Aprendemos como configurar uma tela inteira com o padrão MVP, criamos um protocolo base Coordinator e implementamos nossos primeiros dois coordinators. Vimos como envolver nossa view em um NavigationView, e como implementar NavigationLink de forma que não dependa de nada mais na view.
E é isso! Temos aí o final da parte 1 desta série. No próximo post, veremos como extrair aquele NavigationLink do MasterView e colocá-lo em um novo coordinator. Teremos que modificar nosso protocolo base Coordinator, e vamos ver como mudar facilmente de uma navegação push para uma apresentação modal sem tocar na view, vai perder isso? Então corra para a parte 2!
Vá agora para a próxima parte da série (parte 2)!: https://lascorbe.com/posts/2020-04-28-MVPCoordinators-SwiftUI-part2
Obrigado por ler, espero que tenha gostado.
Luis.

