Programación en Swift y SwiftUI para iOS Developers

Cómo seleccionar múltiples items en un Picker en SwiftUI

SwiftUI ha revolucionado la forma en que construimos interfaces para el ecosistema de Apple. Su sintaxis declarativa y su capacidad para previsualizar cambios en tiempo real son una bendición. Sin embargo, a pesar de sus inmensas ventajas, los desarrolladores que vienen de UIKit o de la web a menudo se topan con una pared cuando intentan implementar patrones de interfaz que parecen triviales, pero que no tienen un componente nativo directo.

Uno de los casos más notorios es el Picker de selección múltiple.

El componente nativo Picker de SwiftUI es excelente para opciones mutuamente excluyentes (“uno de muchos”), pero ¿qué pasa si quieres que tu usuario seleccione múltiples categorías, etiquetas o destinatarios de una lista? En UIKit, esto requeriría un UITableView con gestión de estados de celdas. En HTML, sería un simple <select multiple>. En SwiftUI, sorprendentemente, no existe un MultiPicker nativo “out of the box” que se comporte como un formulario estándar.

En este tutorial exhaustivo, no solo construiremos una solución; diseñaremos un componente genérico, reutilizable y profesional que podrás copiar y pegar en cualquiera de tus proyectos. Aprenderemos sobre Genéricos, gestión de estados con Set, optimización de rendimiento y accesibilidad.


El Problema: ¿Por qué no usar simplemente una Lista?

Antes de escribir código, definamos el problema de UX (Experiencia de Usuario).

Podrías pensar: “Simplemente usaré una List con un EditButton. SwiftUI permite la selección múltiple en listas cuando el entorno está en modo de edición (.environment(\.editMode, .constant(.active))).

Sin embargo, esta solución tiene limitaciones de diseño:

  1. Estética de Edición: Muestra círculos de selección a la izquierda, lo cual grita “estoy borrando correos”, no “estoy seleccionando mis intereses”.
  2. Contexto: A menudo queremos este componente dentro de un Form, donde el usuario toca una fila, navega a una pantalla de detalle, selecciona varias opciones y regresa. La lista en modo edición rompe este flujo de navegación estándar de iOS.

Nuestro objetivo es replicar la experiencia de selección múltiple que ves en las aplicaciones de Configuración de iOS: una navegación limpia, checkmarks (palomitas) a la derecha y un resumen de lo seleccionado en la vista padre.


Parte 1: Fundamentos y Estructura de Datos

Para hacer esto correctamente, necesitamos alejarnos de los tipos de datos simples. No queremos seleccionar solo Strings; queremos seleccionar objetos completos (Usuarios, Productos, Etiquetas).

Para que nuestro componente funcione, necesitamos que nuestros datos cumplan dos protocolos clave:

  • Identifiable: Para que SwiftUI pueda distinguir cada fila.
  • Hashable: Para poder almacenar la selección en un Set (Conjunto) de manera eficiente.

El Modelo de Datos

Imaginemos que estamos construyendo una app para gestionar equipos de proyecto. Necesitamos seleccionar varios miembros para una tarea.

struct TeamMember: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let role: String
    let avatar: String // Nombre del icono SF Symbol
}

// Datos de prueba
let allMembers = [
    TeamMember(name: "Ana García", role: "iOS Dev", avatar: "iphone"),
    TeamMember(name: "Carlos Ruiz", role: "Backend", avatar: "server.rack"),
    TeamMember(name: "Elena Torres", role: "Diseñadora", avatar: "paintpalette"),
    TeamMember(name: "David Kim", role: "PM", avatar: "briefcase"),
    TeamMember(name: "Sofia L.", role: "QA", avatar: "ant")
]

Parte 2: La Lógica de Selección (Sets vs Arrays)

Aquí es donde muchos tutoriales fallan. A menudo usan un Array para guardar los items seleccionados.

Error común: Usar [TeamMember]Solución Pro: Usar Set<TeamMember>.

¿Por qué?

  1. Rendimiento: Verificar si un item está seleccionado en un Array es una operación O(n) (tiene que recorrer la lista). En un Set, es O(1) (tiempo constante gracias al hash). Si tienes una lista de 500 elementos, la diferencia se nota en la fluidez del scroll.
  2. Unicidad: Un Set garantiza matemáticamente que no tendrás al mismo usuario seleccionado dos veces por error.

Parte 3: Construyendo la Vista de Selección (El “Detail View”)

Primero, crearemos la pantalla a la que el usuario navega para marcar las opciones. La llamaremos MultiSelectionView.

Queremos que esta vista sea Genérica. No debe saber nada sobre TeamMember. Debe funcionar con cualquier tipo T.

import SwiftUI

struct MultiSelectionView<T: Identifiable & Hashable>: View {
    // Título de la navegación
    let title: String
    
    // Todas las opciones posibles
    let options: [T]
    
    // Binding a la selección externa. Usamos Set para O(1) lookup.
    @Binding var selection: Set<T>
    
    // Un closure para saber cómo mostrar el texto de cada celda
    let textRepresentation: (T) -> String

