Tutoriales

Sistema de color en SwiftUI: construcción de paletas de color adaptativas

12 min de lectura

El sistema de color de SwiftUI es uno de los más sofisticados de cualquier framework de UI. Va mucho más allá de almacenar un valor hex — un Color de SwiftUI puede resolverse de manera diferente según el esquema de color actual, el espacio de color activo del display, la configuración de accesibilidad del usuario e incluso el entorno en el que se renderiza. Construir una paleta de color adaptativa significa comprender todas estas dimensiones, no solo elegir un conjunto de códigos hex. Esta guía recorre el sistema completo: la diferencia entre Color y UIColor, la gestión en catálogos de assets, los colores dinámicos para modo oscuro, las extensiones de Swift para un acceso ergonómico y los patrones de color accesibles.

SwiftUI Color vs UIColor

La relación entre Color (SwiftUI) y UIColor (UIKit) es una fuente habitual de confusión. No son el mismo tipo, aunque son interconvertibles.

UIColor: la base

UIColor es una clase de UIKit que existe desde iOS 2. Representa un color que puede resolverse de forma diferente según un UITraitCollection — el objeto que describe el entorno actual (modo claro/oscuro, espacio de color del display, configuración de accesibilidad, etc.).

// UIColor con resolución dinámica
let dynamicRed = UIColor { traitCollection in
    if traitCollection.userInterfaceStyle == .dark {
        return UIColor(red: 1.0, green: 0.27, blue: 0.23, alpha: 1.0) // rojo más claro para fondo oscuro
    } else {
        return UIColor(red: 0.85, green: 0.11, blue: 0.11, alpha: 1.0) // rojo más intenso para fondo claro
    }
}

Las instancias de UIColor creadas de esta forma son "dinámicas" — consultan la trait collection actual en el momento de uso, no en el momento de creación. Este es el mecanismo detrás de todos los colores del sistema como UIColor.systemBackground y UIColor.label.

SwiftUI Color: la capa moderna

SwiftUI.Color es una estructura, no una clase, y fue diseñada específicamente para UI declarativa. Envuelve una de varias representaciones subyacentes:

Representación Ejemplos
Color del sistema con nombre Color.primary, Color.secondary
Color del catálogo de assets Color("BrandBlue")
Puente UIColor Color(uiColor: .systemRed)
Valor RGBA fijo Color(red: 0.2, green: 0.5, blue: 1.0)
Valor OKLCH / P3 Color(.displayP3, red: 0.2, green: 0.6, blue: 1.0)

La distinción crítica es que un Color con un valor RGBA fijo es estático — no se adapta al modo claro/oscuro ni a la configuración de accesibilidad. Un Color respaldado por una entrada del catálogo de assets o un UIColor dinámico es adaptativo.

// Estático — no cambia en modo oscuro
let staticBlue = Color(red: 0.23, green: 0.51, blue: 0.96)

// Adaptativo — se resuelve correctamente en modo claro Y oscuro
let adaptiveBlue = Color("AccentBlue") // del catálogo de assets

En un sistema de color de producción, todo color que aparezca en pantalla debe ser adaptativo, no estático. Esto implica enrutar todos los colores a través del catálogo de assets o de inicializadores dinámicos de UIColor, sin codificar jamás los componentes RGB directamente en las vistas.

Conversión entre los dos tipos

En aplicaciones que mezclan SwiftUI y UIKit (que es prácticamente toda aplicación no trivial en 2026), es frecuente necesitar convertir entre los tipos:

// UIColor → Color (SwiftUI)
let swiftUIColor = Color(uiColor: UIColor.systemBlue)

// Color → UIColor (para APIs de UIKit)
let uiKitColor = UIColor(Color.accentColor)

El inicializador UIColor(Color:) se añadió en iOS 15. Para versiones anteriores, la conversión es más compleja y puede perder la resolución dinámica — otra razón más para apuntar a iOS 16+ en aplicaciones SwiftUI-first.

Gestión de colores en el catálogo de assets

El catálogo de assets de Xcode es el hogar canónico de los colores personalizados. Los colores del catálogo de assets admiten:

  • Apariencias dinámicas (claro/oscuro)
  • Variantes de alto contraste
  • Colores de gama amplia Display P3
  • Cualquier gama / gama específica
  • Valores de color localizados (poco frecuente, pero soportado)

Creación de colores en el catálogo de assets

En el editor del catálogo de assets de Xcode:

  1. Abre Assets.xcassets.
  2. Haz clic en el botón + en la parte inferior y elige Color Set.
  3. Nombra el color — usa un nombre semántico como BrandPrimary, no un nombre descriptivo como DarkBlue.
  4. En el inspector de atributos, configura Appearances como Any, Dark para habilitar variantes claro/oscuro.
  5. Establece Color Space como sRGB para colores estándar o Display P3 para colores de gama amplia.
  6. Ingresa los valores hex o RGB para las variantes de modo claro y oscuro por separado.

