DEV Community

Cover image for Deep Links no iOS: Implementação Nativa com Swift + Flutter (Parte 3)
Cristian Dornelles
Cristian Dornelles

Posted on

Deep Links no iOS: Implementação Nativa com Swift + Flutter (Parte 3)

No post anterior, configuramos o Android para capturar deep links usando Kotlin. Agora é a vez do iOS — e você vai ver como a implementação em Swift pode ficar bastante limpa.

Dando continuidade à série sobre Deep Links no Flutter, agora vamos implementar o fluxo nativo no iOS. Se você ainda não viu os posts anteriores, recomendo começar por aqui: Post 1 — Guia para Iniciantes | Post 2 — Android com Kotlin.


Neste artigo, você vai aprender:

  • Como configurar Info.plist e Associated Domains para deep links.
  • A diferença entre custom schemes e Universal Links no iOS.
  • Como implementar AppDelegate.swift com FlutterMethodChannel e FlutterEventChannel.

Configurando o Info.plist

No iOS, o registro de um custom scheme é feito no Info.plist, por meio da chave CFBundleURLTypes. É aqui que o sistema operacional aprende que links com o scheme fitconnect:// devem abrir este app.

<!-- ios/Runner/Info.plist -->

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLName</key>
        <string>com.fitconnect.app</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>fitconnect</string>
        </array>
    </dict>
</array>
Enter fullscreen mode Exit fullscreen mode

CFBundleURLSchemes é o que de fato registra o scheme. O CFBundleURLName é um identificador interno — por convenção, usa-se o bundle ID reverso. O CFBundleTypeRole como Editor indica que o app não apenas lê o tipo de URL, mas também pode criar links com esse scheme.


Configurando Universal Links

Universal Links exigem um passo a mais: o app precisa declarar quais domínios ele está autorizado a tratar. Essa declaração vai no arquivo Runner.entitlements, sob a chave com.apple.developer.associated-domains.

<!-- ios/Runner/Runner.entitlements -->

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:fitconnect.app</string>
</array>
Enter fullscreen mode Exit fullscreen mode

O prefixo applinks: é obrigatório — é ele que instrui o iOS a tratar o domínio como Universal Link. Sem ele, o sistema ignora a entrada. A verificação bidirecional (o arquivo apple-app-site-association no servidor) será montada no Post 5.


Implementando o AppDelegate.swift

Com a configuração pronta, o iOS entrega os links ao AppDelegate. Diferente do Android, onde a entrada costuma ser centralizada em torno do Intent, no iOS o link pode chegar por caminhos diferentes: no cold start via launchOptions, por custom schemes via open url e por Universal Links via continue userActivity.

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {

    private let CHANNEL = "com.fitconnect.app/deeplink"
    private let EVENT_CHANNEL = "com.fitconnect.app/deeplink_stream"

    private var methodChannel: FlutterMethodChannel?
    private var eventChannel: FlutterEventChannel?
    private var eventSink: FlutterEventSink?

    private var initialLink: String?
    private var lastProcessedLink: String?

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        let controller = window?.rootViewController as! FlutterViewController
        setupChannels(controller: controller)

        // Captura deep link inicial (cold start)
        if let url = launchOptions?[.url] as? URL {
            handleDeepLink(url: url, isInitial: true)
        } else if
            let userActivityDictionary = launchOptions?[.userActivityDictionary] as? [AnyHashable: Any] {
            for value in userActivityDictionary.values {
                if let userActivity = value as? NSUserActivity,
                   userActivity.activityType == NSUserActivityTypeBrowsingWeb,
                   let url = userActivity.webpageURL {
                    handleDeepLink(url: url, isInitial: true)
                    break
                }
            }
        }

        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func setupChannels(controller: FlutterViewController) {
        // MethodChannel: Flutter pede o deep link inicial
        methodChannel = FlutterMethodChannel(
            name: CHANNEL,
            binaryMessenger: controller.binaryMessenger
        )
        methodChannel?.setMethodCallHandler { [weak self] call, result in
            if call.method == "getInitialLink" {
                result(self?.initialLink)
            } else {
                result(FlutterMethodNotImplemented)
            }
        }

        // EventChannel: stream de deep links em tempo real enquanto o app está em execução
        eventChannel = FlutterEventChannel(
            name: EVENT_CHANNEL,
            binaryMessenger: controller.binaryMessenger
        )
        eventChannel?.setStreamHandler(self)
    }

    // Custom URL Scheme
    override func application(
        _ app: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey : Any] = [:]
    ) -> Bool {
        handleDeepLink(url: url, isInitial: false)
        return super.application(app, open: url, options: options)
    }

    // Universal Links
    override func application(
        _ application: UIApplication,
        continue userActivity: NSUserActivity,
        restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
    ) -> Bool {
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let url = userActivity.webpageURL else {
            return false
        }

        handleDeepLink(url: url, isInitial: false)
        return true
    }

    private func handleDeepLink(url: URL, isInitial: Bool) {
        let urlString = url.absoluteString

        // Evita processar o mesmo link duas vezes
        if urlString == lastProcessedLink { return }

        lastProcessedLink = urlString
        NSLog("Deep link: \(urlString)")

        if isInitial {
            initialLink = urlString
        } else {
            eventSink?(urlString)
        }
    }
}

