Por qué el WebView nativo de SwiftUI lo cambia todo
Si llevas tiempo desarrollando para iOS, seguro conoces la historia: querías mostrar contenido web en tu app SwiftUI y te tocaba envolver WKWebView con UIViewRepresentable. Funcionaba, sí. Pero era incómodo. Coordinadores, delegados de navegación, sincronizar estado a mano... y el resultado nunca se sentía realmente "nativo" dentro del paradigma declarativo de SwiftUI.
Bueno, eso se acabó.
Con iOS 26, Apple introdujo WebView y WebPage directamente en el módulo de WebKit para SwiftUI. Y no es un wrapper disfrazado. Es un componente de primera clase, diseñado desde cero para funcionar con el sistema de estado reactivo de SwiftUI, el ciclo de vida declarativo y el modelo de concurrencia de Swift.
En esta guía vamos a recorrer cada aspecto del nuevo WebView: desde lo más básico hasta la comunicación bidireccional con JavaScript, el manejo de esquemas de URL personalizados con URLSchemeHandler, el control de navegación con NavigationDeciding, y las mejores prácticas de seguridad. Todo con código funcional que puedes copiar directo a tu proyecto.
Requisitos previos y configuración del proyecto
Antes de escribir una sola línea de código, asegúrate de tener lo siguiente:
- Xcode 26 o posterior
- iOS 26 SDK como target de deployment
- Swift 6.2 o posterior
- Un proyecto SwiftUI (existente o nuevo, da igual)
El nuevo WebView forma parte del framework WebKit, así que necesitas importarlo explícitamente junto con SwiftUI:
import SwiftUI
import WebKit
No necesitas agregar ningún framework adicional ni dependencia externa. Todo viene integrado en el SDK, lo cual se agradece bastante.
Tu primer WebView: carga básica de URLs
La forma más directa de mostrar contenido web es crear un WebView con una URL. Literalmente una línea de código te da un navegador completo impulsado por el mismo motor WebKit que ejecuta Safari:
import SwiftUI
import WebKit
struct NavegadorBasicoView: View {
var body: some View {
WebView(url: URL(string: "https://developer.apple.com")!)
}
}
Eso es todo. Sin coordinadores, sin protocolos de delegado, sin UIViewRepresentable. Honestamente, la primera vez que lo probé me quedé un momento mirando la pantalla esperando que algo fallara. Pero no. El WebView maneja automáticamente la carga de la página, los gestos de navegación (deslizar hacia atrás y adelante) y la integración con el sistema de estado de SwiftUI.
URL dinámica con @State
Puedes hacer la URL reactiva usando @State. Cuando el valor cambia, el WebView se actualiza automáticamente:
struct NavegadorDinamicoView: View {
@State private var urlActual = URL(string: "https://developer.apple.com")!
var body: some View {
VStack {
WebView(url: urlActual)
HStack {
Button("Apple") {
urlActual = URL(string: "https://www.apple.com")!
}
Button("Swift.org") {
urlActual = URL(string: "https://www.swift.org")!
}
}
.padding()
}
}
}
Esto funciona perfecto para casos simples: mostrar documentación, términos y condiciones, o contenido estático. Pero cuando necesitas más control, entra en escena WebPage.
WebPage: control granular del contenido web
WebPage es una clase @Observable que representa el estado completo de tu contenido web. Piensa en ella como el puente entre tu lógica SwiftUI y el motor WebKit subyacente. Mientras que WebView(url:) es un atajo conveniente, WebPage te da acceso a todo:
- Rastrear el progreso de carga en tiempo real
- Leer el título y la URL actual de la página
- Ejecutar JavaScript y recibir resultados
- Controlar la navegación (recargar, detener, avanzar, retroceder)
- Acceder al historial de navegación completo
Configuración básica con WebPage
struct NavegadorAvanzadoView: View {
@State private var pagina = WebPage()
var body: some View {
VStack(spacing: 0) {
// Barra de progreso
if pagina.isLoading {
ProgressView(value: pagina.estimatedProgress)
.tint(.blue)
}
// Título de la página
Text(pagina.title ?? "Cargando...")
.font(.headline)
.padding(.horizontal)
// El WebView vinculado a la página
WebView(pagina)
.onAppear {
let request = URLRequest(url: URL(string: "https://www.swift.org")!)
pagina.load(request)
}
// Controles de navegación
HStack(spacing: 20) {
Button(action: { pagina.goBack() }) {
Image(systemName: "chevron.left")
}
.disabled(!pagina.canGoBack)
Button(action: { pagina.goForward() }) {
Image(systemName: "chevron.right")
}
.disabled(!pagina.canGoForward)
Button(action: { pagina.reload() }) {
Image(systemName: "arrow.clockwise")
}
if pagina.isLoading {
Button(action: { pagina.stopLoading() }) {
Image(systemName: "xmark")
}
}
}
.padding()
}
}
}
Fíjate en cómo las propiedades canGoBack y canGoForward se actualizan automáticamente conforme el usuario navega. Como WebPage es un tipo Observable, SwiftUI recalcula la vista cada vez que cambia cualquiera de sus propiedades publicadas. Nada de Combine, nada de suscripciones manuales.
Historial de navegación con backForwardList
Si quieres crear menús de historial como los de Safari (y seamos honestos, es un detalle que los usuarios agradecen), puedes acceder a backForwardList:
struct BarraNavegacionView: View {
let pagina: WebPage
var body: some View {
HStack {
Menu {
ForEach(pagina.backForwardList.backList, id: \.url) { item in
Button(item.title ?? item.url.absoluteString) {
pagina.go(to: item)
}
}
} label: {
Image(systemName: "chevron.left")
} primaryAction: {
pagina.goBack()
}
.disabled(!pagina.canGoBack)
Menu {
ForEach(pagina.backForwardList.forwardList, id: \.url) { item in
Button(item.title ?? item.url.absoluteString) {
pagina.go(to: item)
}
}
} label: {
Image(systemName: "chevron.right")
} primaryAction: {
pagina.goForward()
}
.disabled(!pagina.canGoForward)
}
}
}
El toque principal ejecuta la acción estándar (ir atrás o adelante), mientras que mantener pulsado despliega el menú con el historial completo. Un patrón elegante y familiar para el usuario.
Comunicación bidireccional con JavaScript
Aquí es donde las cosas se ponen realmente interesantes. Una de las capacidades más potentes del nuevo WebView es la ejecución de JavaScript desde Swift y la recepción de datos del lado web. Esto abre la puerta a interacciones híbridas donde tu app nativa y el contenido web se comunican de forma fluida.
Ejecutar JavaScript desde Swift
El método callJavaScript(_:) de WebPage te permite ejecutar cualquier script y recibir el resultado de forma asíncrona:
struct JavaScriptDemoView: View {
@State private var pagina = WebPage()
@State private var tituloPagina = ""
@State private var encabezados: [String] = []
var body: some View {
VStack {
WebView(pagina)
.onAppear {
pagina.load(URLRequest(url: URL(string: "https://www.swift.org")!))
}
Button("Obtener título") {
Task {
do {
let resultado = try await pagina.callJavaScript("document.title")
tituloPagina = resultado as? String ?? "Sin título"
} catch {
print("Error JS: \(error)")
}
}
}
Button("Extraer encabezados H2") {
Task {
do {
let script = """
Array.from(document.querySelectorAll('h2')).map(h => h.textContent)
"""
if let resultado = try await pagina.callJavaScript(script) as? [String] {
encabezados = resultado
}
} catch {
print("Error JS: \(error)")
}
}
}
if !encabezados.isEmpty {
List(encabezados, id: \.self) { encabezado in
Text(encabezado)
}
}
Text(tituloPagina)
.font(.caption)
}
}
}
El método callJavaScript es asíncrono y lanza errores si el script falla, así que puedes manejarlos de forma estructurada con try/catch dentro de un Task. Nada de callbacks anidados como en los viejos tiempos.
Inyectar HTML local
No siempre necesitas cargar una URL remota. A veces quieres generar HTML sobre la marcha y mostrarlo directamente:
struct HTMLLocalView: View {
@State private var pagina = WebPage()
let contenidoHTML = """
Contenido Generado
Este HTML fue creado desde Swift y renderizado nativamente.
"""
var body: some View {
WebView(pagina)
.onAppear {
pagina.loadHTMLString(contenidoHTML, baseURL: nil)
}
}
}
Esto es especialmente útil para renderizar contenido formateado como correos electrónicos, reportes o vistas previas de documentos. Básicamente, cualquier situación donde necesites control total sobre el diseño visual sin depender de un servidor.
Esquemas de URL personalizados con URLSchemeHandler
Vale, esto es algo que me parece particularmente útil. ¿Qué pasa cuando tu contenido web referencia recursos locales de tu app, como imágenes del bundle o archivos de datos? Aquí es donde URLSchemeHandler entra en acción, permitiéndote interceptar y responder a esquemas de URL personalizados.
Implementar un URLSchemeHandler
El protocolo URLSchemeHandler requiere un método reply(for:) que devuelve una secuencia asíncrona de resultados:
struct RecursosLocalesHandler: URLSchemeHandler {
func reply(for request: URLRequest) -> some AsyncSequence {
AsyncThrowingStream { continuation in
guard let url = request.url,
let nombreArchivo = url.host(),
let archivoURL = Bundle.main.url(
forResource: nombreArchivo,
withExtension: url.pathExtension
),
let datos = try? Data(contentsOf: archivoURL) else {
continuation.finish(throwing: URLError(.fileDoesNotExist))
return
}
let tipoMIME = obtenerTipoMIME(para: url.pathExtension)
let respuesta = URLResponse(
url: url,
mimeType: tipoMIME,
expectedContentLength: datos.count,
textEncodingName: nil
)
continuation.yield(.response(respuesta))
continuation.yield(.data(datos))
continuation.finish()
}
}
private func obtenerTipoMIME(para extension: String) -> String {
switch `extension`.lowercased() {
case "html": return "text/html"
case "css": return "text/css"
case "js": return "application/javascript"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "svg": return "image/svg+xml"
case "json": return "application/json"
default: return "application/octet-stream"
}
}
}
Registrar el esquema en WebPage
Una vez creado el handler, lo registras en la configuración de WebPage:
struct ContenidoLocalView: View {
@State private var pagina: WebPage
init() {
let esquema = URLScheme(name: "mi-app", handler: RecursosLocalesHandler())
let configuracion = WebPage.Configuration(urlSchemes: [esquema])
_pagina = State(initialValue: WebPage(configuration: configuracion))
}
var body: some View {
WebView(pagina)
.onAppear {
let html = """
Imagen cargada desde el bundle de la app
"""
pagina.loadHTMLString(html, baseURL: nil)
}
}
}
Cuando el contenido web referencia mi-app://logo.png, tu handler intercepta la petición, busca el archivo en el bundle y devuelve los datos. Todo sin salir de tu app ni hacer peticiones de red. Limpio y eficiente.
Control de navegación con NavigationDeciding
En apps de producción, rara vez quieres que el usuario navegue libremente a cualquier destino. Quizás necesitas abrir enlaces externos en Safari, bloquear ciertos dominios o redirigir peticiones a vistas nativas. El protocolo NavigationDeciding te da exactamente ese control.
import WebKit
struct MiPoliticaNavegacion: NavigationDeciding {
let dominiosPermitidos: Set
func decidePolicy(
for action: NavigationAction
) async -> NavigationActionPolicy {
guard let host = action.request.url?.host() else {
return .cancel
}
// Permitir navegación dentro de dominios autorizados
if dominiosPermitidos.contains(where: { host.hasSuffix($0) }) {
return .allow
}
// Abrir enlaces externos en Safari
if let url = action.request.url {
await UIApplication.shared.open(url)
}
return .cancel
}
}
struct NavegadorControladoView: View {
@State private var pagina = WebPage()
private let politica = MiPoliticaNavegacion(
dominiosPermitidos: ["apple.com", "swift.org", "developer.apple.com"]
)
var body: some View {
WebView(pagina)
.webViewNavigationPolicy(politica)
.onAppear {
pagina.load(URLRequest(url: URL(string: "https://developer.apple.com")!))
}
}
}
Con esto, cualquier enlace que apunte fuera de los dominios permitidos se abrirá en Safari en lugar de dentro de tu app. Es una medida de seguridad fundamental (y sinceramente, debería ser obligatoria) para evitar que los usuarios acaben en sitios maliciosos dentro de tu propia interfaz.
Modificadores de comportamiento del WebView
SwiftUI nos da modificadores declarativos para personalizar el comportamiento del WebView sin tocar JavaScript ni delegados:
WebView(pagina)
// Desactivar gestos de navegación atrás/adelante
.webViewBackForwardNavigationGestures(.disabled)
// Habilitar gestos de zoom (pinch-to-zoom)
.webViewMagnificationGestures(.enabled)
// Desactivar vista previa de enlaces al mantener pulsado
.webViewLinkPreviews(.disabled)
// Controlar la posición de scroll programáticamente
.webViewScrollPosition($posicionScroll)
Estos modificadores siguen la convención habitual de SwiftUI: son declarativos, componibles y reactivos. Puedes condicionar cualquiera de ellos según el estado de tu app, lo cual está muy bien pensado.
Observar eventos de navegación
La API de observación de Swift 6.2 te permite reaccionar a cambios en el WebPage de una forma bastante elegante. Puedes detectar cuándo cambia la URL, cuándo se carga una nueva página o cuándo ocurre un error:
struct NavegadorObservableView: View {
@State private var pagina = WebPage()
@State private var historialURLs: [URL] = []
var body: some View {
VStack {
WebView(pagina)
.onAppear {
pagina.load(URLRequest(url: URL(string: "https://www.swift.org")!))
}
.onChange(of: pagina.url) { _, nuevaURL in
if let url = nuevaURL {
historialURLs.append(url)
}
}
// Lista del historial visitado en esta sesión
if !historialURLs.isEmpty {
List(historialURLs, id: \.self) { url in
Text(url.absoluteString)
.font(.caption)
.lineLimit(1)
}
.frame(maxHeight: 150)
}
}
}
}
Combinar la observabilidad de WebPage con onChange de SwiftUI te permite construir analíticas de navegación, breadcrumbs o cualquier funcionalidad que dependa del comportamiento del usuario dentro del contenido web. Las posibilidades son amplias.
Compatibilidad con versiones anteriores de iOS
Si tu app necesita soportar iOS 25 o anteriores (que siendo realistas, probablemente sí al menos por un tiempo), necesitas una estrategia de compatibilidad. La forma más limpia es usar #available para seleccionar la implementación correcta:
struct WebViewCompatibleView: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
} else {
WebViewLegacy(url: url)
}
}
}
// Fallback para iOS 25 y anteriores
struct WebViewLegacy: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Recargar solo si la URL cambió
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
}
Para proyectos nuevos que tengan como target mínimo iOS 26, puedes usar WebView directamente sin preocuparte por el fallback. Y francamente, eso es lo ideal.
Ejemplo completo: navegador in-app con todas las funcionalidades
Bien, vamos a juntar todo lo que hemos visto en un navegador in-app completo. Incluye barra de progreso, controles de navegación, ejecución de JavaScript y política de navegación:
import SwiftUI
import WebKit
struct NavegadorCompletoView: View {
@State private var pagina = WebPage()
@State private var inputURL = "https://www.swift.org"
@State private var modoOscuro = false
private let politicaNavegacion = MiPoliticaNavegacion(
dominiosPermitidos: ["swift.org", "apple.com", "developer.apple.com", "github.com"]
)
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Barra de progreso
if pagina.isLoading {
ProgressView(value: pagina.estimatedProgress)
.tint(.accentColor)
}
// Contenido web
WebView(pagina)
.webViewNavigationPolicy(politicaNavegacion)
.webViewBackForwardNavigationGestures(.enabled)
}
.navigationTitle(pagina.title ?? "Navegador")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button(action: { pagina.goBack() }) {
Image(systemName: "chevron.left")
}
.disabled(!pagina.canGoBack)
Button(action: { pagina.goForward() }) {
Image(systemName: "chevron.right")
}
.disabled(!pagina.canGoForward)
Spacer()
Button(action: { pagina.reload() }) {
Image(systemName: pagina.isLoading ? "xmark" : "arrow.clockwise")
}
Button(action: alternarModoOscuro) {
Image(systemName: modoOscuro ? "sun.max" : "moon")
}
}
}
.onAppear {
cargarURL()
}
}
}
private func cargarURL() {
guard let url = URL(string: inputURL) else { return }
pagina.load(URLRequest(url: url))
}
private func alternarModoOscuro() {
modoOscuro.toggle()
let script = """
document.body.style.backgroundColor = '\(modoOscuro ? "#1a1a1a" : "#ffffff")';
document.body.style.color = '\(modoOscuro ? "#ffffff" : "#000000")';
"""
Task {
try? await pagina.callJavaScript(script)
}
}
}
Este ejemplo muestra cómo las distintas piezas del API encajan de forma natural. La barra de progreso reacciona al estado de carga, los botones se habilitan o deshabilitan según el historial, y JavaScript se usa para alternar el modo oscuro del contenido web. No es un navegador completo, claro, pero es una base sólida sobre la que construir.
Mejores prácticas y consideraciones de seguridad
Al trabajar con contenido web dentro de tu app, hay varias prácticas que vale la pena tener en cuenta para mantener la seguridad y una buena experiencia de usuario:
- Usa siempre HTTPS — Evita cargar contenido HTTP sin cifrar. App Transport Security (ATS) lo bloquea por defecto, y no deberías deshabilitarlo salvo en casos muy específicos de desarrollo local.
- Valida las URLs antes de cargarlas — Si la URL proviene del usuario o de una fuente externa, verifica que sea un esquema válido (
https) y que el host sea de confianza. - Sanitiza los inputs de JavaScript — Nunca interpoles directamente datos del usuario en cadenas de JavaScript. Esto puede provocar vulnerabilidades de inyección de código (XSS), y créeme, es más fácil de lo que parece caer en esto.
- Implementa NavigationDeciding — Para apps en producción, siempre controla a qué dominios puede navegar el usuario dentro del WebView.
- Maneja errores de carga — Proporciona una interfaz clara cuando la carga falla (sin conexión, timeout, certificado inválido). Tus usuarios te lo van a agradecer.
- Respeta la privacidad del usuario — Ten en cuenta las cookies, el almacenamiento local y los rastreadores que pueda cargar el contenido web. Considera limpiar los datos de sesión cuando sea apropiado.
Preguntas frecuentes
¿Puedo usar el nuevo WebView de SwiftUI en versiones anteriores a iOS 26?
No. El WebView nativo de SwiftUI está disponible exclusivamente a partir de iOS 26, macOS 26, visionOS 26 y watchOS 26. Para soportar versiones anteriores, necesitas mantener una implementación alternativa usando UIViewRepresentable con WKWebView y usar #available(iOS 26, *) para elegir la implementación correcta en tiempo de ejecución.
¿Cuál es la diferencia entre WebView y WebPage en SwiftUI?
WebView es el componente visual que renderiza el contenido web en tu interfaz. WebPage es el modelo de datos observable que controla y expone el estado del contenido (título, URL, progreso de carga, historial). Para casos simples donde solo necesitas mostrar una URL, usa WebView(url:). Para cualquier escenario que requiera interacción programática, crea un WebPage y pásalo a WebView(pagina).
¿Cómo ejecuto JavaScript en el nuevo WebView de SwiftUI?
Usa el método callJavaScript(_:) del objeto WebPage. Es un método asíncrono que puedes llamar dentro de un Task. Devuelve un valor opcional con el resultado del script y lanza errores si la ejecución falla. Eso sí, recuerda siempre sanitizar cualquier dato que interpoles en el script para prevenir vulnerabilidades de inyección.
¿Reemplaza el nuevo WebView a WKWebView por completo?
No del todo. El nuevo WebView de SwiftUI cubre la gran mayoría de casos de uso comunes, pero WKWebView sigue ofreciendo funcionalidades más avanzadas como la gestión detallada de cookies mediante WKHTTPCookieStore, la configuración de content rules (WKContentRuleList) y el acceso directo a la configuración de procesos web. Si necesitas ese nivel de control, WKWebView sigue siendo tu mejor opción.
¿Funciona el WebView nativo en macOS y visionOS también?
Sí. El WebView de SwiftUI está disponible en todas las plataformas Apple que recibieron la actualización de 2025: iOS 26, macOS 26, visionOS 26 y watchOS 26. El mismo código funciona en todas las plataformas, adaptándose automáticamente a las convenciones de interfaz de cada una. Multiplataforma de verdad.