Color sets programáticos

Para sistemas de diseño grandes, crear docenas de conjuntos de colores manualmente en la interfaz de Xcode es tedioso y propenso a errores. Los conjuntos de colores del catálogo de assets son archivos JSON, y puedes generarlos programáticamente desde tu pipeline de design tokens:

Assets.xcassets/
└── BrandPrimary.colorset/
    └── Contents.json
{
  "colors": [
    {
      "color": {
        "color-space": "srgb",
        "components": {
          "red": "0.114",
          "green": "0.306",
          "blue": "0.847",
          "alpha": "1.000"
        }
      },
      "idiom": "universal"
    },
    {
      "appearances": [
        { "appearance": "luminosity", "value": "dark" }
      ],
      "color": {
        "color-space": "srgb",
        "components": {
          "red": "0.376",
          "green": "0.647",
          "blue": "0.980",
          "alpha": "1.000"
        }
      },
      "idiom": "universal"
    }
  ],
  "info": { "author": "xcode", "version": 1 }
}

Un formateador de Style Dictionary puede generar esta estructura JSON desde tu fuente de design tokens:

// style-dictionary-ios-colorset.js
StyleDictionary.registerFormat({
  name: 'ios/colorset',
  formatter: ({ token }) => {
    const lightHex = token.value
    const darkHex  = token.darkValue ?? token.value

    const toComponents = (hex) => ({
      red:   (parseInt(hex.slice(1, 3), 16) / 255).toFixed(3),
      green: (parseInt(hex.slice(3, 5), 16) / 255).toFixed(3),
      blue:  (parseInt(hex.slice(5, 7), 16) / 255).toFixed(3),
      alpha: '1.000',
    })

    return JSON.stringify({
      colors: [
        {
          color: { 'color-space': 'srgb', components: toComponents(lightHex) },
          idiom: 'universal',
        },
        {
          appearances: [{ appearance: 'luminosity', value: 'dark' }],
          color: { 'color-space': 'srgb', components: toComponents(darkHex) },
          idiom: 'universal',
        },
      ],
      info: { author: 'xcode', version: 1 },
    }, null, 2)
  },
})

Esta automatización garantiza que cada color en el catálogo de assets permanezca sincronizado con los design tokens sin interacción manual en Xcode.

Colores dinámicos para el modo oscuro

El soporte del modo oscuro en iOS/iPadOS no es opcional para aplicaciones que apuntan a iOS 13 y versiones posteriores. Los colores del catálogo de assets con variantes Any/Dark gestionan el cambio automáticamente, pero hay matices que vale la pena entender.

Colores adaptativos del sistema

Antes de definir colores personalizados, comprueba si el sistema proporciona lo que necesitas. Los colores del sistema de Apple cubren los casos de uso más comunes y se adaptan automáticamente al modo claro, oscuro y de accesibilidad:

Color Modo claro Modo oscuro Uso
.systemBackground #FFFFFF #000000 Fondo principal
.secondarySystemBackground #F2F2F7 #1C1C1E Fondo de tabla agrupada
.label #000000 #FFFFFF Texto principal
.secondaryLabel #3C3C43 al 60% de opacidad #EBEBF5 al 60% de opacidad Texto secundario
.systemBlue #007AFF #0A84FF Azul estándar
.systemRed #FF3B30 #FF453A Error/destructivo
.systemGreen #34C759 #30D158 Éxito

Para la UI informativa estándar (vistas de tabla, alertas, hojas del sistema), prefiere los colores del sistema sobre los personalizados. Se adaptan automáticamente a los futuros cambios de diseño de iOS y no requieren mantenimiento.

Colores dinámicos personalizados en código

Cuando los colores del sistema son insuficientes, define colores dinámicos en código usando el inicializador basado en clausuras de UIColor:

// Colors.swift
import UIKit
import SwiftUI

enum BrandColor {
    static let primary = UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(hex: "#60A5FA")  // blue-400 para fondo oscuro
            : UIColor(hex: "#2563EB")  // blue-600 para fondo claro
    }

    static let surface = UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(hex: "#0F172A")  // slate-900
            : UIColor(hex: "#FFFFFF")  // blanco
    }

    static let surfaceElevated = UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(hex: "#1E293B")  // slate-800
            : UIColor(hex: "#F8FAFC")  // slate-50
    }
}

Para los colores SwiftUI correspondientes:

extension Color {
    static let brandPrimary     = Color(uiColor: BrandColor.primary)
    static let brandSurface     = Color(uiColor: BrandColor.surface)
    static let brandSurfaceElevated = Color(uiColor: BrandColor.surfaceElevated)
}

