Saltar al contenido
← Todos los posts

UI, theming y animaciones en SwiftUI sin NativeWind ni Reanimated

Cómo hacer theming, animaciones y accesibilidad en SwiftUI viniendo de NativeWind y Reanimated: design tokens con Asset Catalog, Dynamic Type como estándar, SF Symbols, withAnimation, matchedGeometryEffect, PhaseAnimator, gestos nativos y Liquid Glass como API del día uno.

Ilustración de una forma que se transforma en tres fases hacia otra posición, con una escala de tamaños de texto y un símbolo a un lado

En React Native, la capa visual se armaba con dos herramientas que no venían con el framework: NativeWind para tokens y utilidades de estilo, y Reanimated para cualquier animación que tuviera que correr a sesenta cuadros por segundo. En SwiftUI las dos necesidades existen y ninguna requiere una librería. Los tokens viven en el Asset Catalog y en extensiones de tipos del sistema, las animaciones son una propiedad del cambio de estado y corren fuera de tu código, y la accesibilidad (Dynamic Type, VoiceOver, contraste) no es una capa que se agrega al final sino el comportamiento por defecto de cada componente, que solo hay que no romper. Este post cubre esa capa completa, y cierra con Liquid Glass como ejemplo de lo que el primer post de la serie llamaba una API del día uno.

Resumen para perezosos
  • Los design tokens van en el Asset Catalog (colores con variante clara y oscura, y de alto contraste) y en extensiones de Color, Font y ShapeStyle. No hay clases utilitarias; hay modifiers propios y estilos de componente (ButtonStyle, LabelStyle).
  • Una animación en SwiftUI no es un valor que interpolas: es una instrucción de que un cambio de estado se anime. withAnimation, .animation(_:value:), matchedGeometryEffect y PhaseAnimator cubren lo que Reanimated hacía con shared values y worklets, sin hilo de UI que administrar.
  • Dynamic Type, VoiceOver y prefers-reduced-motion funcionan solos si usas estilos de texto del sistema y no fijas tamaños. Cada .font(.system(size: 14)) y cada .frame(height: 44) los rompe un poco.

En este artículo:

Design tokens sin NativeWind: Asset Catalog y extensiones

NativeWind daba una tailwind.config.js con la paleta y clases como bg-primary text-lg. En SwiftUI la paleta va en el Asset Catalog (Assets.xcassets), donde cada color se define con sus variantes: apariencia clara y oscura, y opcionalmente alto contraste, por dispositivo. Xcode genera símbolos con nombre para cada color y cada imagen del catálogo, así que Color.brandPrimary existe en tiempo de compilación y el compilador avisa si lo renombras.

Sobre eso, la capa de tokens semánticos es una extensión:

// DesignSystem/Tokens.swift
import SwiftUI

extension Color {
    // Colores del catálogo: variantes clara/oscura resueltas por el sistema.
    static let surface = Color("Surface")
    static let surfaceRaised = Color("SurfaceRaised")
    static let accent = Color("Accent")
    static let textPrimary = Color("TextPrimary")
    static let textMuted = Color("TextMuted")
}

extension Font {
    // Siempre relativos a un estilo de texto del sistema: escalan con Dynamic Type.
    static let display = Font.system(.largeTitle, design: .serif, weight: .semibold)
    static let heading = Font.system(.title2, weight: .semibold)
    static let body = Font.system(.body)
    static let caption = Font.system(.caption, design: .monospaced)
}

enum Spacing {
    static let xs: CGFloat = 4
    static let sm: CGFloat = 8
    static let md: CGFloat = 16
    static let lg: CGFloat = 24
    static let xl: CGFloat = 40
}

enum Radius {
    static let card: CGFloat = 16
    static let control: CGFloat = 10
}

Dos decisiones que se notan después:

  • Los colores semánticos del sistema van primero. Color.primary, .secondary, Color(.systemBackground), .systemGroupedBackground ya cambian con el modo oscuro, el alto contraste y el contexto (una List agrupada tiene otro fondo). Los tokens propios se agregan para la marca, no para reinventar lo que el sistema resuelve.
  • El modo oscuro no se implementa; se declara. Cada color del catálogo tiene su variante oscura, y el sistema elige. No hay useColorScheme() con ternarios en cada componente. Si un componente necesita saber el esquema (raro), @Environment(\.colorScheme) lo expone.

