Skip to content
GitHub Twitter

SwiftUI Examples - Color Picker

Cuando se trata de construir interfaces de usuario en iOS, SwiftUI ha sido una herramienta revolucionaria. Nos permite crear vistas dinámicas, interactivas y visualmente atractivas con un código relativamente conciso y legible.

Inspirado en contenido del libro SwiftUI by Example de Hacking with Swift, en este post vamos a explorar algunas de las increíbles capacidades de SwiftUI para trabajar con colores y gradientes, centrándonos en la construcción de un anillo con gradiente de colores.

Crear un gradiente angular en forma de círculo es un ejemplo clásico de lo sencillo y poderoso que puede ser SwiftUI cuando se trata de personalización visual. Vamos a ver cómo se hace.

--

Creando un anillo con gradiente de colores

Uno de los puntos fuertes de SwiftUI es la capacidad de manejar fácilmente elementos visuales como formas y colores. Aquí te muestro un ejemplo simple para crear un anillo utilizando un gradiente angular.

struct ContentView: View {
    private let circleSize: CGSize = .init(width: 200, height: 200)
    
    private static var colors: [Color] = {
        [
            Color.red,
            Color.yellow,
            Color.green,
            Color.cyan,
            Color.blue,
            Color.purple,
            Color.red // Cierra el ciclo de colores
        ]
    }()
    
    var body: some View {
        VStack(spacing: 24) {
            Circle()
                .fill(Color.clear)
                .strokeBorder(
                    AngularGradient(colors: Self.colors,
                                    center: .center, startAngle: .zero, endAngle: .degrees(360)),
                    lineWidth: 50
                )
                .frame(width: circleSize.width, height: circleSize.height)
        }
    }
}


Hasta aquí todo sencillo y rápido: hemos logrado crear un anillo con gradientes. Usamos un Circle() para la forma del anillo y aplicamos un AngularGradient para darle ese efecto de transición entre los colores. Es decir, el círculo está compuesto por una serie de colores que empiezan en rojo, pasan por varios tonos, y vuelven a cerrar el ciclo nuevamente en rojo.

Para darle el grosor al anillo, en lugar de rellenarlo completamente, utilizamos el método strokeBorder, que básicamente le dice a SwiftUI que aplique el gradiente solo al borde. Este borde tiene un grosor de 50 puntos, como habrás notado en el código.

Lo mejor de todo es que el gradiente sigue de manera fluida el borde circular gracias a la propiedad AngularGradient. Esto asegura que el efecto de transición de colores se vea suave y continuo alrededor de todo el círculo.

Pero viendo este componente, me animé a seguir adelante y crear un selector de color a partir de él. ¡Después de todo, SwiftUI nos permite hacer este tipo de personalización visual con muy poco esfuerzo!

Añadiendo un gesto y un punto para mostrar el color seleccionado

Después de crear el anillo con gradiente, el siguiente paso es hacerlo interactivo. La idea es que podamos tocar cualquier parte del anillo para seleccionar un color y ver el resultado en tiempo real en una pequeña vista. Para lograr esto, añadiremos un punto de selección y un gesto para detectar dónde se hace el toque.

Cambios clave en el código:

Primero, vamos a necesitar una nueva propiedad @State para almacenar el color que el usuario selecciona y otra para la posición del punto de selección en el anillo.

@State var selectedColor: Color = .black
@State private var point: CGPoint = .zero
  • selectedColor: Esta propiedad almacenará el color que el usuario seleccionará, aunque en este primer paso aún no lo estamos actualizando.
  • point: Guardará la posición del punto donde el usuario ha tocado en el anillo.

Ahora que tenemos el estado preparado, añadimos un círculo que mostrará el color seleccionado. Este será un círculo pequeño en la parte superior del anillo que reflejará el color escogido, aunque, de momento, se queda fijo en negro.

Circle()
    .fill(selectedColor)
    .frame(width: 100, height: 100, alignment: .center)
    .overlay(Circle().stroke(Color.white, lineWidth: 2))

  • El círculo que se genera aquí actúa como un visualizador del color seleccionado. Lo envolvemos con un borde blanco para darle un contraste visual y destacarlo mejor.

