Los enlaces Markdown en SwiftUI pueden hacer mucho más que abrir una web
Arturo Rivas Arias
SwiftUI permite incluir enlaces dentro de un Text utilizando la sintaxis habitual de Markdown. A primera vista parece una forma cómoda de insertar una dirección web en mitad de una frase, pero el mecanismo que hay detrás es bastante más flexible: el destino sigue siendo una URL, no necesariamente una página web, y la aplicación puede interceptarla antes de que llegue la llamada al sistema.
Esta combinación permite convertir una parte de un texto en navegación interna, registrar una interacción, abrir una sección concreta de la aplicación o ejecutar una acción. Todo ello sin dividir la frase en varias vistas ni recurrir a gestos aplicados sobre rangos de caracteres.
Enlaces dentro de un Text
Desde iOS 15, macOS 12 y el resto de sistemas de la misma generación, Text interpreta varios elementos inline de Markdown, entre ellos la negrita, la cursiva, el código y los enlaces.
Text("Consulta la [guía de migración](https://developer.apple.com/documentation/swiftui).")
SwiftUI representa únicamente el fragmento entre corchetes como enlace interactivo. El resultado se integra en la misma línea, conserva la adaptación a Dynamic Type y evita tener que construir manualmente un HStack con varios textos.
Esto no sustituye a Link. Cuando toda la vista representa una única salida a una dirección, Link continúa siendo la opción más explícita:
Link(
"Abrir documentación",
destination: URL(string: "https://developer.apple.com/documentation/swiftui")!
)
La ventaja de Markdown aparece cuando el enlace forma parte de una frase o cuando un mismo texto contiene varios destinos.
Una URL no tiene por qué apuntar a una web
Aunque en Swift el tipo se llama URL, su estructura no está limitada a los protocolos http y https. Una dirección comienza por un esquema que define cómo debe interpretarse: https, mailto, tel, file o cualquier otro esquema que la aplicación sea capaz de reconocer.
También podemos expresar intenciones internas mediante un esquema propio:
Text("Puedes [reintentar la copia](gallery-action://backup/retry).")
En este caso, gallery-action es el esquema, backup es el host y /retry es el path. No existe ningún servidor al que conectarse. La URL funciona como una representación estructurada de la intención «reintentar la copia».
Si ese enlace se entregara directamente al sistema, probablemente no habría ninguna aplicación capaz de abrirlo. La pieza que lo convierte en una acción útil es OpenURLAction.
openURL es el punto de intercepción
SwiftUI expone en el entorno un valor llamado openURL. Link, los enlaces de un Text y un Text construido a partir de un AttributedString consultan esa acción cuando el usuario pulsa uno de sus enlaces.
El comportamiento predeterminado delega la URL en el sistema. Por eso una dirección https suele abrir el navegador y un Universal Link puede abrir directamente su aplicación asociada. Sin embargo, podemos sustituir la acción para una vista y todos sus descendientes:
Text("[Reintentar](gallery-action://backup/retry)")
.environment(\.openURL, OpenURLAction { url in
print("Enlace pulsado: \(url)")
return .handled
})
El cierre recibe el URL y devuelve un OpenURLAction.Result. Ese resultado indica a SwiftUI qué ha ocurrido:
.handledcomunica que la aplicación ya se ha ocupado del enlace y evita que el sistema intente abrirlo..discardedrechaza el enlace. También evita la apertura, pero informa de que la acción no fue aceptada..systemActionpide al sistema que procese la URL original..systemAction(newURL)delega una dirección diferente. Puede utilizarse, por ejemplo, para normalizar un destino antes de abrirlo.
Esta API también ofrece un lugar centralizado para instrumentar enlaces externos sin reemplazar su comportamiento:
.environment(\.openURL, OpenURLAction { url in
analytics.track(.externalLinkOpened(host: url.host))
return .systemAction
})
Convertir enlaces Markdown en navegación y acciones
El siguiente ejemplo muestra un texto de ayuda para una aplicación de fotografía. Uno de sus enlaces solicita repetir una copia de seguridad, otro navega hacia los ajustes de almacenamiento y el tercero abre una página externa.
import SwiftUI
private enum Destination: Hashable {
case storageSettings
}
struct BackupHelpView: View {
@State private var path: [Destination] = []
@State private var showRetryConfirmation = false
var body: some View {
NavigationStack(path: $path) {
Text("""
La última copia no se ha completado. Puedes \
[reintentarlo](gallery-action://backup/retry), revisar los \
[ajustes de almacenamiento](gallery-action://settings/storage) \
o consultar el [estado del servicio](https://status.example.com).
""")
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .storageSettings:
StorageSettingsView()
}
}
.alert("¿Reintentar la copia?", isPresented: $showRetryConfirmation) {
Button("Cancelar", role: .cancel) {}
Button("Reintentar") {
startBackup()
}
}
}
.environment(\.openURL, OpenURLAction(handler: handle))
}
private func handle(_ url: URL) -> OpenURLAction.Result {
if url.scheme == "gallery-action" {
return handleInternalURL(url)
}
if url.scheme == "https" {
return .systemAction
}
return .discarded
}
private func handleInternalURL(_ url: URL) -> OpenURLAction.Result {
switch (url.host, url.path) {
case ("backup", "/retry"):
showRetryConfirmation = true
return .handled
case ("settings", "/storage"):
path.append(.storageSettings)
return .handled
default:
return .discarded
}
}
private func startBackup() {
// Inicia el proceso real de copia de seguridad.
}
}
La política del ejemplo es deliberadamente restrictiva. Solo reconoce dos acciones internas concretas, permite delegar direcciones https y descarta cualquier otro esquema. El texto describe las acciones, pero la aplicación conserva el control sobre lo que realmente puede ejecutarse.
El esquema personalizado no tiene que declararse en Info.plist mientras la URL sea consumida por este OpenURLAction. Esa configuración solo resulta necesaria si queremos que otras aplicaciones puedan abrir la nuestra mediante ese esquema o si delegamos la URL al sistema.
No conviertas cada URL en lógica de negocio
Puede resultar tentador añadir un switch enorme dentro de la vista, pero los enlaces internos funcionan mejor cuando se traducen a un conjunto cerrado de intenciones. De esa forma, la representación textual del URL queda separada de la navegación y de los efectos reales.
enum HelpAction: Equatable {
case retryBackup
case openStorageSettings
init?(url: URL) {
guard url.scheme == "gallery-action" else {
return nil
}
switch (url.host, url.path) {
case ("backup", "/retry"):
self = .retryBackup
case ("settings", "/storage"):
self = .openStorageSettings
default:
return nil
}
}
}
Un router, coordinador o modelo observable puede recibir después ese HelpAction. Este diseño facilita los tests, impide que el contenido construya comandos arbitrarios y permite cambiar la estructura de navegación sin reescribir todos los textos.
También conviene elegir un esquema específico de la aplicación en lugar de reutilizar uno estándar con otro significado. Nombres como gallery-action o una variante basada en el identificador del bundle reducen colisiones y hacen explícito que se trata de una intención interna.
Qué ocurre cuando el Markdown es dinámico
Los ejemplos anteriores funcionan directamente porque el argumento de Text es un literal y SwiftUI lo trata como contenido localizable con Markdown. Una cadena obtenida de una API, un fichero o una base de datos no se interpreta automáticamente de la misma forma.
let source = "Pulsa [aquí](gallery-action://backup/retry)"
Text(source) // Muestra también los corchetes y paréntesis
Para contenido dinámico debemos analizar primero la cadena con AttributedString y entregar el resultado a Text:
struct MarkdownText: View {
let source: String
var body: some View {
if let attributed = try? AttributedString(
markdown: source,
options: .init(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
) {
Text(attributed)
} else {
Text(source)
}
}
}
Los atributos de enlace que produce Foundation siguen llegando al mismo openURL del entorno cuando se muestran en un Text. Esto permite utilizar una única estrategia tanto para cadenas incluidas en el código como para contenido descargado.
Text está especialmente orientado al Markdown inline. Si el contenido contiene títulos, listas, bloques de código o una estructura completa de documento, lo adecuado es disponer de un renderizador que transforme cada bloque en su vista SwiftUI correspondiente. AttributedString resuelve los atributos del texto, pero no sustituye por sí solo la composición visual de un documento completo.
Contenido remoto y seguridad
Un enlace capaz de iniciar una acción debe tratarse como entrada no confiable cuando el Markdown procede de un servidor, de un CMS o del usuario. La aplicación no debería convertir nombres de tipos, selectores o comandos incluidos en la URL en llamadas dinámicas.
Una estrategia segura se apoya en varias reglas sencillas:
- Aceptar únicamente una lista cerrada de combinaciones de esquema, host y path.
- Traducir cada URL reconocido a un
enumantes de ejecutar lógica de negocio. - Delegar en el sistema solo los esquemas realmente permitidos, normalmente
https. - Devolver
.discardedpara cualquier dirección desconocida o mal formada. - Pedir confirmación antes de acciones sensibles o destructivas.
De esta forma, el contenido puede decidir dónde aparece un enlace, pero nunca ampliar por sí mismo las capacidades de la aplicación.
openURL no es lo mismo que onOpenURL
Los nombres son parecidos, pero resuelven direcciones opuestas. El valor openURL del entorno controla qué ocurre cuando una vista intenta abrir un enlace. El modificador onOpenURL(perform:), en cambio, recibe URLs que llegan desde fuera de la aplicación, como un Universal Link o un esquema registrado.
// Salida: una vista intenta abrir una URL
@Environment(\.openURL) private var openURL
// Entrada: el sistema entrega una URL a la aplicación
.onOpenURL { incomingURL in
router.handleIncomingURL(incomingURL)
}
Ambos mecanismos pueden compartir el mismo router, pero no son intercambiables. Un enlace Markdown pulsa openURL; no llama automáticamente al onOpenURL de la propia aplicación.
Cuándo utilizar cada alternativa
Link, Button y un enlace Markdown pueden terminar provocando una navegación, pero comunican intenciones distintas:
- Utiliza
Linkcuando un componente completo representa un destino. - Utiliza
Buttoncuando la interfaz presenta una acción independiente, especialmente si modifica datos o tiene consecuencias importantes. - Utiliza Markdown cuando el destino o la acción forma parte natural de un texto, como una ayuda contextual, unas condiciones de uso o contenido generado por un CMS.
Convertir un enlace en una acción no significa que todas las acciones deban parecer enlaces. Una operación destructiva escondida en mitad de un párrafo sigue siendo una mala decisión de interfaz aunque la implementación sea técnicamente posible.
Conclusión
El soporte de Markdown de SwiftUI no se limita al formato visual. Los enlaces se apoyan en URL, AttributedString y el valor openURL del entorno, tres piezas que permiten tratar una parte interactiva del texto como una intención estructurada.
Con un esquema propio, un conjunto cerrado de rutas y un OpenURLAction bien delimitado, el mismo texto puede combinar navegación interna, acciones de la aplicación y enlaces externos. El resultado conserva la sencillez declarativa de SwiftUI sin renunciar a una arquitectura controlada, confiable y robusta.