Para forzar un esquema en una pantalla (un onboarding siempre oscuro), .preferredColorScheme(.dark) en esa vista. Para previsualizar los dos, #Preview(traits: .sizeThatFitsLayout) con .environment(\.colorScheme, .dark).

Estilos de componente en vez de clases utilitarias

Lo que en NativeWind era className="rounded-xl bg-accent px-4 py-3 text-white font-semibold" repetido en cada botón, en SwiftUI es un estilo de componente: un tipo que describe cómo se ve un botón, se aplica una vez con un modifier, y se hereda hacia abajo por el árbol.

struct PrimaryButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.heading)
            .padding(.horizontal, Spacing.md)
            .padding(.vertical, Spacing.sm + 4)
            .frame(maxWidth: .infinity)
            .background(isEnabled ? Color.accent : Color.textMuted, in: .rect(cornerRadius: Radius.control))
            .foregroundStyle(.white)
            .opacity(configuration.isPressed ? 0.8 : 1)
            .scaleEffect(configuration.isPressed ? 0.98 : 1)
            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
    }
}

extension ButtonStyle where Self == PrimaryButtonStyle {
    static var primary: PrimaryButtonStyle { .init() }
}

// Uso: el estado de presionado y el deshabilitado vienen resueltos.
Button("Continuar") { submit() }
    .buttonStyle(.primary)

Hay ButtonStyle, ToggleStyle, LabelStyle, TextFieldStyle, ProgressViewStyle, MenuStyle, y desde iOS 17 estilos para más contenedores. Y para composiciones propias, un ViewModifier con una extensión de View:

struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(Spacing.md)
            .background(Color.surfaceRaised, in: .rect(cornerRadius: Radius.card))
            .shadow(color: .black.opacity(0.06), radius: 8, y: 2)
    }
}

extension View {
    func card() -> some View { modifier(CardModifier()) }
}

La diferencia con las clases utilitarias no es solo de sintaxis. Un estilo aplicado a un contenedor (.buttonStyle(.primary) en un VStack) afecta a todos los botones dentro, y las vistas no necesitan saber qué estilo llevan. El design system deja de ser una lista de clases que cada pantalla recuerda y pasa a ser un conjunto de decisiones que se aplican arriba.

SF Symbols: los iconos que ya vienen con el sistema

@expo/vector-icons traía varias familias de iconos como fuentes. iOS incluye SF Symbols, más de seis mil símbolos diseñados para alinearse con la tipografía del sistema, con nueve pesos, escalas, variantes (relleno, círculo, slash), modos de renderizado (monocromo, jerárquico, paleta, multicolor) y animaciones integradas.

Label("Favoritos", systemImage: "heart.fill")
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(.accent)

Image(systemName: "wifi")
    .symbolVariant(isConnected ? .none : .slash)
    .symbolEffect(.bounce, value: reconnectCount)     // animación integrada

Tres cosas que cambian respecto a una fuente de iconos: los símbolos escalan con Dynamic Type junto al texto que acompañan, se alinean a la línea base sin ajustes manuales, y tienen efectos de animación (.bounce, .pulse, .variableColor, .replace) que no requieren nada más que un cambio de valor. Para íconos propios, se importan como símbolos personalizados en el catálogo con la app SF Symbols de Apple y se usan con la misma API.

Dynamic Type y VoiceOver: el estándar que solo hay que no romper