Agregando el gesto y el punto de selección:

Circle()
    .fill(Color.clear)
    .strokeBorder(
        AngularGradient(colors: Self.colors,
                        center: .center, startAngle: .zero, endAngle: .degrees(360)),
        lineWidth: 50
    )
    .overlay {
        // Selector de color (punto blanco)
        Circle()
            .fill(.white)
            .frame(width: 12, height: 12)
            .offset(getOffset(for: point, in: circleSize))
    }
    .gesture(
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                let newPoint = value.location
                point = calculatePointOnRing(from: newPoint, in: circleSize)
            }
    )
    .frame(width: circleSize.width, height: circleSize.height)

Aquí es donde ocurre la magia de la interacción. A continuación te detallo los puntos clave:

  1. El punto de selección: Hemos añadido un pequeño círculo blanco que sirve como el marcador visual de donde se ha tocado. Usamos el método .offset para posicionarlo en la ubicación correcta del anillo basándonos en las coordenadas calculadas.

  2. Gesto DragGesture: Añadimos un DragGesture para detectar los toques en la pantalla. El gesto captura la ubicación del toque y actualiza la posición del punto de selección con calculatePointOnRing, lo que garantiza que el punto siempre permanezca dentro del anillo.

  3. Cálculo del punto en el anillo: Para mantener el punto de selección dentro del anillo, utilizamos las funciones auxiliares getOffset y calculatePointOnRing, que calculan el ángulo y la distancia para asegurarse de que el punto siempre esté en el borde.

getOffset:

 private func getOffset(for point: CGPoint, in size: CGSize) -> CGSize {
        let radius = size.width / 2
        let dx = point.x - radius
        let dy = point.y - radius
        return CGSize(width: dx, height: dy)
    }

Este método toma como parámetros el punto donde el usuario toca y el tamaño del círculo. Su función es calcular el desplazamiento adecuado del punto para posicionarlo dentro del anillo. La fórmula básicamente calcula la diferencia (dx y dy) entre las coordenadas del toque y el centro del círculo.

El resultado es un CGSize que se usa para desplazar el punto a lo largo del borde del anillo. De esta manera, el punto siempre permanece "pegado" al borde del círculo, sin importar dónde toquemos.

calculatePointOnRing:

private func calculatePointOnRing(from location: CGPoint, in size: CGSize) -> CGPoint {
        let radius = size.width / 2
        let dx = location.x - radius
        let dy = location.y - radius

        // Calcular el ángulo
        let angle = atan2(dy, dx)

        // Ajustar el radio para mantener el punto en el centro del anillo (25 es la mitad del grosor)
        let ringRadius = radius - 25

        // Calcular las nuevas coordenadas del punto en el anillo
        let newX = radius + ringRadius * cos(angle)
        let newY = radius + ringRadius * sin(angle)

        return CGPoint(x: newX, y: newY)
    }

Aquí es donde realmente se calcula el ángulo y las nuevas coordenadas para que el punto esté correctamente alineado en el anillo. Esta función utiliza un poco de trigonometría para encontrar la ubicación adecuada.

  • Primero, calculamos el ángulo con atan2(dy, dx), que nos dice en qué dirección se ha hecho el toque.
  • Luego ajustamos el radio para que el punto no se salga del borde del anillo.
  • Finalmente, calculamos las nuevas coordenadas en el anillo usando cos(angle) y sin(angle) para mantener el punto en la circunferencia, asegurando que siempre esté en el borde del anillo.

Estado actual

Hasta este momento, hemos logrado hacer que el punto blanco se mueva alrededor del anillo conforme tocamos en diferentes lugares, pero aún no hemos vinculado este gesto con la selección real de un color. Así que, por ahora, solo estamos posicionando el punto, pero no cambiamos el color seleccionado.

