PoC: Migrando UIKit → SwiftUI com Container-Coordinator
Como encapsular um UIViewController legado dentro de uma tela SwiftUI sem acoplar lifecycle, com comunicação bidirecional via Coordinator e NotificationCenter — e sem reescrever uma linha do controller.
O projeto
Um app mínimo que demonstra, na prática, o padrão descrito no artigo. Vamos construir um fluxo de pagamento: um UIViewController legado (UIKit puro) encapsulado dentro de uma tela SwiftUI, com comunicação bidirecional funcionando sem acoplar lifecycle.
Estrutura do projeto
ContainerCoordinatorPoC/
├── App/
│ └── ContainerCoordinatorApp.swift # Entry point SwiftUI
├── Legacy/
│ └── LegacyPaymentViewController.swift # Controller UIKit "legado"
├── Bridge/
│ ├── UIKitContainer.swift # Wrapper genérico reutilizável
│ └── PaymentBridge.swift # Bridge específica do domínio
└── Views/
└── PaymentScreen.swift # Tela SwiftUI que consome tudo
Passo 1 — O controller legado (não mexemos nele)
Antes de qualquer coisa: o controller legado é o código que já existe e funciona. A premissa do padrão é não tocá-lo. Se você precisa modificar o controller para migrar, então você não está migrando — está reescrevendo. E reescrever é exatamente o que queremos evitar.
// Legacy/LegacyPaymentViewController.swift
import UIKit
// Delegate protocol — forma clássica de comunicação do UIKit
protocol PaymentControllerDelegate: AnyObject {
func paymentDidSucceed(amount: Decimal)
func paymentDidFail(error: Error)
}
final class LegacyPaymentViewController: UIViewController {
// MARK: - Estado interno (privado — não expomos para SwiftUI)
private var amount: Decimal = 0
private let maxAmount: Decimal = 999_999.99
// MARK: - Callback para o mundo exterior
weak var delegate: PaymentControllerDelegate?
var onComplete: ((Result<Decimal, Error>) -> Void)?
// MARK: - UI (UIKit puro — não usamos SwiftUI aqui propositalmente)
private lazy var amountLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 32, weight: .bold)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var stepperLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 18)
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var payButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Confirmar Pagamento", for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
button.backgroundColor = UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.43, green: 0.29, blue: 1.0, alpha: 1.0)
: UIColor(red: 0.35, green: 0.22, blue: 0.95, alpha: 1.0)
}
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 12
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(payTapped), for: .touchUpInside)
return button
}()
private lazy var incrementButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("+ R$ 50", for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(incrementTapped), for: .touchUpInside)
return button
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupLayout()
updateDisplay()
print("📌 [LegacyVC] viewDidLoad chamado")
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
print("📌 [LegacyVC] viewWillDisappear — liberando recursos")
}
// MARK: - Setup
private func setupLayout() {
view.addSubview(amountLabel)
view.addSubview(stepperLabel)
view.addSubview(incrementButton)
view.addSubview(payButton)
NSLayoutConstraint.activate([
amountLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 60),
amountLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stepperLabel.topAnchor.constraint(equalTo: amountLabel.bottomAnchor, constant: 16),
stepperLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
incrementButton.topAnchor.constraint(equalTo: stepperLabel.bottomAnchor, constant: 24),
incrementButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
payButton.topAnchor.constraint(equalTo: incrementButton.bottomAnchor, constant: 32),
payButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
payButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
payButton.heightAnchor.constraint(equalToConstant: 54),
])
}
private func updateDisplay() {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: "pt_BR")
amountLabel.text = formatter.string(from: NSDecimalNumber(decimal: amount))
stepperLabel.text = "Toque para adicionar R$ 50 ao valor"
}
// MARK: - Actions
@objc private func incrementTapped() {
amount += 50
if amount > maxAmount { amount = maxAmount }
updateDisplay()
}
@objc private func payTapped() {
print("📌 [LegacyVC] Pagamento confirmado: R$ \(amount)")
// Comunicação via delegate (padrão clássico UIKit)
delegate?.paymentDidSucceed(amount: amount)
// Comunicação via closure (alternativa comum)
onComplete?(.success(amount))
}
}
Por que existe assim?
Se você abrir qualquer projeto iOS com mais de dois anos, vai encontrar algo parecido: um UIViewController com delegate, com closures, com UI construída em código ou storyboard. Não é bonito pelo padrão SwiftUI de hoje, mas funciona. E funciona há anos.
Aqui está o primeiro princípio que Feynman seguiria: antes de mudar algo que funciona, entenda por que ele funciona. O delegate existe porque UIKit não tinha binding reativo nativo. A closure existe porque adicionar um segundo canal de comunicação era mais fácil que refatorar. Cada decisão técnica, por pior que pareça hoje, teve um motivo na época.
Passo 2 — O container genérico
Agora construímos a peça central: um wrapper reutilizável que serve para qualquer UIViewController. Esta é a única parte que você escreve uma vez e reutiliza em todas as migrações.
// Bridge/UIKitContainer.swift
import SwiftUI
import UIKit
/// Container genérico que encapsula qualquer UIViewController em SwiftUI.
///
/// O nome é "Container" porque ele CONTÉM o controller sem modificá-lo.
/// O nome é "Coordinator" porque o objeto interno COORDENA a comunicação.
///
/// Pense assim: o Container é uma caixa transparente. O controller entra
/// na caixa sem saber que está dentro de uma caixa. O Coordinator é o
/// único que sabe que a caixa existe e traduz sinais entre os dois lados.
struct UIKitContainer<VCType: UIViewController>: UIViewControllerRepresentable {
// MARK: - Configuration closures (declaradas, não executadas aqui)
let makeVC: () -> VCType
let configure: (VCType, GenericCoordinator<VCType>) -> Void
let onUpdate: ((VCType, GenericCoordinator<VCType>) -> Void)?
// MARK: - Init
init(
make: @escaping () -> VCType,
configure: @escaping (VCType, GenericCoordinator<VCType>) -> Void,
onUpdate: ((VCType, GenericCoordinator<VCType>) -> Void)? = nil
) {
self.makeVC = make
self.configure = configure
self.onUpdate = onUpdate
}
// MARK: - UIViewControllerRepresentable
func makeUIViewController(context: Context) -> VCType {
print("🔄 [Container] makeUIViewController — criando controller")
let vc = makeVC()
let coordinator = context.coordinator
coordinator.viewController = vc
configure(vc, coordinator)
return vc
}
func updateUIViewController(_ uiViewController: VCType, context: Context) {
print("🔄 [Container] updateUIViewController — propagando mudanças")
context.coordinator.handleUpdate(uiViewController)
}
static func dismantleUIViewController(
_ uiViewController: VCType,
coordinator: GenericCoordinator<VCType>
) {
print("🧹 [Container] dismantle — limpando coordinator")
coordinator.cleanup()
}
// MARK: - Coordinator
func makeCoordinator() -> GenericCoordinator<VCType> {
print("🔄 [Container] makeCoordinator — criando coordinator")
return GenericCoordinator(onUpdate: onUpdate)
}
}
/// Coordinator genérico. Ele é o "tradutor" entre UIKit e SwiftUI.
///
/// Se o Container é a caixa, o Coordinator é o intérprete dentro da caixa.
/// Ele segura a referência fraca ao controller e executa os handlers
/// configurados pela view SwiftUI.
final class GenericCoordinator<VCType: UIViewController>: NSObject {
// weak porque o controller é dono da própria vida.
// Se segurássemos forte, criaríamos um retain cycle:
// Container → Coordinator → ViewController → ... → Container
weak var viewController: VCType?
private let updateHandler: ((VCType, GenericCoordinator<VCType>) -> Void)?
// Aqui armazenamos quaisquer observadores que precisamos limpar.
private var observers: [NSObjectProtocol] = []
private var cleanupBlocks: [() -> Void] = []
init(onUpdate: ((VCType, GenericCoordinator<VCType>) -> Void)?) {
self.updateHandler = onUpdate
super.init()
}
func handleUpdate(_ vc: VCType) {
updateHandler?(vc, self)
}
func register(observer: NSObjectProtocol) {
observers.append(observer)
}
func registerCleanup(_ block: @escaping () -> Void) {
cleanupBlocks.append(block)
}
func cleanup() {
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
cleanupBlocks.forEach { $0() }
cleanupBlocks.removeAll()
viewController = nil
}
}
Por que o Coordinator é uma classe e não uma struct?
Esta é uma pergunta que eu me fiz quando vi o padrão pela primeira vez. A resposta é simples: o Coordinator precisa sobreviver entre chamadas de makeUIViewController e updateUIViewController. Se fosse uma struct, cada chamada criaria uma cópia nova, e perderíamos todas as referências registradas. Classes vivem no heap, persistem, e permitem que weak var viewController funcione (struct não suporta weak).
Por que weak var viewController?
Imagine que você está em um quarto com uma porta. O quarto (Coordinator) tem uma porta (referência) para o corredor (ViewController). Se o prédio decide demolir o corredor, o quarto não deveria impedir a demolição. weak significa “eu sei onde a porta está, mas não sou dono do corredor”. Se o corredor some, a porta aponta para nil. Sem weak, o Coordinator manteria o controller vivo para sempre — um leak de memória silencioso.
Passo 3 — A bridge específica do domínio
O container genérico não sabe nada sobre pagamentos. Ele só sabe encapsular controllers. A bridge é onde conectamos o domínio específico (pagamento) ao mecanismo genérico (container).
// Bridge/PaymentBridge.swift
import Foundation
import UIKit
/// Extensão de Notification.Name para comunicação desacoplada.
///
/// Por que NotificationCenter e não um delegate direto?
///
/// Porque o SwiftUI é declarativo. Ele não tem um objeto que persiste
/// para receber delegates no sentido clássico. Se criarmos um delegate
/// object dentro da View struct, ele morre a cada re-render.
///
/// NotificationCenter é o "rádio": qualquer um pode escutar,
/// qualquer um pode transmitir, ninguém precisa conhecer o outro.
extension Notification.Name {
static let paymentSucceeded = Notification.Name("paymentSucceeded")
static let paymentFailed = Notification.Name("paymentFailed")
static let requestPaymentReset = Notification.Name("requestPaymentReset")
}
/// Ponte específica para o fluxo de pagamento.
/// Esta é a função que a view SwiftUI chama para obter o container configurado.
enum PaymentBridge {
static func makeContainer() -> some View {
UIKitContainer(
make: {
// Fábrica: cria o controller LEGADO sem modificações
print("🏭 [Bridge] Criando LegacyPaymentViewController")
return LegacyPaymentViewController()
},
configure: { vc, coordinator in
print("⚙️ [Bridge] Configurando comunicação")
// Conexão 1: Controller → SwiftUI (via delegate)
vc.delegate = PaymentDelegateAdapter(coordinator: coordinator)
// Conexão 2: Controller → SwiftUI (via closure alternativa)
vc.onComplete = { result in
switch result {
case .success(let amount):
NotificationCenter.default.post(
name: .paymentSucceeded,
object: nil,
userInfo: ["amount": NSDecimalNumber(decimal: amount)]
)
case .failure(let error):
NotificationCenter.default.post(
name: .paymentFailed,
object: nil,
userInfo: ["error": error]
)
}
}
// Conexão 3: SwiftUI → Controller (via NotificationCenter)
let observer = NotificationCenter.default.addObserver(
forName: .requestPaymentReset,
object: nil,
queue: .main
) { [weak vc] _ in
print("🔁 [Bridge] Reset solicitado pelo SwiftUI")
// Aqui o controller legado seria resetado.
// No PoC, apenas logamos pois o controller não tem método público de reset.
// Em código real: vc?.reset()
}
// Registra para limpeza automática no dismantle
coordinator.register(observer: observer)
coordinator.registerCleanup {
vc.delegate = nil
vc.onComplete = nil
}
},
onUpdate: { vc, coordinator in
// Este handler roda toda vez que a View SwiftUI é atualizada.
// Aqui poderíamos sincronizar estado do SwiftUI para o UIKit.
// No PoC, não há estado para sincronizar — deixamos vazio.
print("⬆️ [Bridge] Update recebido (sem ação necessária)")
}
)
}
}
/// Adapter que converte o delegate do UIKit em notificações do NotificationCenter.
///
/// Por que um adapter separado?
/// Porque o controller legado define o protocolo PaymentControllerDelegate.
/// O coordinator não implementa esse protocolo diretamente porque ele é
/// genérico (não conhece o protocolo específico). O adapter faz a ponte.
final class PaymentDelegateAdapter: NSObject, PaymentControllerDelegate {
private weak var coordinator: GenericCoordinator<LegacyPaymentViewController>?
init(coordinator: GenericCoordinator<LegacyPaymentViewController>) {
self.coordinator = coordinator
super.init()
}
func paymentDidSucceed(amount: Decimal) {
NotificationCenter.default.post(
name: .paymentSucceeded,
object: nil,
userInfo: ["amount": NSDecimalNumber(decimal: amount)]
)
}
func paymentDidFail(error: Error) {
NotificationCenter.default.post(
name: .paymentFailed,
object: nil,
userInfo: ["error": error]
)
}
}
Por que um adapter separado?
Imagine que você tem um rádio AM e quer conectar um fone Bluetooth. Eles não têm o mesmo conector. Você precisa de um adaptador. O PaymentControllerDelegate é o conector do UIKit. O NotificationCenter é o conector do SwiftUI. O PaymentDelegateAdapter é o adaptador entre os dois.
O Coordinator é genérico — ele não sabe que protocolo de delegate seu controller usa. Tentar fazer o Coordinator implementar todos os protocols possíveis seria absurdo. Então isolamos essa responsabilidade no adapter.
Passo 4 — A tela SwiftUI
Agora a parte que o usuário final vê. É onde tudo se junta.
// Views/PaymentScreen.swift
import SwiftUI
struct PaymentScreen: View {
@State private var paymentHistory: [String] = []
@State private var lastAmount: String?
@State private var showSuccess = false
@State private var showError = false
@State private var errorMessage = ""
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// --- Cabeçalho em SwiftUI ---
headerSection
// --- Controller UIKit encapsulado ---
// É aqui que a mágica acontece.
// PaymentBridge.makeContainer() retorna uma View,
// então ela se integra ao SwiftUI como qualquer outra.
PaymentBridge.makeContainer()
.frame(height: 340)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
.padding(.horizontal)
// --- Histórico de pagamentos (SwiftUI puro) ---
historySection
Spacer()
}
.navigationTitle("Pagamento")
.navigationBarTitleDisplayMode(.inline)
}
// Escuta os sinais que o controller UIKit envia via NotificationCenter
.onReceive(NotificationCenter.default.publisher(for: .paymentSucceeded)) { note in
if let amount = note.userInfo?["amount"] as? NSDecimalNumber {
let formatted = formatCurrency(amount.decimalValue)
lastAmount = formatted
paymentHistory.insert("✅ Pagamento: \(formatted)", at: 0)
showSuccess = true
print("📱 [SwiftUI] Recebeu sucesso: \(formatted)")
}
}
.onReceive(NotificationCenter.default.publisher(for: .paymentFailed)) { note in
if let error = note.userInfo?["error"] as? Error {
errorMessage = error.localizedDescription
showError = true
print("📱 [SwiftUI] Recebeu erro: \(error.localizedDescription)")
}
}
.alert("Pagamento Confirmado! 🎉", isPresented: $showSuccess) {
Button("OK") { }
} message: {
if let amount = lastAmount {
Text("Valor processado: \(amount)")
}
}
.alert("Erro no Pagamento", isPresented: $showError) {
Button("Entendido") { }
} message: {
Text(errorMessage)
}
}
// MARK: - Subviews
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: "creditcard.fill")
.font(.system(size: 44))
.foregroundStyle(.purple)
Text("Fluxo de Pagamento Híbrido")
.font(.title2.bold())
Text("UIKit Controller dentro de SwiftUI")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
private var historySection: some View {
VStack(alignment: .leading, spacing: 12) {
if paymentHistory.isEmpty {
Text("Nenhum pagamento ainda.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
} else {
Text("Histórico")
.font(.headline)
ForEach(paymentHistory, id: \.self) { entry in
Text(entry)
.font(.callout.monospacedDigit())
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding(.horizontal)
}
// MARK: - Helpers
private func formatCurrency(_ value: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: "pt_BR")
return formatter.string(from: value as NSDecimalNumber) ?? "\(value)"
}
}
O que esta tela faz?
Três coisas, nesta ordem:
- Renderiza o controller UIKit como se fosse uma view SwiftUI qualquer, usando
.frame(height: 340)para delimitar seu espaço. - Escuta os sinais que vêm do mundo UIKit via
.onReceive— não importa se vieram por delegate ou closure, ambas convergem no NotificationCenter. - Responde atualizando estado SwiftUI (
@State), que re-renderiza a UI declarativamente.
O controller não sabe que está dentro de SwiftUI. A view SwiftUI não sabe que está consumindo UIKit. O Coordinator sabe das duas coisas, e é o único que precisa.
Passo 5 — O entry point
// App/ContainerCoordinatorApp.swift
import SwiftUI
@main
struct ContainerCoordinatorApp: App {
var body: some Scene {
WindowGroup {
PaymentScreen()
}
}
}
Era isso. Sem configuração especial, sem injeção manual, sem nada. O padrão se resolve sozinho porque as responsabilidades estão isoladas.
Passo 6 — Como testar e observar o ciclo de vida
Quando você rodar o app, abra o Console. Você verá algo assim:
🔄 [Container] makeCoordinator — criando coordinator
🔄 [Container] makeUIViewController — criando controller
🏭 [Bridge] Criando LegacyPaymentViewController
⚙️ [Bridge] Configurando comunicação
📌 [LegacyVC] viewDidLoad chamado
Toque em ”+ R$ 50” algumas vezes. Depois toque em “Confirmar Pagamento”:
📌 [LegacyVC] Pagamento confirmado: R$ 150
📱 [SwiftUI] Recebeu sucesso: R$ 150,00
Se você fizer swipe back (ou navegar para fora) numa NavigationStack:
📌 [LegacyVC] viewWillDisappear — liberando recursos
🧹 [Container] dismantle — limpando coordinator
Esse último log é o mais importante. Se você não vê “dismantle”, seus observers não foram limpos. E observers não limpos significam leaks. E leaks significam que você voltou à estaca zero do problema que o padrão tenta resolver.
README.md para o GitHub
# Migrando de UIKit para SwiftUI sem rewriting: o padrão Container-Coordinator
PoC demonstrando como encapsular `UIViewController` em `UIViewControllerRepresentable`
sem acoplar lifecycle, com comunicação bidirecional via Coordinator + NotificationCenter.
## Estrutura
| Arquivo | Responsabilidade |
|---------|-----------------|
| `LegacyPaymentViewController` | Controller UIKit legado (não modificado) |
| `UIKitContainer` | Wrapper genérico reutilizável |
| `GenericCoordinator` | Coordenação de lifecycle e comunicação |
| `PaymentBridge` | Configuração específica do domínio |
| `PaymentScreen` | Tela SwiftUI que consome o container |
## Como rodar
1. Abra o projeto no Xcode 15+
2. Build & Run (iOS 17+ recomendado)
3. Abra o Console para ver os logs de lifecycle
4. Interaja: incremente o valor, confirme o pagamento, observe o histórico atualizar
## Lições-chave
- `makeUIViewController` pode ser chamado mais de uma vez → não faça side effects lá
- `weak var` no coordinator previne retain cycles
- `dismantleUIViewController` é obrigatório para limpeza de observers
- NotificationCenter desacopla comunicação sem custo de performance para UI
- O adapter isola a adaptação de protocolo do coordinator genérico
## Requisitos
- iOS 16.0+
- Swift 5.9+
- Xcode 15+
## Licença
MIT
O que eu aprendi errado (para você não repetir)
Erro 1: No primeiro projeto que migrei, eu criava uma instância nova do coordinator dentro de makeUIViewController em vez de usar context.coordinator. Resultado: os observers registrados eram perdidos a cada re-renderização, e o controller parava de responder após a primeira interação.
Erro 2: Segurei o controller com referência forte no coordinator “para garantir”. O resultado foi que o controller nunca era desalocado. O Instruments mostrava 40MB de leak acumulado após 20 navegações.
Erro 3: Tentei usar @Binding passado diretamente para o controller. Funcionou até o momento em que a view SwiftUI re-renderizou e o binding disparou updateUIViewController que disparou uma mudança no controller que disparou um callback que re-renderizou a view. Loop infinito. Crash.
Cada um desses erros me ensinou uma coisa: o Container precisa ser burro (só encapsula), o Coordinator precisa ser persistente (sobrevive entre updates), e a comunicação precisa ser indireta (NotificationCenter), porque indireto significa que nenhum lado conhece o outro — e quando nenhum lado conhece o outro, nenhum lado pode quebrar o outro.
Isso é o padrão. Simples depois que você entende. Difícil até então.