SwiftUI – List

Qué es List

El componente List permite mostrar un listado vertical de elementos. A diferencia del VStack estos elementos ocupan todo el ancho del List y la carga de las vistas es lazy. Esto nos permite mejorar mucho el rendimiento, ya que el sistema solo cargará en memoria aquellos elementos visibles en pantalla y eliminará de memoria aquellos que dejen de ser visibles. De esta forma se consigue una manejo de memoria muy eficiente. Es un componente equivalente a UITableView de UIKit. Puedes consultar aquí la documentación oficial.

El componente List tiene varios inicializadores que se usarán dependiendo del tipo de lista y de cómo tengamos los datos a mostrar. En su forma más básica encontramos el siguiente caso:

List {
    ForEach(0..<20) {
        Text("Index: ($0)")
    }
}

Este es el caso más común en el que crearemos dentro del bloque ViewBuilder todas las vistas que necesitemos en base a la fuente de datos. Recuerda que para crear un bucle dentro de un ViewBuilder se debe usar el componente ForEach definido en SwiftUI para estos casos. Al hacerlo de esta forma podemos usar el componente Section dentro de List para crear secciones con un header y/o footer englobando los datos dentro de cada sección:

List {
    Section(header: Text("First Header"), footer: Text("First footer")) {
        ForEach(0..<10) {
            Text("Index: ($0)")
        }
        }
    Section(header: Text("Second Header"), footer: Text("Second footer")) {
        ForEach(10..<20) {
            Text("Index: ($0)")
        }
    }
}
.listStyle(InsetGroupedListStyle())

También tenemos otros inicializadores donde podemos incluir más parámetros para crear, por ejemplo, una lista colapsable:

struct RowItem: Identifiable {
    let id = UUID()
    var name: String
    var list: [RowItem]?
    
    static let mockupItems: [RowItem] = {
        var item1 = RowItem(name: "Section 1", list: [RowItem(name: "Option 1"),
                                                         RowItem(name: "Option 2"),
                                                         RowItem(name: "Option 3"),
                                                         RowItem(name: "Option 4"),
                                                         RowItem(name: "Option 5")
        ])
        
        var item2 = RowItem(name: "Section 2", list: [RowItem(name: "Option 6"),
                                                         RowItem(name: "Option 7"),
                                                         RowItem(name: "Option 8"),
                                                         RowItem(name: "Option 9"),
                                                         RowItem(name: "Option 10")
        ])
        
        return [item1, item2]
    }()
}

struct ListSimpleCollapsableView: View {
    let items: [RowItem] = RowItem.mockupItems
    
    var body: some View {
        List(items, children: .list) { children in
            Text(children.name)
        }
    }
}

Para este caso los listados de tipo RowItem que recibe el inicializador deben implementar el protocolo Identifiable, que necesita que cada elemento tenga una propiedad id que debe ser única para que el listado pueda identificar cada row. Al inicializar de esta forma el List perdemos la capacidad de poder usar el componente Section.

Modificadores comunes para List

El componente  List  comparte los mismos métodos de personalización que el componente  View  y pueden ser consultados en el  siguiente enlace

listStyle

Permite modificar el estilo visual de presentación de las celdas de List. El parámetro de entrada debe implementar el protocolo ListStyle. La SDK nos proporciona varias implementaciones que cambian el aspecto visual del componente:

PlainListStyle

List {
    ForEach(0..<30) {
        Text("Index: ($0)")
    }
}
.listStyle(PlainListStyle())

InserListStyle

List {
    ForEach(0..<30) {
        Text("Index: ($0)")
    }
}
.listStyle(InsetListStyle())

GroupListStyle

List {
    ForEach(0..<30) {
        Text("Index: ($0)")
    }
}
.listStyle(GroupedListStyle())

InsetGroupListStyle

List {
    ForEach(0..<30) {
        Text("Index: ($0)")
    }
}
.listStyle(InsetGroupedListStyle())

SidebarListStyle

List {
    ForEach(0..<30) {
        Text("Index: ($0)")
    }
}
.listStyle(SidebarListStyle())

listRowBackground

Este modificador se debe aplicar a las celdas del componente List. Si se aplica sobre List no tiene ningún efecto. Permite modificar el color de fondo de toda la celda. Para este caso el modificador background no nos sirve ya que pondría el color de fondo al componente pero no a la celda.

struct ContentView: View {
    
    var body: some View {
        List {
            ForEach(0..<30) {
                cell(index: $0)
            }
        }
    }
    
    func cell(index: Int) -> some View {
        VStack(alignment: .leading) {
            Text("Index")
                .font(.caption)
            Text("(index)")
                .font(.title2)
        }
        .listRowBackground((index % 2 == 0) ? Color.blue.opacity(0.3) : Color.orange.opacity(0.3))
    }
}