En la siguiente sección, haremos que el color realmente cambie según el punto seleccionado en el anillo. ¡Aquí es donde la interacción cobra vida!

Actualizando el color seleccionado a partir de la posición del punto

Ahora que ya podemos mover el punto de selección en el anillo, es hora de actualizar el color seleccionado según la posición de este punto. Para lograrlo, necesitaremos calcular el hue (tono) en función del ángulo del punto en el anillo y luego actualizar el color utilizando ese valor junto con la saturación y el brillo.

Nuevos cambios en el código:

Añadimos propiedades @State para el [object Object], la saturación y el brillo:

@State private var hue: CGFloat = 0.0 // Para almacenar el tono del gradiente
private var saturation: CGFloat = 1.0 
private var brightness: CGFloat = 1.0 

Estas nuevas propiedades nos permitirán almacenar los valores de tono, saturación y brillo que se utilizarán para actualizar el color dinámicamente.

Actualizamos el color seleccionado con updateColor():

private func updateColor() {
    selectedColor = Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness))
}

La función updateColor() combina el tono, la saturación y el brillo para generar un nuevo color cada vez que el punto en el anillo cambia.

Cálculo del tono basado en la posición del toque:

private func getHue(at location: CGPoint, in size: CGSize) -> CGFloat {
    let radius = size.width / 2
    let dx = location.x - radius
    let dy = location.y - radius
    
    // Calcular el ángulo en radianes desde la posición del toque
    var angle = atan2(dy, dx)
    
    // Asegurar que el ángulo sea positivo (de 0 a 2π)
    if angle < 0 { angle += 2 * .pi }
    
    // Normalizar el ángulo para que esté entre 0 y 1 (Hue)
    return angle / (2 * .pi)
}

Esta función convierte la posición en el anillo en un tono. Lo que hacemos aquí es calcular el ángulo en el que se encuentra el punto dentro del círculo, asegurándonos de que sea un valor positivo. Luego normalizamos el ángulo para que esté en el rango de 0 a 1, que es el rango que SwiftUI utiliza para representar el tono en los colores.

Modificación en el gesto DragGesture:

DragGesture(minimumDistance: 0)
    .onChanged { value in
        let newPoint = value.location
        point = calculatePointOnRing(from: newPoint, in: circleSize)
        hue = getHue(at: point, in: circleSize)
        updateColor() // Actualizar color con sliders de saturación y brillo
    }

Aquí es donde realmente conectamos todo. Cuando el usuario arrastra el dedo por el anillo, primero calculamos la nueva posición del punto y luego usamos esa posición para determinar el tono. Finalmente, actualizamos el color utilizando la función updateColor() para reflejar el cambio en tiempo real, ajustando el tono en función de la ubicación del punto.

Ahora, a medida que el punto blanco se mueve, también cambia el color en el círculo pequeño de selección, ofreciendo una retroalimentación visual instantánea.

Añadiendo Sliders de Saturación y Brillo

Para mejorar la selección de colores y permitir la elección de tonos blancos y negros, vamos a añadir sliders que ajusten la saturación y el brillo del color. De este modo, podremos ajustar tanto el tono (hue) como estos dos parámetros para obtener cualquier color en el espectro, incluidos los valores extremos como el blanco y el negro.

Cambios introducidos en el código:

Propiedades para saturation y brightness:

@State private var saturation: CGFloat = 1.0 // Saturación ajustable por el slider
@State private var brightness: CGFloat = 1.0 // Brillo ajustable por el slider

Estas propiedades @State almacenan los valores de saturación y brillo. Inicialmente, se configuran con valores predeterminados, pero los sliders permitirán al usuario modificarlos dinámicamente.

Sliders para ajustar la saturación y el brillo:

// Slider para ajustar la saturación
VStack {
    Text("Saturation")
    Slider(value: $saturation, in: 0...1) {
        Text("Saturation")
    }
    .onChange(of: saturation) { oldValue, newValue in
        updateColor()
    }
}