En React Native, la accesibilidad era trabajo explícito: accessibilityLabel, allowFontScaling, probar con el lector de pantalla. En SwiftUI, cada control del sistema ya expone su rol, su etiqueta y su valor a VoiceOver, y cada Text con un estilo del sistema escala con el tamaño de letra que el usuario eligió en Ajustes. El trabajo no es agregar accesibilidad; es no quitarla. Las formas más comunes de quitarla:

  • .font(.system(size: 14)). Tamaño fijo, no escala. Se reemplaza por .font(.subheadline) o por un tamaño relativo: .font(.system(size: 14, relativeTo: .subheadline)).
  • .frame(height: 44) en algo que contiene texto. Con letra grande, el texto se corta. Se reemplaza por minHeight, o se deja que el contenido decida.
  • HStack con texto y controles que no caben en letra grande. Se usa ViewThatFits para ofrecer una variante vertical, o @Environment(\.dynamicTypeSize) para cambiar el layout a partir de .accessibility1.
  • Un Image decorativo sin marcar. VoiceOver lo lee como “imagen”. Image(decorative: "pattern") lo omite.
  • Un botón hecho con un onTapGesture sobre una vista. No tiene rol de botón. Se usa Button con estilo, que además maneja el estado de presionado.

Y las dos que agregan valor con una línea:

// Un grupo de vistas que VoiceOver debe leer como una sola tarjeta.
OrderCard(order: order)
    .accessibilityElement(children: .combine)

// Una acción disponible sin buscar el botón dentro de la tarjeta.
    .accessibilityAction(named: "Reordenar") { reorder(order) }

Sobre el movimiento: @Environment(\.accessibilityReduceMotion) dice si el usuario pidió reducir animaciones. Las transiciones del sistema ya lo respetan; las tuyas deberían: un withAnimation(reduceMotion ? nil : .spring) o una transición de .opacity en vez de .move. Es el equivalente de prefers-reduced-motion de la web, y en iOS un porcentaje de usuarios lo activa.

La forma rápida de probar todo esto es el Accessibility Inspector de Xcode y, en las Previews, el modificador .dynamicTypeSize(.accessibility3) para ver la pantalla con letra enorme sin cambiar los Ajustes del dispositivo. Una pantalla que sobrevive a .accessibility3 sobrevive a casi todo.

Animaciones sin Reanimated: el cambio de estado se anima

Reanimated resolvía un problema real de React Native: las animaciones basadas en estado de React corren en el hilo de JavaScript y se cortan cuando ese hilo está ocupado. Por eso existían shared values, worklets y el hilo de UI. En SwiftUI ese problema no existe: las animaciones las ejecuta el motor de render fuera de tu código, y tu código solo declara qué cambio de estado debe animarse y con qué curva.

struct Expandable: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Text("Detalles")
            if isExpanded {
                Text("Contenido largo que aparece y desaparece.")
            }
        }
        .onTapGesture {
            withAnimation(.snappy) {      // todo lo que cambie por este set, se anima
                isExpanded.toggle()
            }
        }
    }
}

withAnimation envuelve el cambio de estado, y SwiftUI interpola todo lo que ese cambio afecte: tamaños, posiciones, opacidades, colores, incluso las vistas que aparecen o desaparecen (con una transición por defecto de fundido). No hay un useSharedValue ni un useAnimatedStyle; el valor animable es el estado mismo.

La segunda forma, .animation(_:value:), ata la animación a una vista y a un valor concreto, para que se anime solo cuando ese valor cambia, sin importar quién lo cambió:

Circle()
    .fill(isOnline ? .green : .gray)
    .scaleEffect(isOnline ? 1 : 0.8)
    .animation(.bouncy, value: isOnline)

Las curvas: .linear, .easeIn, .easeOut, .easeInOut con duración; .spring con parámetros físicos; y las predefinidas .snappy, .bouncy, .smooth que cubren la mayoría de los casos. Las de resorte son interrumpibles por defecto: si el estado cambia a mitad de la animación, la nueva animación arranca desde la velocidad actual, sin el salto que en Reanimated pedía withSpring con cuidado.

Para valores que no son del sistema (un porcentaje propio, una forma custom), el protocolo Animatable con animatableData dice a SwiftUI cómo interpolar tu tipo. Una Shape con un progress animable es la forma de dibujar un gráfico que se anima al aparecer.

Transiciones y matchedGeometryEffect

Una transición define cómo entra y sale una vista cuando aparece o desaparece dentro de una animación:

if showBanner {
    Banner()
        .transition(.move(edge: .top).combined(with: .opacity))
}

.opacity, .scale, .move(edge:), .slide, .push(from:), .blurReplace, y .asymmetric(insertion:removal:) para que entre de una forma y salga de otra. Se combinan con .combined(with:).