Cómo entrar en modo edición (EditMode) en un List

SwiftUI proporciona una funcionalidad por la cual se puede indicar a los componentes visuales que están en modo edición. Esto hará que se modifique su comportamiento permitiéndonos realizar nuevas acciones sobre los componentes. Para ello hay que modificar el valor de la variable de entorno EditMode. Esta variable se puede modificar de dos formas:

  • A través del botón EditButton que proporciona SwiftUI. Con este botón se podrá realizar el control del modo de edición de forma automática, ya que implementa todo lo necesario para entrar y salir de él. El botón se puede añadir a cualquier parte de la pantalla.
.navigationBarItems(trailing: EditButton())

A continuación vemos un ejemplo donde a través de este botón se permite eliminar celdas del List.

struct ContentView : View {
    @State private var items = ["Iron Man",
                                "The Incredible Hulk",
                                "Iron Man 2",
                                "Thor",
                                "Captain America",
                                "Marvel's The Avengers"]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: .self) {
                    Text($0)
                }
                .onDelete {
                    items.remove(atOffsets: $0)
                }
            }
        }
        .navigationBarItems(trailing: EditButton())
    }
    
}
  • Modificando la variable de entorno editMode manualmente y controlando sus diferentes estados. Es lo mismo que con el botón EditButton pero nos debemos encargar nosotros de actualizar el estado de la variable de entorno editMode. Esta variable se modifica con la siguiente función:
@State var isEditMode: EditMode = .inactive

...

.environment(.editMode, $isEditMode)

De esta forma cuando modifiquemos la variable de estado isEditMode también se modificará la variable de entorno editMode.

struct ContentView : View {
    @State var isEditMode: EditMode = .inactive
    @State private var items = ["Iron Man",
                                "The Incredible Hulk",
                                "Iron Man 2",
                                "Thor",
                                "Captain America",
                                "Marvel's The Avengers"]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: .self) {
                    Text($0)
                }
                .onDelete {
                    items.remove(atOffsets: $0)
                }
            }
        }
        .navigationBarItems(trailing:
                                Group {
                                    if isEditMode == .inactive {
                                        Button("Start Edit") {
                                            withAnimation {
                                                isEditMode = .active
                                            }
                                        }
                                        .padding(.all, 5)
                                        .background(Color.green.opacity(0.8))
                                    } else {
                                        Button("End Edit") {
                                            withAnimation {
                                                isEditMode = .inactive
                                            }
                                        }
                                        .padding(.all, 5)
                                        .background(Color.red.opacity(0.8))
                                    }
                                }
        )
        .environment(.editMode, $isEditMode)
    }
    
}

Para que veamos algún cambio visual al modificar el editMode es necesario que las vistas implementen alguno de los modificadores necesarios que afecta a este modo, como el onDelete en los ejemplos anteriores.

Operaciones comunes sobre el componente List

Los datos que se muestran en un componente List suelen ser datos dinámicos que son proporcionados por una base de datos o un web service externos a la aplicación. En muchas ocasiones estos datos que obtenemos son para que el usuario opere con ellos, añadiendo nuevos, seleccionándolos, modificando el orden o eliminándolos. A continuación veremos como realizar las siguientes operaciones que son muy comunes sobre un List

  • Navegación
  • Seleccionar elementos
  • Eliminar elementos
  • Mover elementos

Cómo navegar a una nueva pantalla al pulsar sobre una celda de un List

Si venimos de UIKit seguramente que conozcamos el método -tableView:didSelectRowAtIndexPath:. A través de este método obteníamos el indice seleccionado y con este dato decidíamos la acción que se debía ejecutar.

En SwiftUI para conseguir ese mismo efecto debemos implementar el método onTapGesture para tener un control similar al indicado anteriormente, pero si lo que queremos es navegar a una nueva pantalla la implementación correcta será implementar un NavigationLink. Aquí puedes consultar más  información sobre los componentes de navegación.

Al añadir un NavigationLink en una celda encontramos un comportamiento especial diferente de cuando lo usamos en otros sitios: le da a la celda un estilo visual único en el que añade una flecha a la derecha indicando que existe navegación.

struct ContentView: View {
    
    var body: some View {
        NavigationView {
            List {
                ForEach(0..<30) {
                    NavigationLink("Show: Go to index: ($0)", destination: Text("Index: ($0)"))
                }
            }
        }
    }
}

Este tipo de comportamientos es común en SwiftUI y se encuentra en otros componentes (como Form).

Con esta implementación conseguimos que toda la celda se pueda pulsar y ejecutará la acción de navegación.

Hay otras ocasiones donde no queremos que se vea esa flecha que añade el NavigationLink. Para estos casos necesitamos que el NavigationLink sea un EmptyView y lo tendremos que añadir como overlay a la primera vista de la celda.