Elección de los tonos para el modo oscuro

El principio para seleccionar tonos en modo oscuro refleja lo que aplica en diseño web: un color legible sobre un fondo claro necesita una variante más clara para mantener la legibilidad sobre un fondo oscuro. Usa el Generador de tonos para explorar el matiz de tu marca a lo largo del rango completo 50–950. Para un azul primario:

  • Modo claro: rango 600–700 (azul oscuro, contrasta bien sobre blanco)
  • Modo oscuro: rango 300–400 (azul más claro, contrasta bien sobre casi negro)

Ingresa el hex azul primario de tu marca en el generador de tonos. La tabla de salida permite comparar los tonos uno al lado del otro y elegir el nivel adecuado para cada contexto de superficie.

Extensiones de color personalizadas en Swift

Los inicializadores UIColor(hex:) en bruto y los literales de cadena Color("TokenName") dispersos en el código de vista son un riesgo de mantenimiento — los errores tipográficos causan fallos silenciosos en tiempo de ejecución en lugar de errores en tiempo de compilación. Una extensión de color tipada elimina esto:

Inicializador hex de UIColor

extension UIColor {
    convenience init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 6:
            (a, r, g, b) = (255, (int >> 16) & 255, (int >> 8) & 255, int & 255)
        case 8:
            (a, r, g, b) = ((int >> 24) & 255, (int >> 16) & 255, (int >> 8) & 255, int & 255)
        default:
            (a, r, g, b) = (255, 0, 0, 0)
        }
        self.init(
            red:   CGFloat(r) / 255,
            green: CGFloat(g) / 255,
            blue:  CGFloat(b) / 255,
            alpha: CGFloat(a) / 255
        )
    }
}

Espacio de nombres de color tipado

En lugar de Color("BrandPrimary") en todas partes, define un espacio de nombres tipado:

// DesignSystem/Colors.swift

import SwiftUI

extension Color {
    // MARK: — Marca

    /// Color de acción principal. Se adapta al modo claro/oscuro.
    static let brandPrimary = Color("BrandPrimary", bundle: .main)

    /// Estado hover/presionado de brandPrimary. Normalmente un tono más oscuro.
    static let brandPrimaryHover = Color("BrandPrimaryHover", bundle: .main)

    // MARK: — Superficie

    /// Fondo de la página. En la mayoría de los casos, `systemBackground`; úsalo para versiones ajustadas a la marca.
    static let surface = Color("Surface", bundle: .main)

    /// Superficie de tarjeta elevada. Ligeramente más clara que `surface` en modo oscuro como indicación de elevación.
    static let surfaceElevated = Color("SurfaceElevated", bundle: .main)

    // MARK: — Texto

    /// Texto de cuerpo principal. Mayor contraste sobre la superficie actual.
    static let textPrimary = Color("TextPrimary", bundle: .main)

    /// Texto secundario. Metadatos, créditos, pies de foto.
    static let textSecondary = Color("TextSecondary", bundle: .main)

    // MARK: — Retroalimentación

    /// Estado de éxito: confirmaciones, indicadores de completado.
    static let feedbackSuccess = Color("FeedbackSuccess", bundle: .main)

    /// Estado de advertencia: precauciones, funcionalidad degradada.
    static let feedbackWarning = Color("FeedbackWarning", bundle: .main)

    /// Estado de error/destructivo: fallos de validación, acciones destructivas.
    static let feedbackError = Color("FeedbackError", bundle: .main)
}

El uso en las vistas se vuelve fuertemente tipado:

struct PrimaryButton: View {
    let title: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title)
                .font(.headline)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .padding(.vertical, 14)
                .background(Color.brandPrimary)
                .clipShape(RoundedRectangle(cornerRadius: 10))
        }
    }
}

Un error tipográfico en un nombre de color como Color.brandPrimry es ahora un error en tiempo de compilación en lugar de un fallo o un color de reserva invisible.

Colores de accesibilidad y modo de alto contraste

SwiftUI y UIKit admiten la configuración de accesibilidad Aumentar contraste (Ajustes → Accesibilidad → Pantalla y tamaño del texto → Aumentar contraste). Esta configuración es usada por usuarios que necesitan mayor separación visual entre elementos.

Proporcionar variantes de alto contraste

Los conjuntos de color del catálogo de assets admiten variantes de alto contraste además de claro/oscuro:

  1. En Xcode, selecciona tu conjunto de colores.
  2. En el inspector de atributos, en Appearances, habilita también High Contrast.
  3. Aparecen cuatro ranuras de color: Any (predeterminado), Dark, Any (alto contraste), Dark (alto contraste).
  4. Completa las variantes de alto contraste con proporciones de contraste más fuertes que las variantes predeterminadas.