extension AppDelegate: FlutterStreamHandler {
    func onListen(
        withArguments arguments: Any?,
        eventSink events: @escaping FlutterEventSink
    ) -> FlutterError? {
        self.eventSink = events
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

Por que dois métodos de entrada?

  • open url: chamado quando o link usa um custom scheme (fitconnect://). O iOS já sabe que é para abrir o app porque o scheme foi registrado no Info.plist.
  • continue userActivity: chamado para Universal Links (HTTPS). O iOS só chega aqui após confirmar que o domínio está no Runner.entitlements e que o servidor possui o arquivo apple-app-site-association. Se qualquer verificação falhar, o link abre no Safari como fallback — sem erros, sem seletor.

Diagrama de fluxo no iOS

Por que o AppDelegate adota FlutterStreamHandler via extension?

Em vez de criar uma classe separada para o stream handler, o próprio AppDelegate implementa o protocolo FlutterStreamHandler via extension. Isso mantém o código coeso: tudo relacionado à entrega de links fica no mesmo lugar. O onListen guarda a referência para o eventSink, e o onCancel a limpa quando o Flutter para de ouvir.


Testando no iOS

Para testar sem precisar de um device físico, o simulador do Xcode expõe o comando xcrun simctl openurl.

# Custom scheme — não requer verificação de domínio
xcrun simctl openurl booted \
  "fitconnect://fitconnect.app/signup?referralCode=TRAINER1234567890123"
Enter fullscreen mode Exit fullscreen mode
# Universal Link (HTTPS)
xcrun simctl openurl booted \
  "https://fitconnect.app/signup?referralCode=TRAINER1234567890123"
Enter fullscreen mode Exit fullscreen mode

Atenção: testar Universal Links no iOS pode ser enganoso. Em muitos casos, abrir a URL diretamente no Safari não reproduz o comportamento real de associação com o app. Para validar corretamente, prefira tocar no link a partir de apps como Mail, Notas ou iMessage.


Android vs iOS: comparação rápida

Android vs iOS: comparação rápida

A lógica é a mesma nas duas plataformas — o que muda são as APIs e os arquivos de configuração. O handleDeepLink no Swift é o equivalente direto do handleIntent no Kotlin.


O que construímos até aqui

Ao final desta etapa, você já tem:

  • O Info.plist configurado para custom schemes.
  • O Runner.entitlements com o domínio para Universal Links.
  • O AppDelegate.swift com FlutterMethodChannel (link inicial) e FlutterEventChannel (stream em tempo real).
  • Clareza sobre a diferença entre open url e continue userActivity e quando cada um é chamado.
  • Comandos xcrun simctl prontos para testar no simulador.

Este é o terceiro de 9 posts da série. Com Android e iOS prontos, temos o código nativo completo das duas plataformas. Se você já passou por algum problema com Universal Links — domínio não verificado, link abrindo no Safari em vez do app — conta nos comentários, esse tipo de detalhe vai direto para os próximos artigos.

No próximo post, conectamos tudo no Flutter: o DeepLinkService em Dart que fala com o código nativo via MethodChannel e EventChannel.


Código completo disponível no repositório: FitConnect no GitHub


Se esse conteúdo te ajudou, deixa um clap 👏 e salva o post — isso me ajuda a continuar a série.

Já configurou Universal Links antes? Conta nos comentários o maior obstáculo — vou usar isso nos próximos artigos.


Tags: Flutter, iOS, Swift, Universal Links, Deep Links

Top comments (0)