struct ContentView: View {

    var body: some View {
        NavigationView {
            List {
                ForEach(0..<30) {
                    cell($0)
                }
            }
        }
    }
    
    func cell(_ index: Int) -> some View {
        HStack {
            Text("Empty: Go to index: (index)")
        }
        .overlay(
            NavigationLink(destination: Text("Index: (index)")) {
                EmptyView()
            }
        )
    }
}

Con esta implementación también conseguimos que toda la celda se pueda pulsar para ejecutar la acción del NavigationLink.

Por último, nos podemos encontrar otros casos donde queremos que la navegación sólo se ejecute al pulsar un botón o una imagen y no toda la celda. Para estos casos seguiremos usando un NavigationLink pero tendremos que realizar un control manual sobre él para controlar correctamente la navegación.

  • De forma similar al caso anterior añadiremos un NavigationLink con un EmptyView como overlay de la celda, pero es importante que se le añada el modificador hidden.
  • En la inicialización del NavigationLink tendremos que usar los parámetros tag y selection:
    • tag: Identificador único de la celda. Puede ser un ID o el propio indice
    • selection: Variable de estado del mismo tipo que el tag, pero opcional.
  • Añadiremos un Button que será quien desencadene la navegación. La acción de este Button consistirá en modificar la variable de estado selection para setear el valor del tag al que queremos realizar la navegación.

Todo esto nos deja la siguiente implementación:

struct ContentView: View {
    @State private var navigateToNext: Int?
    
    var body: some View {
        NavigationView {
            List {
                ForEach(0..<30) {
                    cell($0)
                }
            }
        }
    }
    
    func cell(_ index: Int) -> some View {
        HStack {
            Button("Hidden: Go to index: (index)") {
                navigateToNext = index
            }
            .buttonStyle(BorderlessButtonStyle())
        }
        .overlay(
            NavigationLink(
                destination: Text("Index: (index)"),
                tag: index,
                selection: $navigateToNext) {
                EmptyView()
            }
            .hidden()
        )
    }
}

A continuación vemos un ejemplo con estos tres casos incluidos:

struct ContentView: View {
    @State private var navigateToNext: Int?
    
    var body: some View {
                NavigationView {
                List {
                    ForEach(0..<30) {
                        if $0 < 10 {
                            navigationLinkShow($0)
                        } else if $0 < 20 {
                            navigationLinkEmpty($0)
                        } else {
                            navigationLinkHidden($0)
                        }
                    }
                }
        }
    }
    
    
    func navigationLinkShow(_ index: Int) -> some View {
        NavigationLink("Show: Go to index: (index)", destination: Text("Index: (index)"))
    }
    
    func navigationLinkEmpty(_ index: Int) -> some View {
        HStack {
            Text("Empty: Go to index: (index)")
        }
        .overlay(
            NavigationLink(destination: Text("Index: (index)")) {
                EmptyView()
            }
        )
    }
    
    func navigationLinkHidden(_ index: Int) -> some View {
        HStack {
            Button("Hidden: Go to index: (index)") {
                navigateToNext = index
            }
            .buttonStyle(BorderlessButtonStyle())
        }
        .overlay(
            NavigationLink(
                destination: Text("Index: (index)"),
                tag: index,
                selection: $navigateToNext) {
                EmptyView()
            }
            .hidden()
        )
    }
}

Como permitir la selección de celdas en un List (simple y múltiple)

Para implementar la selección de celdas en un List tendremos que usar el parámetro selection que podemos encontrar en su inicializador.

@State var selection: String?

...

List(selection: $selection) {
    ForEach(items, id: .self) {
        Text($0)
    }
}

La variable selection deberá ser del mismo tipo que la variable id del ForEach encargado de devolver cada celda. Hay otras ocasiones en las que el ForEach no especifica explícitamente la variable id. Esto ocurre cuando iteramos sobre un listado que implementa el protocolo Identifiable, siendo implícitamente su id el propio id del ForEach.

De esta forma el comportamiento que obtendremos será que el valor de la variable selection cambiará cada vez que se seleccione una nueva celda. Este ejemplo es para la selección de un único elemento del List. Si queremos seleccionar varios elementos habría que cambiar el tipo de la variable selection a una colección:

@State var selection = Set()

Ejemplo de selección simple

struct ContentView : View {
    @State var selection: UUID?
    
    @State private var items = Film.mockFilms
    
    var body: some View {
        VStack(spacing: 15) {
            List(selection: $selection) {
                ForEach(items) {
                    Text($0.name)
                }
                
            }
            if let uuid = selection, let film = items.first(where: { $0.id == uuid }) {
                VStack(spacing: 5) {
                    Text("Your select:")
                        .font(.headline)
                    Text("(film.name)")
                        .font(.body)
                }
                .padding()
            }
        }
        .navigationBarItems(trailing: EditButton())
    }
    
