Programación en Swift y SwiftUI para iOS Developers

Picker personalizado en SwiftUI

Como iOS Developer, sabes que Apple nos proporciona un conjunto increíble de herramientas y componentes de interfaz de usuario predeterminados. El Picker estándar de SwiftUI es fantástico para la mayoría de los casos de uso básicos. Sin embargo, cuando trabajas en un proyecto con un sistema de diseño estricto, o cuando simplemente quieres que tu aplicación destaque entre la multitud con animaciones fluidas y una estética única, el componente estándar puede quedarse corto.

Es aquí donde brilla la programación Swift moderna. En este tutorial, te guiaré paso a paso sobre cómo construir un picker personalizado en SwiftUI desde cero. Lo haremos altamente reutilizable, animado, accesible y, lo más importante, compatible con múltiples plataformas: iOS, macOS y watchOS, todo desde un único proyecto en Xcode.


1. Por Qué Necesitamos un Picker Personalizado

Antes de sumergirnos en Xcode y escribir código, es crucial entender por qué y cuándo debemos alejarnos de los componentes nativos.

El Picker nativo en SwiftUI adapta su estilo dependiendo de la plataforma y del modificador .pickerStyle(). Puede ser un menú desplegable, un control segmentado (SegmentedPickerStyle), o una rueda (WheelPickerStyle). Aunque esto es genial para la consistencia del sistema operativo, presenta limitaciones:

  1. Falta de control sobre la altura y el padding: El control segmentado nativo tiene un tamaño fijo que es difícil de modificar.
  2. Limitaciones de color: Cambiar el color de fondo del elemento seleccionado o el color del texto nativo no siempre responde bien a nuestras necesidades de branding.
  3. Animaciones restringidas: No podemos modificar cómo transiciona el indicador de un elemento a otro.

Al crear nuestro propio picker personalizado en SwiftUI, obtenemos control total sobre cada píxel, cada milisegundo de animación y cada estado de accesibilidad.


2. Preparando el Entorno en Xcode

Para comenzar, abre Xcode y crea un nuevo proyecto multiplataforma (Multiplatform App) o simplemente una aplicación para iOS si prefieres enfocarte primero en esa plataforma.

Asegúrate de seleccionar SwiftUI como la interfaz y Swift como el lenguaje.

Requisitos previos:

  • Conocimientos intermedios de programación Swift.
  • Familiaridad con los modificadores de SwiftUI y el manejo de estado (@State, @Binding).
  • Xcode 14 o superior (recomendado Xcode 15+ para aprovechar las últimas optimizaciones de Swift).

3. Definiendo el Modelo de Datos

Para que nuestro picker sea verdaderamente profesional y reutilizable, no debemos codificar cadenas de texto (Strings) directamente. En su lugar, utilizaremos Generics y el protocolo Hashable. Para este ejemplo, crearemos un enum simple, pero nuestro componente final aceptará cualquier tipo de dato.

Crea un nuevo archivo llamado PickerOpciones.swift:

import Foundation

// Definimos un enum que representará las opciones de nuestro picker de ejemplo
enum PeriodoTiempo: String, CaseIterable, Identifiable {
    case dia = "Día"
    case semana = "Semana"
    case mes = "Mes"
    case anio = "Año"
    
    var id: String { self.rawValue }
}

Al conformar CaseIterable e Identifiable, facilitamos la iteración sobre estas opciones dentro de las vistas de SwiftUI.


4. Construyendo la Vista del Picker Personalizado en SwiftUI

Ahora vamos a crear el núcleo de nuestro componente. Queremos un diseño tipo “Segmented Control”, pero con esquinas más redondeadas, colores personalizados y una animación fluida cuando el usuario cambia de opción.

Crea un nuevo archivo de vista de SwiftUI llamado CustomSegmentedPicker.swift.

4.1 La Estructura Básica y Generics

Para hacer de esto una herramienta digna de un iOS Developer Senior, usaremos genéricos para que el picker pueda aceptar cualquier tipo de opción.

import SwiftUI

struct CustomSegmentedPicker<T: Hashable & Identifiable & RawRepresentable>: View where T.RawValue == String {
    
    // El estado seleccionado que se comparte con la vista padre
    @Binding var selection: T
    
    // Las opciones disponibles
    let options: [T]
    