    var body: some View {
        List {
            ForEach(options) { item in
                Button(action: {
                    toggleSelection(item)
                }) {
                    HStack {
                        Text(textRepresentation(item))
                            .foregroundColor(.primary)
                        
                        Spacer()
                        
                        // Lógica visual del Checkmark
                        if selection.contains(item) {
                            Image(systemName: "checkmark")
                                .foregroundColor(.blue)
                                .fontWeight(.bold)
                        }
                    }
                }
                .tag(item.id) // Buena práctica para tests
            }
        }
        .navigationTitle(title)
        .listStyle(.insetGrouped) // Estilo moderno de iOS
    }

    // Lógica privada para manejar la selección
    private func toggleSelection(_ item: T) {
        if selection.contains(item) {
            selection.remove(item)
        } else {
            selection.insert(item)
        }
    }
}

Análisis del Código

  1. Generics <T: ...>: Al definir T, hacemos que esta vista sea agnóstica al tipo de dato.
  2. @Binding: No poseemos el estado aquí. Modificamos el estado que vive en la vista padre (el formulario).
  3. Button en lugar de NavigationLink: Dentro de la lista, usamos botones. Al tocar la fila, no navegamos a otro lado; simplemente ejecutamos toggleSelection.
  4. Feedback Visual: El Spacer() empuja el checkmark a la derecha, imitando el estilo nativo de Apple.

Parte 4: El Componente “Picker” (La Fila del Formulario)

Ahora necesitamos la fila que vive en el formulario principal. Esta fila debe mostrar qué se ha seleccionado (o un resumen) y actuar como el enlace de navegación.

Llamaremos a esto MultiSelector.

struct MultiSelector<T: Identifiable & Hashable>: View {
    let title: String
    let options: [T]
    @Binding var selection: Set<T>
    let textRepresentation: (T) -> String
    
    var body: some View {
        NavigationLink(destination: MultiSelectionView(
            title: title,
            options: options,
            selection: $selection,
            textRepresentation: textRepresentation
        )) {
            HStack {
                Text(title)
                Spacer()
                // Mostrar resumen de selección
                Text(summary)
                    .foregroundColor(.gray)
                    .lineLimit(1)
            }
        }
    }
    
    // Propiedad computada para el texto resumen
    private var summary: String {
        if selection.isEmpty {
            return "Ninguno"
        } else {
            // Convertimos el Set a Array, ordenamos (opcional) y mapeamos a String
            let names = selection.map(textRepresentation)
            return names.joined(separator: ", ")
        }
    }
}

Este componente encapsula la complejidad. El desarrollador que use MultiSelector no necesita preocuparse por configurar la vista de detalle, solo pasa los datos.


Parte 5: Implementación Final (Poniéndolo todo junto)

Ahora, veamos cómo usaríamos esto en un escenario real dentro de nuestra ContentView.

struct ContentView: View {
    // Estado local para almacenar la selección
    @State private var selectedMembers: Set<TeamMember> = []
    
    var body: some View {
        NavigationStack {
            Form {
                Section(header: Text("Configuración del Proyecto")) {
                    TextField("Nombre del Proyecto", text: .constant(""))
                    
                    // Aquí usamos nuestro componente personalizado
                    MultiSelector(
                        title: "Asignar Miembros",
                        options: allMembers,
                        selection: $selectedMembers,
                        textRepresentation: { member in
                            return "\(member.name) (\(member.role))"
                        }
                    )
                }
                
                Section(header: Text("Resumen")) {
                    if selectedMembers.isEmpty {
                        Text("No hay miembros asignados.")
                            .italic()
                            .foregroundColor(.secondary)
                    } else {
                        ForEach(Array(selectedMembers), id: \.self) { member in
                            Label(member.name, systemImage: member.avatar)
                        }
                    }
                }
            }
            .navigationTitle("Nuevo Proyecto")
        }
    }
}

Parte 6: Elevando el Nivel (Búsqueda y Accesibilidad)

Un tutorial no estaría completo sin pulir los detalles que separan a un junior de un senior. Vamos a mejorar nuestra MultiSelectionView.

Añadiendo Barra de Búsqueda (.searchable)

Si la lista de opciones es larga (ej. países), el usuario necesita buscar. Gracias a SwiftUI, esto es trivial, pero debemos filtrar los datos correctamente.

Modifiquemos MultiSelectionView:

struct MultiSelectionView<T: Identifiable & Hashable>: View {
    // ... propiedades anteriores ...
    @State private var searchText = "" // Nuevo estado local

    // Filtramos las opciones dinámicamente
    var filteredOptions: [T] {
        if searchText.isEmpty {
            return options
        } else {
            return options.filter { item in
                textRepresentation(item).localizedCaseInsensitiveContains(searchText)
            }
        }
    }

    var body: some View {
        List {
            ForEach(filteredOptions) { item in
                Button(action: { toggleSelection(item) }) {
                    HStack {
                        Text(textRepresentation(item))
                        Spacer()
                        if selection.contains(item) {
                            Image(systemName: "checkmark")
                                .foregroundColor(.blue)
                        }
                    }
                }
                // Accesibilidad es clave
                .accessibilityElement(children: .combine)
                .accessibilityAddTraits(selection.contains(item) ? [.isSelected] : [])
            }
        }
        .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
        .navigationTitle(title)
    }
    // ... toggleSelection ...
}

