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:
- 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”.
- 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 unSet(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é?
- 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.
- 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
- Generics
<T: ...>: Al definirT, hacemos que esta vista sea agnóstica al tipo de dato. @Binding: No poseemos el estado aquí. Modificamos el estado que vive en la vista padre (el formulario).Buttonen lugar deNavigationLink: Dentro de la lista, usamos botones. Al tocar la fila, no navegamos a otro lado; simplemente ejecutamostoggleSelection.- 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.debouncede Combine o la nueva macro@Observablepara 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:
- Entender la limitación del
Pickernativo. - Implementar una solución basada en
Setpara eficiencia matemática. - Crear una arquitectura Genérica reutilizable.
- Añadir accesibilidad y búsqueda.
- 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.