Por ejemplo, un color gris de texto apagado (#9CA3AF) que cumple WCAG AA con 3.1:1 sobre blanco podría necesitar oscurecerse a #6B7280 (4.8:1) en modo de alto contraste.

Detección del nivel de contraste en código

Al construir colores programáticamente (no mediante catálogo de assets), comprueba la trait collection:

let adaptiveGray = UIColor { traits in
    let isHighContrast = traits.accessibilityContrast == .high
    let isDark = traits.userInterfaceStyle == .dark

    switch (isDark, isHighContrast) {
    case (false, false): return UIColor(hex: "#9CA3AF") // claro normal
    case (false, true):  return UIColor(hex: "#6B7280") // claro de alto contraste
    case (true, false):  return UIColor(hex: "#6B7280") // oscuro normal
    case (true, true):   return UIColor(hex: "#D1D5DB") // oscuro de alto contraste
    }
}

Proporciones de contraste mínimas para iOS

Las aplicaciones iOS que apuntan a las Directrices de Interfaz Humana de Apple deben cumplir estos umbrales de contraste:

Tipo de texto Proporción mínima Nivel WCAG
Texto normal (< 18pt) 4.5:1 AA
Texto grande (≥ 18pt o ≥ 14pt en negrita) 3:1 AA
Componentes de UI y objetos gráficos 3:1 AA
Modo de alto contraste 7:1 recomendado AAA

Usa el Verificador de contraste para comprobar cualquier combinación texto/fondo antes de finalizar tu paleta. Ingresa los valores hex de primer plano y fondo en modo claro, verifica la proporción y repite para el modo oscuro. Ambos modos deben superarla de forma independiente.

Diferenciación sin depender solo del color

WCAG requiere que la información transmitida por el color también se transmita por otro medio — forma, patrón, texto o icono. Esto es crítico para usuarios daltónicos (aproximadamente el 8% de los hombres tienen alguna forma de deficiencia en la visión del color).

En la práctica para iOS:

  • Errores de validación en formularios: Muestra un icono de error y texto descriptivo además de un borde rojo.
  • Estados de éxito/fallo: Usa formas distintas (palomita vs. X) junto a los colores verde/rojo.
  • Gráficos de datos: Usa patrones o etiquetas además de la codificación de color para las diferentes series.
  • Vínculos: Asegúrate de que los vínculos estén subrayados o tengan una forma distintiva, no diferenciados solo por color.
// Correcto: estado de error comunicado por forma + color + texto
struct ValidatedTextField: View {
    let text: String
    let error: String?

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            TextField("", text: .constant(text))
                .padding(10)
                .background(Color.surface)
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .strokeBorder(
                            error != nil ? Color.feedbackError : Color(UIColor.separator),
                            lineWidth: error != nil ? 2 : 1
                        )
                )
            if let error {
                Label(error, systemImage: "exclamationmark.circle.fill")
                    .font(.caption)
                    .foregroundStyle(Color.feedbackError)
            }
        }
    }
}

El error se comunica mediante tres señales: el color rojo del borde, el aumento del grosor del borde (cambio de forma) y el texto de error con un icono. Un usuario daltónico ve la forma y el texto; un usuario con visión normal ve los tres.

Conclusiones clave

  • Las instancias de Color en SwiftUI respaldadas por entradas del catálogo de assets o clausuras dinámicas de UIColor son adaptativas — se resuelven correctamente para el modo claro/oscuro, el espacio de color del display y la configuración de accesibilidad. Las instancias de Color con RGBA fijo son estáticas y deben evitarse en producción.
  • Los conjuntos de color del catálogo de assets son archivos JSON y pueden generarse programáticamente desde design tokens, manteniendo los colores iOS sincronizados con los tokens de web y otras plataformas mediante un pipeline compartido.
  • Los colores dinámicos para el modo oscuro deben usar un tono más claro del mismo matiz, no un valor invertido. Usa el Generador de tonos para encontrar el tono correcto para cada contexto: rango 600–700 para modo claro, rango 300–400 para fondos en modo oscuro.
  • Las extensiones de color Swift tipadas (Color.brandPrimary en lugar de Color("BrandPrimary")) convierten los errores tipográficos de color de fallos en tiempo de ejecución a errores en tiempo de compilación y proporcionan documentación a través del sistema de tipos de Swift.
  • Los catálogos de assets admiten variantes de alto contraste además de variantes claro/oscuro. Proporciónelas para cualquier color usado en texto o delimitación de componentes de UI — el Verificador de contraste comprueba que cada variante cumpla el umbral WCAG correspondiente.
  • La accesibilidad requiere que la información transmitida por el color también se transmita por forma, icono o texto — esto es tanto un requisito WCAG como una necesidad práctica para el 8% de los usuarios masculinos con deficiencia en la visión del color.

Colores relacionados

Marcas relacionadas

Herramientas relacionadas