    // Namespace para la animación del fondo (Matched Geometry Effect)
    @Namespace private var animationNamespace
    
    // Colores personalizables
    var activeBgColor: Color = .blue
    var inactiveBgColor: Color = Color.gray.opacity(0.2)
    var activeTextColor: Color = .white
    var inactiveTextColor: Color = .primary
    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(options) { option in
                pickerItem(for: option)
            }
        }
        .padding(4)
        .background(inactiveBgColor)
        .clipShape(Capsule())
    }
}

4.2 Detallando el Elemento Individual (Picker Item)

Dentro de la misma estructura CustomSegmentedPicker, vamos a añadir el método que construye cada botón individual. Aquí es donde ocurre la magia de la animación en Swift.

extension CustomSegmentedPicker {
    
    @ViewBuilder
    private func pickerItem(for option: T) -> some View {
        let isSelected = selection == option
        
        ZStack {
            if isSelected {
                Capsule()
                    .fill(activeBgColor)
                    // El secreto para una animación fluida estilo Apple
                    .matchedGeometryEffect(id: "activeBackground", in: animationNamespace)
            }
            
            Text(option.rawValue)
                .font(.system(size: 14, weight: isSelected ? .bold : .medium, design: .rounded))
                .foregroundColor(isSelected ? activeTextColor : inactiveTextColor)
                .padding(.vertical, 10)
                .padding(.horizontal, 16)
                .lineLimit(1)
                .minimumScaleFactor(0.8)
        }
        .contentShape(Capsule())
        .onTapGesture {
            withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7, blendDuration: 0.2)) {
                selection = option
            }
        }
        // Accesibilidad: Muy importante para cualquier iOS Developer
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(Text(option.rawValue))
        .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
    }
}

Explicación Técnica de la Implementación:

  1. Matched Geometry Effect: El modificador .matchedGeometryEffect es una de las herramientas más potentes en la programación Swift para UI. Le dice a SwiftUI que rastree la geometría de la Capsule() de fondo. Cuando la variable selection cambia, SwiftUI no destruye y recrea el fondo en una nueva posición al instante; en su lugar, interpola (anima) el tamaño y la posición desde el botón anterior al nuevo.
  2. Animación Spring: En lugar de un simple .easeInOut, usamos .interactiveSpring. Esto imita la física real y es lo que le da a las aplicaciones de Apple ese toque “premium”.
  3. Accesibilidad: Un verdadero profesional no olvida el VoiceOver. Hemos añadido accessibilityAddTraits para que los usuarios con discapacidad visual sepan qué elemento está seleccionado y que funciona como un botón.

5. Implementación en la Vista Principal (iOS y macOS)

Ahora que tenemos nuestro picker personalizado en SwiftUI, vamos a usarlo en nuestra ContentView.

import SwiftUI

struct ContentView: View {
    @State private var periodoSeleccionado: PeriodoTiempo = .semana
    
    var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                
                // Nuestro Custom Picker
                CustomSegmentedPicker(
                    selection: $periodoSeleccionado,
                    options: PeriodoTiempo.allCases,
                    activeBgColor: .indigo,
                    inactiveBgColor: .gray.opacity(0.15)
                )
                .padding(.horizontal)
                
                // Contenido que reacciona al picker
                VStack(spacing: 20) {
                    Image(systemName: iconFor(periodo: periodoSeleccionado))
                        .font(.system(size: 80))
                        .foregroundColor(.indigo)
                        // Añadimos una transición para el cambio de contenido
                        .transition(.scale.combined(with: .opacity))
                        .id(periodoSeleccionado) // Fuerza a SwiftUI a recrear la vista para animar
                    
                    Text("Estás viendo los datos de la \(periodoSeleccionado.rawValue.lowercased())")
                        .font(.headline)
                        .foregroundColor(.secondary)
                }
                
                Spacer()
            }
            .padding(.top, 40)
            .navigationTitle("Estadísticas")
            // Usamos animation global para el cambio de contenido
            .animation(.easeInOut, value: periodoSeleccionado)
        }
    }
    
    // Función helper para cambiar el icono
    private func iconFor(periodo: PeriodoTiempo) -> String {
        switch periodo {
        case .dia: return "sun.max.fill"
        case .semana: return "calendar"
        case .mes: return "chart.bar.doc.horizontal"
        case .anio: return "globe.americas.fill"
        }
    }
}