matchedGeometryEffect es el que reemplaza al patrón de “shared element transition” que en React Native requería una librería y un puente con la navegación. Dos vistas con el mismo id y el mismo Namespace se interpretan como la misma vista en dos lugares, y al cambiar el estado que muestra una u otra, SwiftUI anima el marco entre las dos posiciones:

struct Gallery: View {
    @Namespace private var hero
    @State private var selected: Photo?

    var body: some View {
        ZStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
                    ForEach(photos) { photo in
                        Thumbnail(photo)
                            .matchedGeometryEffect(id: photo.id, in: hero)
                            .onTapGesture { withAnimation(.snappy) { selected = photo } }
                    }
                }
            }
            if let selected {
                FullPhoto(selected)
                    .matchedGeometryEffect(id: selected.id, in: hero)
                    .onTapGesture { withAnimation(.snappy) { self.selected = nil } }
            }
        }
    }
}

La miniatura crece hasta ocupar la pantalla y vuelve. Lo mismo entre pantallas de un NavigationStack se hace con .navigationTransition(.zoom(sourceID:in:)) desde iOS 18. Es el tipo de efecto que un cliente pide en la demo y que en nativo cuesta diez líneas.

PhaseAnimator, keyframes y gestos nativos

Para animaciones con varias etapas, lo que en Reanimated era una secuencia de withSequence o un withRepeat, SwiftUI tiene dos herramientas desde iOS 17.

PhaseAnimator recorre una lista de fases y anima entre ellas, en bucle o disparado por un valor:

enum Pulse: CaseIterable { case idle, grow, fade }

Image(systemName: "bell.fill")
    .phaseAnimator(Pulse.allCases, trigger: notificationCount) { view, phase in
        view
            .scaleEffect(phase == .grow ? 1.3 : 1)
            .opacity(phase == .fade ? 0.5 : 1)
    } animation: { phase in
        switch phase {
        case .idle: .smooth
        case .grow: .snappy(duration: 0.2)
        case .fade: .easeOut(duration: 0.4)
        }
    }

KeyframeAnimator para controlar varias propiedades en una línea de tiempo con curvas independientes, como una animación de “sacudida” o un logo que entra por partes. Recibe un struct con los valores animables y una lista de pistas (KeyframeTrack) con sus keyframes.

Sobre gestos: react-native-gesture-handler era necesario porque los gestos tenían que reconocerse fuera del hilo de JavaScript. En SwiftUI, DragGesture, MagnifyGesture, RotateGesture, LongPressGesture y TapGesture son nativos, componibles (.simultaneously(with:), .sequenced(before:), .exclusively(before:)) y su estado se vincula al de la vista con @GestureState, que se reinicia solo cuando el gesto termina:

struct DraggableCard: View {
    @GestureState private var offset: CGSize = .zero

    var body: some View {
        Card()
            .offset(offset)
            .gesture(
                DragGesture()
                    .updating($offset) { value, state, _ in
                        state = value.translation           // durante el arrastre
                    }
                    .onEnded { value in
                        if abs(value.translation.width) > 120 { dismiss() }
                    }
            )
            .animation(.spring, value: offset)              // vuelve al centro al soltar
    }
}

El @GestureState vuelve a .zero al terminar el gesto, y la animación atada a offset devuelve la tarjeta al centro. Son doce líneas para un “swipe to dismiss” con física de resorte e interrumpible. Lo que no hay es el hilo de UI que administrar, porque el gesto y la animación ya viven ahí.

Liquid Glass: una API del día uno

El primer post mencionaba Liquid Glass como ejemplo de “API del día uno”. Vale la pena cerrar con él porque muestra la diferencia de forma concreta.

Liquid Glass es el lenguaje visual que Apple introdujo en iOS 26: superficies translúcidas con refracción y reflejo del contenido de fondo, aplicadas a barras, controles y contenedores del sistema. Las apps SwiftUI que usan componentes estándar (TabView, NavigationStack, toolbar, sheet) lo adoptaron al recompilar con el SDK de iOS 26, sin cambiar código. Para aplicarlo a vistas propias, hay un modifier:

