Una vista previa empieza con unas pocas líneas. Después necesita un contenedor de SwiftData, datos de ejemplo, una sesión simulada y varias dependencias del entorno. Cuando copiamos esa preparación en cinco pantallas, mantener las previews empieza a parecer otro proyecto dentro de la aplicación.
SwiftUI ofrece una herramienta para centralizar ese trabajo: PreviewModifier. Permite definir un entorno reutilizable y preparar un contexto que el sistema de previews puede conservar y compartir. Está disponible desde iOS 18 y macOS 15, entre otras plataformas, y llegó con Xcode 16. Los ejemplos siguientes asumen esas versiones mínimas. Documentación de Apple.
Preparar las dependencias una sola vez
El protocolo reparte el trabajo entre dos métodos. makeSharedContext() construye el contexto y body(content:context:) lo aplica a la vista previa. Ese contexto puede ser un contenedor de persistencia, un servicio simulado o un tipo propio que agrupe varias dependencias.
La diferencia práctica respecto a una función auxiliar está en quién gestiona la reutilización: el sistema de previews guarda el contexto y lo entrega a las previews que utilizan el mismo tipo de modificador. Por eso interesa colocar aquí la preparación compartida, especialmente si tiene cierto coste. Referencia de makeSharedContext().
Vamos a verlo con una aplicación de rutas de senderismo. Primero definimos un modelo sencillo:
import SwiftUI
import SwiftData
import DeveloperToolsSupport
@Model
final class HikingRoute {
var name: String
var distance: Double
init(name: String, distance: Double) {
self.name = name
self.distance = distance
}
}
La pantalla consulta las rutas y recibe un enlace para activar un filtro:
struct RouteListView: View {
@Query(sort: \HikingRoute.name)
private var routes: [HikingRoute]
@Binding var shortRoutesOnly: Bool
private var visibleRoutes: [HikingRoute] {
routes.filter { !shortRoutesOnly || $0.distance < 10 }
}
var body: some View {
List {
Toggle("Menos de 10 km", isOn: $shortRoutesOnly)
ForEach(visibleRoutes) { route in
HStack {
Text(route.name)
Spacer()
Text("\(route.distance, specifier: "%.1f") km")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Rutas")
}
}
Aquí no hay condiciones para detectar si estamos en el lienzo de Xcode. La vista pide las mismas dependencias que utilizaría al ejecutar la aplicación.
Un contenedor de SwiftData para las previews
Ahora podemos crear el modificador que prepara los datos:
struct RouteSamples: PreviewModifier {
static func makeSharedContext() throws -> ModelContainer {
let configuration = ModelConfiguration(
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
let container = try ModelContainer(
for: HikingRoute.self,
configurations: configuration
)
let context = container.mainContext
context.insert(HikingRoute(name: "Bosque del Roble", distance: 6.4))
context.insert(HikingRoute(name: "Cresta del Águila", distance: 14.2))
context.insert(HikingRoute(name: "Laguna Serena", distance: 8.1))
try context.save()
return container
}
func body(content: Content, context: ModelContainer) -> some View {
content.modelContainer(context)
}
}
isStoredInMemoryOnly mantiene el almacenamiento en memoria y .none desactiva CloudKit para esta configuración. Así, los datos de muestra tienen un almacenamiento efímero y no necesitan sincronización. Que el contenedor viva en memoria no significa que se reinicie cada vez que interactuamos con la preview. Referencia de ModelConfiguration.
Al aplicar .modelContainer(context), SwiftUI incorpora el contenedor y su contexto asociado al entorno. La propiedad @Query de RouteListView utiliza ese entorno para consultar los modelos; no necesita recibir un array especial para las previews. Referencia de modelContainer(_:).
El método de preparación conserva throws, de modo que un error puede propagarse al sistema de previews. No necesitamos ocultarlo con try? ni forzar el resultado mediante try!.
Dar nombre a una configuración
Podemos aplicar el modificador directamente con traits: .modifier(RouteSamples()). Si lo utilizamos en varias pantallas, una propiedad estática hace más legible cada declaración:
extension PreviewTrait where T == Preview.ViewTraits {
@MainActor
static var routeSamples: Self {
.modifier(RouteSamples())
}
}
#Preview("Todas las rutas", traits: .routeSamples) {
@Previewable @State var shortRoutesOnly = false
NavigationStack {
RouteListView(shortRoutesOnly: $shortRoutesOnly)
}
}
#Preview("Rutas cortas", traits: .routeSamples) {
@Previewable @State var shortRoutesOnly = true
NavigationStack {
RouteListView(shortRoutesOnly: $shortRoutesOnly)
}
}
Este nombre solo abrevia la aplicación del modificador. No crea otro mecanismo de almacenamiento ni convierte el contexto en un singleton de la aplicación. La API permite además combinar varios modificadores en traits; el orden determina cómo se envuelven las vistas. Referencia de modifier(_:).
Estado compartido y estado local
En este ejemplo, las dos previews utilizan el contexto preparado por RouteSamples, pero cada una tiene su propio filtro. @Previewable @State permite declarar ese estado local dentro del bloque de #Preview. La macro genera una vista que aloja la propiedad, por lo que el interruptor puede cambiarla durante la interacción. Documentación de @Previewable.
Conviene mantener esa separación. El filtro es una elección de presentación; las rutas pertenecen al almacenamiento compartido. Si añadimos acciones para borrar o modificar rutas, estaremos alterando objetos que otras previews participantes podrían utilizar.
De ahí una precaución: no dar por hecho que dos instancias del mismo modificador producirán dos bases de datos independientes. makeSharedContext() es estático y la reutilización está asociada al tipo del modificador. Para escenarios que necesitan aislamiento, como una base vacía frente a otra con contenido, podemos definir tipos de modificador distintos. Si cada interacción debe partir de datos nuevos, necesitaremos controlar explícitamente su creación y reinicio.
Preparación asíncrona sin sorpresas
La firma completa de makeSharedContext() admite async throws, aunque nuestro ejemplo solo necesita throws. Esto permite esperar a una preparación asíncrona antes de aplicar el contexto. Tanto el protocolo como este método están aislados al actor principal: añadir async no desplaza automáticamente el trabajo pesado a segundo plano. Referencia de la firma y su aislamiento.
Para unas previews reproducibles, suele resultar más práctico trabajar con datos locales y servicios simulados. Una petición real introduce variaciones: la sesión puede caducar, el servidor puede fallar y el contenido puede cambiar justo mientras ajustamos el diseño.
Tampoco hace falta llevar toda la aplicación al contexto compartido. Una pantalla de rutas puede necesitar únicamente su contenedor; una tarjeta que recibe un título quizá no necesite ningún modificador. Centralizar la preparación que se repite permite que cada #Preview siga mostrando con claridad qué pantalla y qué estado queremos comprobar.