    struct Film: Identifiable {
        let id: UUID = UUID()
        
        var name: String
        
        static let mockFilms = [Film(name: "Iron Man"),
                                Film(name: "The Incredible Hulk"),
                                Film(name: "Iron Man 2"),
                                Film(name: "Thor"),
                                Film(name: "Captain America: The First Avenger"),
                                Film(name: "Marvel's The Avengers")]
    }
}

Ejemplo de selección multiple

struct ListSelectionMultipleView : View {
    @State var selection = Set()
    
    @State private var items = Film.mockFilms
    
    var body: some View {
        VStack(spacing: 15) {
            List(selection: $selection) {
                ForEach(items) {
                    Text($0.name)
                }
                
            }
            showSelectedFilms()
        }
        .navigationBarItems(trailing: EditButton())
    }
    
    func showSelectedFilms() -> some View {
        if !selection.isEmpty {
            let result = items.compactMap { (film) -> Film? in
                selection.contains(film.id) ? film : nil
            }.map {
                return $0.name
            }
            return AnyView(
                VStack(spacing: 5) {
                    Text("Your select:")
                        .font(.headline)
                    Text("(result.joined(separator: ", "))")
                        .font(.body)
                }
                .padding())
        } else {
            return AnyView(EmptyView())
        }
    }
    
    struct Film: Identifiable {
        let id: UUID = UUID()
        
        var name: String
        
        static let mockFilms = [Film(name: "Iron Man"),
                                Film(name: "The Incredible Hulk"),
                                Film(name: "Iron Man 2"),
                                Film(name: "Thor"),
                                Film(name: "Captain America: The First Avenger"),
                                Film(name: "Marvel's The Avengers")]
    }
}

Cómo eliminar y mover elementos en un List

Para eliminar y mover elementos en un List se usa una implementación similar. En ambos casos la implementación se realiza sobre el ForEach encargado de iterar sobre el listado de elementos que queremos mostrar. Este componente nos proporciona los siguientes métodos:

  • onDelete. Permite controlar los eventos de borrado sobre una colección
  • onMove. Permite controlar los eventos de reordenación sobre una colección.

La implementación consiste en añadir estos métodos al ForEach y modificar la colección original (que será una variable de estado o similar) en base a los parámetros recibidos en cada método.

  • El método onDelete nos proporciona los indices que se deben eliminar de la colección
  • El método onMove nos proporciona los indices originales de las celdas que se deben mover y los indices de donde se deben mover.

En ambos casos la implementación es muy sencilla:

onDelete

@State private var items = Film.mockFilms

...

List {
        ForEach(items) {
            Text($0.name)
        }
        .onDelete {
            items.remove(atOffsets: $0)
        }
}

onMove

@State private var items = Film.mockFilms

...

List {
    ForEach(items) {
        Text($0.name)
    }
    .onMove {
        items.move(fromOffsets: $0, toOffset: $1)
    }
}

Ejemplo completo

struct ContentView : View {
    @State var isEditMode: EditMode = .inactive
    @State private var items = Film.mockFilms
    
    var body: some View {
        VStack(spacing: 15) {
            List {
                ForEach(items) {
                    Text($0.name)
                }
                .onDelete {
                    items.remove(atOffsets: $0)
                }
                .onMove {
                    items.move(fromOffsets: $0, toOffset: $1)
                }
            }
            Group {
                if isEditMode == .inactive {
                    Button("Start Edit") {
                        withAnimation {
                            isEditMode = .active
                        }
                    }
                    .padding()
                    .background(Color.green.opacity(0.3))
                } else {
                    Button("End Edit") {
                        withAnimation {
                            isEditMode = .inactive
                        }
                    }
                    .padding()
                    .background(Color.red.opacity(0.3))
                }
            }
            .padding()
        }
        .navigationBarItems(trailing: EditButton())
        .environment(.editMode, $isEditMode)
        .navigationBarTitle("Edit", displayMode: .inline)
        .navigationColor(background: UIColor(red: 31/255, green: 155/255, blue: 222/255, alpha: 1), title: .white)
    }
    
    
    struct Film: Identifiable {
        let id = UUID()
        
        var name: String
        
        static let mockFilms = [Film(name: "Iron Man"),
                                Film(name: "The Incredible Hulk"),
                                Film(name: "Iron Man 2"),
                                Film(name: "Thor"),
                                Film(name: "Captain America"),
                                Film(name: "Marvel's The Avengers")]
    }
}

Ejemplo

Puedes encontrar este ejemplo en https://github.com/SDOSLabs/SwiftUI-Test bajo el apartado List.

Rafael Fernández,
iOS Tech Lider