Cada actualización de SwiftUI incorpora herramientas que simplifican la construcción de nuestras interfaces. Nuevos componentes, modificadores más potentes y funcionalidades que antes necesitaban bastante código.
El problema aparece cuando queremos utilizarlas, pero nuestra aplicación todavía admite versiones anteriores de iOS. Subir la versión mínima de despliegue puede dejarnos sin usuarios que siguen utilizando dispositivos perfectamente válidos.
Por suerte, existe una alternativa: crear nuestras propias extensiones de View para utilizar las APIs nativas cuando están disponibles y proporcionar una implementación alternativa en los sistemas anteriores.
Esta técnica se conoce como backporting y nos permite incorporar funcionalidades recientes sin renunciar a la compatibilidad con versiones anteriores de iOS.
El SDK y la versión mínima de iOS son cosas diferentes
Antes de empezar, conviene entender una distinción importante.
El SDK determina qué APIs conoce nuestro compilador, mientras que la versión mínima de despliegue establece desde qué versión de iOS puede ejecutarse nuestra aplicación.
Por ejemplo, podemos compilar una aplicación utilizando un SDK reciente y mantener iOS 15 como versión mínima, siempre que las herramientas de desarrollo lo permitan.
Para utilizar funcionalidades que solamente existen en determinadas versiones del sistema tenemos #available:
if #available(iOS 17.0, *) {
// API disponible desde iOS 17
} else {
// Alternativa para versiones anteriores
}
Esta comprobación se realiza durante la ejecución, permitiendo utilizar una implementación diferente según la versión del sistema operativo.
Eso sí, #available no permite utilizar APIs que nuestro SDK desconoce. Para trabajar con una funcionalidad reciente necesitamos compilar con un SDK que la incluya.
También existe @available, que permite declarar la disponibilidad de tipos, propiedades o funciones. Sin embargo, marcar una vista como disponible únicamente desde iOS 17 no soluciona el problema de compatibilidad: simplemente impide utilizarla directamente desde contextos que deben funcionar en versiones anteriores.
Crear una capa de compatibilidad mediante extensiones
📦 Una de las maneras más sencillas de aplicar este patrón consiste en aprovechar las extensiones de View.
SwiftUI utiliza modificadores para configurar nuestras vistas, así que podemos crear nuestros propios métodos que se comporten como cualquier otro modificador.
La idea es sencilla: encapsular las diferencias entre versiones del sistema en un único lugar, evitando repetir comprobaciones de disponibilidad por toda la aplicación.
De esta forma, nuestras vistas pueden utilizar una interfaz común independientemente de la versión del sistema operativo.
Vamos a verlo con un ejemplo.
Un estado vacío para una lista de recetas
🍲 Imaginemos que estamos desarrollando una aplicación de recetas compatible con iOS 15.
Queremos mostrar un mensaje cuando el usuario todavía no tiene ninguna receta guardada.
Desde iOS 17, SwiftUI proporciona ContentUnavailableView, un componente diseñado precisamente para representar estados vacíos o contenido que no está disponible.
Podríamos utilizarlo directamente:
ContentUnavailableView(
"Todavía no hay recetas",
systemImage: "fork.knife",
description: Text("Guarda tu primera receta para verla aquí.")
)
El problema es que nuestra aplicación también debe funcionar en iOS 15 y 16.
En lugar de repetir las comprobaciones de disponibilidad cada vez que necesitemos mostrar un estado vacío, podemos crear un modificador propio mediante una extensión de View:
extension View {
func emptyState(
isEmpty: Bool,
title: LocalizedStringKey,
systemImage: String,
message: LocalizedStringKey
) -> some View {
overlay {
if isEmpty {
if #available(iOS 17.0, *) {
ContentUnavailableView(
title,
systemImage: systemImage,
description: Text(message)
)
} else {
VStack(spacing: 12) {
Image(systemName: systemImage)
.font(.largeTitle)
.accessibilityHidden(true)
Text(title)
.font(.headline)
Text(message)
.foregroundStyle(.secondary)
}
.multilineTextAlignment(.center)
.padding()
}
}
}
}
}
¿Qué hemos conseguido?
Nuestro modificador emptyState utiliza automáticamente la implementación nativa de Apple cuando la aplicación se ejecuta en iOS 17 o posterior.
En versiones anteriores, construye una interfaz equivalente utilizando componentes básicos de SwiftUI que ya estaban disponibles en iOS 15.
Además, toda la lógica de compatibilidad queda concentrada en una única extensión.
Desde el punto de vista de quien utiliza el modificador, la versión del sistema operativo deja de ser una preocupación.
Utilizar nuestro modificador como cualquier otro de SwiftUI
✅ Ahora podemos utilizar emptyState directamente sobre nuestras vistas, exactamente igual que hacemos con .padding(), .background() o cualquier otro modificador.
Por ejemplo, nuestra lista de recetas podría quedar así:
struct RecipeListView: View {
let recipes: [String]
var body: some View {
List(recipes, id: \.self) { recipe in
Text(recipe)
}
.emptyState(
isEmpty: recipes.isEmpty,
title: "Todavía no hay recetas",
systemImage: "fork.knife",
message: "Guarda tu primera receta para verla aquí."
)
}
}
¡Y eso es todo!
No necesitamos comprobar la versión de iOS desde RecipeListView, ni duplicar la construcción de nuestra interfaz.
La vista simplemente utiliza un modificador que sabe cómo comportarse en cada sistema.
Además, si utilizamos este estado vacío en varias pantallas, todas compartirán el mismo comportamiento y cualquier mejora futura se realizará en un único lugar.
Hay algunos detalles importantes en esta implementación.
El modificador utiliza overlay para presentar el mensaje sin sustituir la vista original. Por tanto, nuestra lista permanece en la jerarquía incluso cuando está vacía.
La comprobación de disponibilidad se encuentra dentro de un bloque ViewBuilder, lo que permite que ambas ramas construyan tipos de vistas diferentes.
Por último, nuestra implementación alternativa reproduce la finalidad del componente nativo, pero no necesariamente todos sus detalles visuales o comportamientos. Conviene comprobar ambas versiones con Dynamic Type, VoiceOver y diferentes tamaños de pantalla.
Diseñar extensiones que funcionen en todas las versiones
🧩 Cuando desarrollamos una capa de compatibilidad, debemos prestar atención a los tipos utilizados en sus parámetros.
Imaginemos que una API reciente introduce una enumeración que tampoco existe en versiones anteriores de iOS.
Si nuestra extensión utiliza directamente esa enumeración en su firma, los consumidores del modificador podrían necesitar sus propias comprobaciones de disponibilidad.
Estaríamos trasladando el problema que precisamente queríamos evitar.
Por eso, siempre que sea posible, nuestras extensiones deberían utilizar tipos disponibles desde la versión mínima de iOS que soporta la aplicación.
En nuestro ejemplo utilizamos Bool, String y LocalizedStringKey, disponibles desde mucho antes de iOS 17.
Si necesitamos representar opciones que solamente existen en una API reciente, podemos crear nuestra propia enumeración y realizar la conversión al tipo nativo dentro de la rama correspondiente.
También conviene distinguir dos situaciones diferentes.
Cuando estamos incorporando una mejora puramente visual, puede ser perfectamente válido devolver la vista original sin modificaciones en versiones antiguas:
extension View {
@ViewBuilder
func enhancedAppearance() -> some View {
if #available(iOS 17.0, *) {
self
// Aplicar aquí una mejora disponible
// desde iOS 17.
} else {
self
}
}
}
Este fragmento ilustra el patrón; habría que sustituir el comentario por un modificador real.
Sin embargo, cuando estamos adaptando una funcionalidad necesaria para utilizar nuestra aplicación, devolver simplemente self puede no ser suficiente.
En esos casos, necesitamos proporcionar una alternativa que permita completar la misma acción, aunque su presentación sea diferente.
Mantener nuestras extensiones bajo control
🧹 Crear extensiones de compatibilidad es sencillo, pero también puede convertirse en una fuente de código innecesario si nunca las eliminamos.
Imaginemos que nuestra aplicación termina abandonando el soporte para iOS 15 y 16.
A partir de ese momento, podríamos utilizar directamente ContentUnavailableView y eliminar nuestra implementación alternativa.
Para facilitar esta transición, Swift proporciona mecanismos de disponibilidad y deprecación.
Por ejemplo, podríamos marcar nuestro modificador como obsoleto desde iOS 17:
extension View {
@available(
iOS,
deprecated: 17.0,
message: "Utilizar ContentUnavailableView directamente"
)
func emptyState(
isEmpty: Bool,
title: LocalizedStringKey,
systemImage: String,
message: LocalizedStringKey
) -> some View {
// Implementación de compatibilidad
}
}
De esta forma, el compilador puede advertirnos de su utilización cuando trabajamos en un contexto que garantiza iOS 17 o posterior.
Esto no significa que debamos marcar inmediatamente todas nuestras extensiones como obsoletas. Mientras mantengamos compatibilidad con sistemas anteriores, pueden seguir siendo necesarias.
La idea es contar con una estrategia para identificar y retirar las adaptaciones cuando dejen de aportar valor.
Según la documentación de Swift, podemos utilizar deprecated para generar advertencias y obsoleted para impedir el uso de una declaración a partir de una determinada versión.
También existen bibliotecas como SwiftUIBackports, que ofrecen implementaciones reutilizables de distintas APIs de SwiftUI.
Antes de incorporar una dependencia, merece la pena comprobar qué funcionalidades proporciona y qué versiones del sistema operativo admite.
Para unos pocos modificadores, nuestras propias extensiones pueden ser más que suficientes.
Una pequeña extensión puede simplificar toda la aplicación
🎯 No siempre necesitamos una arquitectura compleja para mantener la compatibilidad con versiones anteriores de iOS.
En muchas ocasiones, una simple extensión de View, acompañada de una comprobación con #available, es suficiente para aprovechar las novedades de SwiftUI sin abandonar a nuestros usuarios.
Este enfoque tiene varias ventajas: mantiene las comprobaciones de disponibilidad fuera de nuestras pantallas, evita duplicar código, proporciona una interfaz uniforme y facilita la eliminación de las implementaciones antiguas cuando dejan de ser necesarias.
La clave está en utilizar las APIs nativas siempre que sea posible y recurrir a nuestras propias implementaciones solamente cuando sea necesario.
Porque adoptar las últimas novedades de SwiftUI no debería obligarnos a renunciar automáticamente a la compatibilidad con versiones anteriores de iOS.