// Slider para ajustar el brillo
VStack {
    Text("Brightness")
    Slider(value: $brightness, in: 0...1) {
        Text("Brightness")
    }
    .onChange(of: brightness) { oldValue, newValue in
        updateColor()
    }
}

Estos sliders permiten ajustar los valores de saturación y brillo en tiempo real. Cada vez que el usuario cambia el valor, se llama a la función updateColor() para reflejar los nuevos valores en el color seleccionado.

Modificación de updateColor() para considerar saturación y brillo:

private func updateColor() {
    selectedColor = Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness))
}

En esta función, ahora se usa no solo el hue, sino también la saturación y el brillo proporcionados por los sliders para actualizar el color seleccionado.

Con estos ajustes, ahora podemos seleccionar cualquier color del espectro, desde tonos saturados hasta tonos más claros o más oscuros.

Iniciando el Selector de Color con un Valor Inicial

Hasta ahora, el selector de color siempre comenzaba con un color predeterminado y se actualizaba a medida que el usuario interactuaba. Sin embargo, para hacer nuestro componente más flexible y útil en aplicaciones reales, necesitaremos que el selector pueda iniciarse con un color específico. Este cambio es importante porque en muchas situaciones, como cuando queremos cargar una configuración previa o editar un color ya seleccionado, el selector debe reflejar ese color inicial desde el principio.

Además, ahora también necesitamos actualizar la posición del punto y los valores de los sliders de saturación y brillo basándonos en el color que se pase en el constructor.

Cabe destacar que, aunque generalmente trabajamos con valores HEX o RGB para guardar los colores, eso es algo que podemos gestionar en una capa de datos separada. A partir del color seleccionado, podemos convertirlo fácilmente al formato que deseemos.

Cambios introducidos en el código:

Pasamos el color seleccionado como parámetro de entrada usando @Binding:

@Binding var selectedColor: Color

Al pasar el color como un @Binding, logramos que el color seleccionado sea dinámico y que cualquier cambio en el color se refleje en otros lugares de la aplicación que escuchen este valor.

Actualizamos los sliders de saturación y brillo según el color inicial:

private func calculateColorAndPointFromSelected() {
    // Convertir el color seleccionado en sus componentes HSB
    let uiColor = UIColor(selectedColor)
    var hue: CGFloat = 0
    var saturation: CGFloat = 0
    var brightness: CGFloat = 0
    var alpha: CGFloat = 0
    
    // Extraer componentes de HSB
    uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
    
    // Actualizar los sliders de saturación y brillo
    self.saturation = saturation
    self.brightness = brightness
    
    calculatePoint()
}

Esta función se encarga de extraer los valores de tono, saturación y brillo del color seleccionado al iniciar el componente. Esto asegura que el punto y los sliders se ajusten correctamente en función del color con el que se comienza.

Calculamos la posición del punto en el anillo basándonos en el tono:

private func calculatePoint() {
    // Calcular la posición del punto en el anillo basado en el hue
    let radius = circleSize.width / 2
    let angle = hue * 2 * .pi
    let ringRadius = radius - 25
    let newX = radius + ringRadius * cos(angle)
    let newY = radius + ringRadius * sin(angle)
    
    // Actualizar la posición del punto
    point = CGPoint(x: newX, y: newY)
}

La función calculatePoint() posiciona el punto en el anillo en función del hue inicial. Esto garantiza que el selector apunte al color correcto desde el inicio.

Con este último cambio, tenemos un selector de color completamente funcional que no solo permite interactuar en tiempo real, sino que también es capaz de iniciar con un color específico. De esta manera, podrás utilizar este componente en cualquier parte de tu proyecto y hacer que escuche cambios de color dinámicamente.

El código completo de este ejemplo puede consultarse en:
GitHub - Swift Examples.

Finalmente, te animo a que sigas practicando y experimentando con las herramientas que estás aprendiendo. Ir un paso más allá, probando cosas nuevas y haciendo ajustes sobre lo que ya sabes, es una excelente forma de aprender SwiftUI. ¡Sigue explorando y divirtiéndote mientras lo haces!