Si ejecutas esto en tu simulador de Xcode para iOS o macOS, verás un control elegante, con una transición suave del fondo índigo y un cambio dinámico en el contenido inferior.


6. Adaptando el Picker para watchOS

Una de las grandes ventajas de SwiftUI es poder compartir código entre plataformas. Sin embargo, la pantalla de un Apple Watch es radicalmente más pequeña que la de un iPhone o un Mac. Un HStack con 4 opciones se desbordará de la pantalla en watchOS, rompiendo la interfaz.

Como iOS Developer experimentado, debes usar directivas de compilación y adaptar el diseño.

Vamos a modificar ligeramente nuestro componente principal CustomSegmentedPicker para que, cuando compile en watchOS, se disponga en formato de lista vertical (VStack) o en un ScrollView horizontal.

Regresemos a CustomSegmentedPicker.swift y modifiquemos la variable body:

    var body: some View {
        #if os(watchOS)
        // En watchOS usamos un ScrollView horizontal si hay muchas opciones, o un VStack
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(options) { option in
                    pickerItem(for: option)
                }
            }
            .padding(4)
        }
        #else
        // En iOS y macOS mantenemos el HStack adaptativo
        HStack(spacing: 0) {
            ForEach(options) { option in
                pickerItem(for: option)
            }
        }
        .padding(4)
        .background(inactiveBgColor)
        .clipShape(Capsule())
        #endif
    }