FloatingActions()
    .glassEffect(.regular, in: .capsule)

// Varios elementos de vidrio que se funden al acercarse:
GlassEffectContainer {
    HStack {
        Button { } label: { Image(systemName: "plus") }
        Button { } label: { Image(systemName: "square.and.arrow.up") }
    }
    .buttonStyle(.glass)
}

Lo que importa no es el efecto sino el calendario. En junio de 2025 se anunció; en septiembre de 2025 estaba en producción para cualquier app que compilara con Xcode 26. En React Native, para el mismo resultado, hacía falta que un módulo nativo expusiera el efecto, que un config plugin lo integrara, y que los componentes de navegación de la comunidad lo adoptaran, un proceso que se mide en meses. Ese intervalo es lo que un cliente que quiere “verse como las apps de Apple” el día del lanzamiento está pagando cuando elige nativo.

No es que cada app deba adoptar cada cambio visual de Apple el primer día. Es que la posibilidad de hacerlo, y de decidir no hacerlo, es tuya y no de la comunidad de un módulo.

Preguntas frecuentes

¿Hay algo como Tailwind o NativeWind para SwiftUI?

Hay experimentos, y ninguno tiene adopción. El modelo de SwiftUI (estilos de componente, modifiers propios, entorno que hereda) cubre lo mismo con menos indirección, y las clases utilitarias no encajan bien con un sistema que no tiene un DOM que estilizar. Después de un par de proyectos, no lo he extrañado.

¿Cómo comparto tokens de diseño con la web o con Android?

Con un archivo fuente (JSON de Style Dictionary o Tokens Studio) y un script que genere el Asset Catalog y la extensión de Color para iOS, junto con el CSS o el archivo de Compose para los otros. El Asset Catalog es un directorio con JSON, así que generarlo es sencillo. No lo hagas a mano dos veces.

¿Las animaciones de SwiftUI rinden igual que las de Reanimated?

Sí o mejor, porque corren en el compositor del sistema sin pasar por tu código en cada cuadro. Lo que puede degradar son animaciones sobre vistas caras de re-evaluar (un body pesado que se recalcula durante la animación) o efectos como desenfoques grandes. Instruments con la plantilla de SwiftUI muestra qué body se están evaluando durante una animación.

¿Cómo hago una animación que dependa del scroll, como un header que se encoge?

Con .scrollTransition para efectos por elemento según su posición en el scroll, y con onScrollGeometryChange (iOS 18) para leer el desplazamiento y derivar estado. Antes de iOS 18, GeometryReader dentro del scroll con una PreferenceKey, que es más código y es lo que vas a ver en ejemplos antiguos.

¿Lottie funciona en SwiftUI?

Sí, con el paquete oficial de Airbnb por SPM, que incluye una vista de SwiftUI. Para animaciones vectoriales complejas hechas por un equipo de diseño sigue siendo la opción práctica; para todo lo demás, las herramientas nativas de este post suelen bastar.

Conclusión

La capa visual de SwiftUI no necesita NativeWind ni Reanimated porque el framework ya incluye lo que esas librerías agregaban a React Native: tokens en el Asset Catalog con variantes de apariencia, estilos de componente que se heredan por el árbol, SF Symbols que escalan y se animan con el texto, accesibilidad como comportamiento por defecto que solo hay que no romper, y animaciones declaradas sobre el cambio de estado que corren fuera de tu código. withAnimation, matchedGeometryEffect, PhaseAnimator y los gestos nativos cubren lo que antes pedía shared values y un hilo de UI. Y Liquid Glass es la prueba de que en nativo la decisión de adoptar lo nuevo es tuya, el día que sale.

Para empezar: define los colores en el catálogo con variante oscura, escribe un ButtonStyle y un modifier de tarjeta antes de la primera pantalla, prueba cada vista con .dynamicTypeSize(.accessibility3) en la Preview, y anima siempre con withAnimation sobre estado, nunca con timers. El siguiente post sale del código y entra en lo que EAS escondía: certificados, perfiles, entitlements, TestFlight, App Review y el Privacy Manifest.

Seguir leyendo