Accesibilidad: Un Deber Moral y Técnico

Observa las líneas de accesibilidad añadidas arriba. Por defecto, VoiceOver leerá “Botón, Carlos Ruiz”. El usuario invidente no sabrá si está seleccionado o no solo por la imagen del checkmark (las imágenes son decorativas a menos que se indiquen lo contrario).

Al usar .accessibilityAddTraits(selection.contains(item) ? [.isSelected] : []), VoiceOver anunciará: “Carlos Ruiz, seleccionado, Botón”. Este pequeño detalle mejora drásticamente la usabilidad de tu app.


Parte 7: Personalización Avanzada de la Celda

Nuestro MultiSelector actual toma un closure simple (T) -> String para mostrar el texto. Pero, ¿y si queremos mostrar avatares, iconos o subtítulos complejos en la lista de selección?

Para hacer esto, necesitamos evolucionar nuestros Genéricos para aceptar una ViewBuilder.

Esta es una técnica avanzada. Transformaremos MultiSelectionView para que acepte un contenido de celda personalizado.

struct CustomMultiSelectionView<T: Identifiable & Hashable, Cell: View>: View {
    let title: String
    let options: [T]
    @Binding var selection: Set<T>
    // Closure que devuelve una Vista en lugar de un String
    let cellContent: (T) -> Cell 

    var body: some View {
        List(options) { item in
            Button(action: { toggleSelection(item) }) {
                HStack {
                    // Renderizamos la vista personalizada aquí
                    cellContent(item) 
                    
                    Spacer()
                    if selection.contains(item) {
                        Image(systemName: "checkmark")
                            .foregroundColor(.accentColor)
                    }
                }
            }
            .buttonStyle(.plain) // Importante para listas complejas
        }
        .navigationTitle(title)
    }
    
    // ... toggleSelection y demás lógica ...
}

Ahora, al llamar a este selector, podemos inyectar diseños complejos:

// Uso en el padre
CustomMultiSelectionView(
    title: "Equipo",
    options: allMembers,
    selection: $selectedMembers
) { member in
    // Aquí definimos el diseño de cada fila
    HStack {
        Image(systemName: member.avatar)
            .padding(8)
            .background(Color.blue.opacity(0.1))
            .clipShape(Circle())
        
        VStack(alignment: .leading) {
            Text(member.name).font(.headline)
            Text(member.role).font(.caption).foregroundColor(.secondary)
        }
    }
}

Esto transforma nuestro simple selector en un componente de UI de clase mundial, capaz de manejar listas ricas en contenido multimedia.


Consideraciones de Rendimiento y Errores Comunes

Para cerrar este tutorial, repasemos algunos puntos críticos que pueden hacer que tu implementación falle en producción.

1. Identificadores Estables

Asegúrate de que la propiedad id de tus objetos sea estable. Si usas UUID() generado dentro de una propiedad computada o en el init de una vista, SwiftUI perderá el rastro de la selección al refrescar la vista. Tus modelos de datos deben ser structs inmutables o clases con identificadores persistentes.

2. Grandes Conjuntos de Datos

Si vas a filtrar una lista de 10,000 elementos, el filtrado en tiempo real dentro del cuerpo de la vista (var filteredOptions: ...) puede causar lag en el tecleo.

  • Solución: Mueve la lógica de filtrado a un ViewModel (ObservableObject) y usa el operador .debounce de Combine o la nueva macro @Observable para retrasar la actualización de la lista hasta que el usuario deje de escribir por unos milisegundos.

3. Navegación en SwiftUI 4+

En el código usamos NavigationStack (disponible desde iOS 16). Si soportas versiones anteriores (iOS 14/15), deberás usar NavigationView. Sin embargo, NavigationStack maneja mucho mejor el estado de la memoria en listas profundas y es el estándar actual.


Conclusión

Crear componentes personalizados en SwiftUI puede parecer intimidante al principio, especialmente cuando venimos de sistemas donde todo nos viene dado. Sin embargo, la flexibilidad de crear tu propio Multi-Selector te da un control total sobre la experiencia de usuario.

Hemos pasado de:

  1. Entender la limitación del Picker nativo.
  2. Implementar una solución basada en Set para eficiencia matemática.
  3. Crear una arquitectura Genérica reutilizable.
  4. Añadir accesibilidad y búsqueda.
  5. Permitir celdas personalizadas con ViewBuilder.

Este componente no es solo un parche; es una herramienta robusta que puedes añadir a tu librería interna de UI (Design System) y utilizar en docenas de pantallas diferentes.

SwiftUI sigue evolucionando, y quizás en la WWDC del próximo año Apple nos regale un Picker(selection: Set<T>). Hasta entonces, ahora tienes el poder y el conocimiento para construirlo tú mismo, y mejor.

Leave a Reply

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

Previous Article

Cómo mostrar un Bottom Sheet en SwiftUI

Related Posts