Para el pickerItem en watchOS, también podemos ajustar el tamaño del texto y el padding dinámicamente:

    @ViewBuilder
    private func pickerItem(for option: T) -> some View {
        let isSelected = selection == option
        
        ZStack {
            if isSelected {
                Capsule()
                    .fill(activeBgColor)
                    .matchedGeometryEffect(id: "activeBackground", in: animationNamespace)
            } else {
                #if os(watchOS)
                // En watchOS le damos un fondo visible a los inactivos también
                Capsule()
                    .fill(Color.gray.opacity(0.3))
                #endif
            }
            
            Text(option.rawValue)
                // Ajustamos fuente según OS
                #if os(watchOS)
                .font(.system(size: 12, weight: isSelected ? .bold : .regular))
                .padding(.vertical, 8)
                .padding(.horizontal, 12)
                #else
                .font(.system(size: 14, weight: isSelected ? .bold : .medium, design: .rounded))
                .padding(.vertical, 10)
                .padding(.horizontal, 16)
                #endif
                .foregroundColor(isSelected ? activeTextColor : inactiveTextColor)
                .lineLimit(1)
        }
        .contentShape(Capsule())
        // ... (resto del código de gestos y accesibilidad se mantiene igual)

Gracias a estas simples directivas #if os(watchOS), Xcode compilará el componente adecuado para el dispositivo destino. El Apple Watch mostrará un carrusel de píldoras deslizable de izquierda a derecha, mientras que iOS mostrará el control segmentado clásico unificado.


7. Temas Avanzados: Manejo de Entornos y Preferencias

Para que este componente se sienta como una API nativa de Apple, en lugar de pasar los colores en el inicializador cada vez, podemos utilizar los @Environment values de SwiftUI. Esto es un nivel superior en la programación Swift.

Podemos definir nuestras propias claves de entorno (Environment Keys) para el color de acento de nuestro picker.

// Definimos la clave de entorno
private struct PickerAccentColorKey: EnvironmentKey {
    static let defaultValue: Color = .blue
}

// Extendemos EnvironmentValues
extension EnvironmentValues {
    var customPickerAccentColor: Color {
        get { self[PickerAccentColorKey.self] }
        set { self[PickerAccentColorKey.self] = newValue }
    }
}

// Creamos un modificador para facilitar su uso
extension View {
    func customPickerAccentColor(_ color: Color) -> some View {
        environment(\.customPickerAccentColor, color)
    }
}

Ahora, en nuestro CustomSegmentedPicker, podemos reemplazar la propiedad activeBgColor por la lectura del entorno:

@Environment(\.customPickerAccentColor) var activeBgColor

Esto permite al iOS Developer establecer el color del picker desde una vista superior, aplicándose a todos los pickers dentro de esa jerarquía, igual que como funciona .accentColor() nativo.

// Ejemplo de uso con Environment
VStack {
    CustomSegmentedPicker(selection: $estado, options: Opciones.allCases)
    CustomSegmentedPicker(selection: $filtro, options: Filtros.allCases)
}
.customPickerAccentColor(.orange) // Aplica naranja a AMBOS pickers a la vez

8. Optimizando el Rendimiento (Performance)

Cuando desarrollas un picker personalizado en SwiftUI, es fácil introducir problemas de rendimiento si no tienes cuidado, especialmente cuando usas MatchedGeometryEffect en listas grandes o componentes complejos.

Aquí hay tres consejos vitales de rendimiento que debes implementar en Xcode:

  1. Evita el uso de vistas pesadas dentro del Item: Mantén el pickerItem lo más ligero posible. Usa primitivas de Text y Capsule. No incrustes imágenes grandes ni realices cálculos complejos dentro de este método, ya que se renderiza varias veces (una por cada opción).
  2. El modificador .id(): Como vimos en el ejemplo de ContentView, usamos .id(periodoSeleccionado) en la vista que reacciona al picker. Esto es crucial. Le dice a SwiftUI explícitamente: “Cuando el ID cambie, trata esto como una vista completamente nueva”. Esto asegura que las transiciones (.transition) se ejecuten sin esfuerzo y evita sobrecargas de estado innecesarias.
  3. Animaciones explícitas vs implícitas: En nuestro código usamos withAnimation { selection = option } en el evento onTapGesture. Esto es una animación explícita. Solo anima los cambios de estado resultantes de ese toque específico. Evita usar .animation(.default) al final del HStack completo, ya que esto podría provocar que otros redibujados no relacionados también se animen accidentalmente (un bug visual muy común en la programación Swift).

9. Pruebas y Depuración en Xcode

Un buen flujo de trabajo de desarrollo en Swift implica pruebas constantes. Xcode ofrece Previews que son invaluables para iterar el diseño de nuestro picker.

Al final de tu archivo CustomSegmentedPicker.swift, asegúrate de configurar un Preview exhaustivo que pruebe los diferentes estados:

struct CustomSegmentedPicker_Previews: PreviewProvider {
    static var previews: some View {
        VStack(spacing: 30) {
            // Preview Modo Claro
            StatefulPreviewWrapper(.semana) { binding in
                CustomSegmentedPicker(selection: binding, options: PeriodoTiempo.allCases)
            }
            
            // Preview Modo Oscuro y Color Personalizado
            StatefulPreviewWrapper(.anio) { binding in
                CustomSegmentedPicker(
                    selection: binding, 
                    options: PeriodoTiempo.allCases,
                    activeBgColor: .green,
                    inactiveBgColor: .gray.opacity(0.3)
                )
            }
            .preferredColorScheme(.dark)
        }
        .padding()
        .previewLayout(.sizeThatFits)
    }
}

// Un wrapper necesario en SwiftUI para hacer Previews de bindings que funcionen al hacer clic
struct StatefulPreviewWrapper<Value, Content: View>: View {
    @State var value: Value
    var content: (Binding<Value>) -> Content

    init(_ value: Value, content: @escaping (Binding<Value>) -> Content) {
        self._value = State(wrappedValue: value)
        self.content = content
    }

    var body: some View {
        content($value)
    }
}

Gracias a este StatefulPreviewWrapper, puedes interactuar con el picker directamente en el Canvas de Xcode sin necesidad de compilar la aplicación entera en un simulador. Verás las animaciones del MatchedGeometryEffect en tiempo real.


10. Conclusión y Siguientes Pasos

Hemos pasado de depender de los componentes estándar restrictivos a construir un sistema de UI modular, animado y accesible.

Crear un picker personalizado en SwiftUI te enseña conceptos fundamentales y avanzados del framework: el manejo de genéricos, animaciones interactivas basadas en geometría espacial (MatchedGeometryEffect), accesibilidad manual y adaptabilidad multiplataforma (iOS, macOS, watchOS).

Como iOS Developer, tu objetivo principal no es solo hacer que el código funcione, sino crear interfaces de usuario que deleiten a los usuarios, que se sientan vivas y que refuercen la identidad del producto en el que estás trabajando. El entorno de Xcode y el poder de SwiftUI hacen que esta tarea sea más declarativa y menos propensa a errores que nunca.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Mejores motores de juegos para Mac

Related Posts