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.

Postado em 5 min Por: Luan Rodrigues

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:

  1. Renderiza o controller UIKit como se fosse uma view SwiftUI qualquer, usando .frame(height: 340) para delimitar seu espaço.
  2. Escuta os sinais que vêm do mundo UIKit via .onReceive — não importa se vieram por delegate ou closure, ambas convergem no NotificationCenter.
  3. 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.